Update from source 7077c5550
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-28 03:53:39 +00:00
parent fb5016405d
commit b94ba25c27
13 changed files with 530 additions and 78 deletions

View file

@ -12,6 +12,9 @@ Built with love, with the prayer that these maps bless the nations.
iframe, script embed, mobile app — or open every map at once as one app. Five plugin
capabilities, three outputs, one shared library, one build.
> **Using an AI agent?** Give it [`skills/plugin-designer`](./skills/plugin-designer). It learns
> this framework's terms and patterns, which lowers the barrier to entry.
### What the library gives every map
| | |

View file

@ -66,6 +66,8 @@ there. Only then does it swap the new build in.
| `OIDC_ADMIN_GROUPS` | comma-separated groups allowed to build and to open PocketBase's dashboard |
| `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | PocketBase's superuser, set on start in both instances |
| `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https://<host>/pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https://<host>/pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` |
| `PRODUCTION_BRANCH` | the branch `/cdn/` is built from (default `production`); the dev server keeps following `BRANCH`. Promote with `git push origin main:production` |
| `CDN_AUTOBUILD_MINUTES` | rebuild `/cdn/` by itself once `PRODUCTION_BRANCH` has held a new commit this long (default `2`; `0` = only the admin button) |
| `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https://<host>/api-prod`) |
| `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) |
| `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech |

View file

@ -3,7 +3,8 @@
// /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard
// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET,
// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections
// /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET
// /admin/build POST: build PRODUCTION_BRANCH now (OIDC_ADMIN_GROUPS only); /admin/status GET.
// The public build also follows PRODUCTION_BRANCH by itself (CDN_AUTOBUILD_MINUTES, default 2).
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
// Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only.
import { spawn } from 'node:child_process'
@ -25,6 +26,45 @@ async function proxy(req, url, prefix, port) {
return new Response(r.body, { status: r.status, headers: r.headers })
}
// One public build at a time (build-cdn.sh also holds a lock), started by an admin or by the watcher below.
function startBuild(by) {
last = { state: 'building', by, branch: PROD, started: new Date().toISOString() }
const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', (d) => { log = (log + d).slice(-4000) })
child.stderr.on('data', (d) => { log = (log + d).slice(-4000) })
child.on('exit', (code) => {
last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', by, branch: PROD, code, finished: new Date().toISOString(), tail: log.slice(-1500),
commit: publishedCommit() }
})
}
const publishedCommit = () => (existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null)
// AUTO-BUILD: the public build follows PRODUCTION_BRANCH by itself. main is the shared sandbox; production
// moves only when someone promotes (git push origin main:production), so rebuilding on every move is
// the point. Debounced: build once the branch has held the same commit for CDN_AUTOBUILD_MINUTES
// (default 2; 0 = off, admin button only). A commit whose build failed is not retried automatically.
const PROD = env.PRODUCTION_BRANCH || 'production'
const AUTO_MIN = env.CDN_AUTOBUILD_MINUTES === undefined || env.CDN_AUTOBUILD_MINUTES === '' ? 2 : Number(env.CDN_AUTOBUILD_MINUTES)
const watch = { seen: null, since: 0, failed: null, error: null }
async function checkProduction() {
const r = Bun.spawnSync(['git', '-C', '/app/repo', 'ls-remote', 'origin', `refs/heads/${PROD}`], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
const sha = r.success ? String(r.stdout).split(/\s/)[0] : ''
watch.error = sha ? null : `cannot read branch '${PROD}' on origin`
if (!sha) return
if (sha !== watch.seen) { watch.seen = sha; watch.since = Date.now() }
const published = publishedCommit()
if (published && sha.startsWith(published)) return
if (last.state === 'building' || watch.failed === sha) return
if (Date.now() - watch.since < AUTO_MIN * 60000) return
startBuild('auto')
const poll = setInterval(() => { if (last.state !== 'building') { clearInterval(poll); if (last.state === 'failed') watch.failed = sha } }, 5000)
}
if (AUTO_MIN > 0) {
setInterval(() => checkProduction().catch(() => {}), 30000)
console.log(`[admin] public build follows ${PROD}, ${AUTO_MIN} min after it moves`)
}
Bun.serve({
hostname: '127.0.0.1',
port: 5175,
@ -54,20 +94,12 @@ Bun.serve({
}
// A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).
if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
if (p === '/admin/status') return Response.json(last)
if (p === '/admin/status') return Response.json({ ...last, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), error: watch.error } })
if (p === '/admin/build') {
if (req.method !== 'POST') return text('POST only', 405)
if (!isAdmin(req)) return text('builds are for team admins', 403)
if (last.state === 'building') return text('a build is already running', 409)
last = { state: 'building', started: new Date().toISOString() }
const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', (d) => { log = (log + d).slice(-4000) })
child.stderr.on('data', (d) => { log = (log + d).slice(-4000) })
child.on('exit', (code) => {
last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', code, finished: new Date().toISOString(), tail: log.slice(-1500),
commit: existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null }
})
startBuild('admin')
return text('build started', 202)
}
return text('not found', 404)

View file

@ -1,6 +1,8 @@
#!/bin/bash
# build-cdn.sh — one production build of the pulled commit into /data/cdn, one at a time.
# snapshot (so the pull loop can't change files mid-build) -> apply the public exclusions ->
# build-cdn.sh — one public build of the PRODUCTION branch into /data/cdn, one at a time.
# main is the shared sandbox the dev server follows; the public build comes only from
# PRODUCTION_BRANCH (default production), which moves when someone promotes main to it.
# fetch that branch into its own ref -> snapshot it -> apply ITS public exclusions ->
# the build steps WITHOUT the gates (memory-capped) -> check the output -> the exclusion check again
# (refuses the swap) -> atomic swap of /data/cdn/current.
# No gates here: they already passed before the commit reached main, and a full gate run on a
@ -12,11 +14,26 @@ exec 9>/tmp/build-cdn.lock
flock -n 9 || { echo "another build is running"; exit 75; }
echo $$ > /tmp/build-cdn.pid
cd /app/repo
SHA=$(git rev-parse --short HEAD); SNAP=/tmp/build-$SHA
PB="${PRODUCTION_BRANCH:-production}"
# Its own ref, and no FETCH_HEAD: the dev server's pull loop uses FETCH_HEAD for main at the same time.
git fetch -q --no-write-fetch-head --depth 1 origin "+refs/heads/$PB:refs/cdn/$PB" \
|| { echo "cannot fetch branch '$PB' (create it with: git push origin main:$PB)"; exit 1; }
SHA=$(git rev-parse --short "refs/cdn/$PB"); SNAP=/tmp/build-$SHA
rm -rf "$SNAP"; mkdir -p "$SNAP"
git archive HEAD | tar -x -C "$SNAP" || { echo "snapshot failed"; exit 1; }
bash /app/repo/deployment/cloud/apply-exclusions.sh "$SNAP"
ln -s /app/repo/node_modules "$SNAP/node_modules"
git archive "refs/cdn/$PB" | tar -x -C "$SNAP" || { echo "snapshot failed"; exit 1; }
# The exclusion list and checker come from the branch being published, so loosening them takes a
# promotion too (falls back to the dev server's copy for a branch older than the list).
SRC="$SNAP/deployment/cloud"; [ -f "$SRC/apply-exclusions.sh" ] && [ -f "$SRC/public-exclusions.txt" ] || SRC=/app/repo/deployment/cloud
EXDIR=/tmp/exclusions-$SHA; rm -rf "$EXDIR"; mkdir -p "$EXDIR"
cp "$SRC/apply-exclusions.sh" "$SRC/public-exclusions.txt" "$EXDIR/" # the script reads the list beside it
bash "$EXDIR/apply-exclusions.sh" "$SNAP"
# Dependencies: reuse the dev server's install when the lockfile is the same, else install for this commit.
if cmp -s "$SNAP/bun.lock" /app/repo/bun.lock; then
ln -s /app/repo/node_modules "$SNAP/node_modules"
else
echo "$(date -u +%FT%TZ) $PB's lockfile differs from main's: installing its dependencies"
(cd "$SNAP" && bun install --frozen-lockfile >/dev/null) || { echo "dependency install failed"; rm -rf "$SNAP"; exit 1; }
fi
cd "$SNAP"
echo "$(date -u +%FT%TZ) building $SHA"
export NODE_OPTIONS="--max-old-space-size=${BUILD_MAX_OLD_SPACE_MB:-1536}"
@ -28,9 +45,9 @@ OUT="$SNAP/public/dx-maps"
bun -e 'console.log("window.MAP_API_BASE=" + JSON.stringify(process.env.PUBLIC_API_BASE || "") + ";")' > "$OUT/runtime-config.js"
NEW=/data/cdn/build-$SHA
rm -rf "$NEW"; mkdir -p /data/cdn; mv "$SNAP/public" "$NEW"
bash /app/repo/deployment/cloud/apply-exclusions.sh --check "$NEW" || { echo "REFUSED: excluded content in the build; not published"; rm -rf "$NEW" "$SNAP"; exit 1; }
bash "$EXDIR/apply-exclusions.sh" --check "$NEW" || { echo "REFUSED: excluded content in the build; not published"; rm -rf "$NEW" "$SNAP" "$EXDIR"; exit 1; }
ln -sfn "$NEW" /data/cdn/current.tmp && mv -T /data/cdn/current.tmp /data/cdn/current
echo "$SHA" > /data/cdn/COMMIT
find /data/cdn -maxdepth 1 -name 'build-*' ! -path "$NEW" -exec rm -rf {} +
rm -rf "$SNAP"
echo "$(date -u +%FT%TZ) published $SHA at /cdn/dx-maps/"
rm -rf "$SNAP" "$EXDIR"
echo "$(date -u +%FT%TZ) published $SHA ($PB) at /cdn/dx-maps/"

View file

@ -64,10 +64,24 @@ Details: [deployment/cloud/README.md](../deployment/cloud/README.md) and
The built folder, `dx-maps/`, is what other sites embed. Every link inside it is relative, so it works
from any address.
- **From the cloud dev server:** an admin runs a build (`POST /admin/build`; about 2 minutes). It builds
the current commit, removes private files, refuses to publish if any remain, then swaps
- **From the cloud dev server:** `/cdn/` serves the **`production`** branch, never `main`. `main` is the
shared sandbox where everyone lands all day; `production` moves only when you decide the current state
is ready to be public. The container notices when `production` moves and rebuilds `/cdn` by itself
(about 2 minutes after the move, then about 2 minutes to build). Each build removes private files,
refuses to publish if any remain, keeps the last good build if anything fails, and swaps
`/cdn/dx-maps/` over atomically. `/cdn/` is public (no sign-in) and sends
`Access-Control-Allow-Origin: *`, so both iframes and JS embeds work on other sites.
**To publish (promote main to production)**, once the checks pass on the commit you want public:
```bash
git fetch origin && git checkout main && git merge --ff-only origin/main
bun run gates:pre # the checks must pass on exactly this commit
git push origin main:production # /cdn follows within a few minutes
```
An admin can also rebuild at once (`POST /admin/build`), and `/admin/status` shows what `/cdn` serves
and what `production` points at.
- **On any static host:** build locally with `BUILD_ALLOW=1 bun run build` and upload `public/dx-maps/`.
For JS embeds on other sites, the host must send `Access-Control-Allow-Origin: *`.

View file

@ -10,8 +10,8 @@ served folder". The licence travels with the asset; the explanation travels with
| Item | Source | Licence | Does it travel? | Gap |
|---|---|---|---|---|
| Fonts: bebas-neue, figtree, noto-serif-devanagari, oswald, poppins | Google Fonts / foundry repos | OFL-1.1 | **Yes** — each `LICENSE` is the family's own upstream OFL, verbatim, with its real copyright line | none (closed 2026-09-28) |
| `vendor/vue/vue.global.prod.js` | vuejs/core | MIT | **Partly** — the minified banner carries `(c) 2018-present Yuxi (Evan) You` and `@license MIT`, but NOT the permission notice MIT asks to include | closed: upstream `LICENSE` now vendored beside it |
| `vendor/tabulator/tabulator.min.js` + `.min.css` | olifolkerd/tabulator v6.3.1 | MIT | **No** — the `.js` had only `(c) Oliver Folkerd 2025`; the `.css` had nothing | closed: upstream `LICENSE` now vendored beside it |
| `data/vendor/vue/vue.global.prod.js` | vuejs/core | MIT | **Partly** — the minified banner carries `(c) 2018-present Yuxi (Evan) You` and `@license MIT`, but NOT the permission notice MIT asks to include | closed: upstream `LICENSE` now vendored beside it |
| `data/vendor/tabulator/tabulator.min.js` + `.min.css` | olifolkerd/tabulator v6.3.1 | MIT | **No** — the `.js` had only `(c) Oliver Folkerd 2025`; the `.css` had nothing | closed: upstream `LICENSE` now vendored beside it |
| `boundaries/gb-IND-ADM1*.geojson` (4 files) | geoBoundaries gbOpen, boundaryID `IND-ADM1-1811400` | **CC BY 2.5 IN**, licenceSource `github.com/datameet/maps`, year 2011 — fetched verbatim from the geoBoundaries API | **Yes** — `boundaries/ATTRIBUTION` now ships beside the data, plus the existing `.meta.json` sidecar | none (closed 2026-09-28) |
| `boundaries/gb-CHN-ADM1.geojson` | geoBoundaries gbOpen, boundaryID `CHN-ADM1-43563684` | **Public Domain**, licenceSource `commons.wikimedia.org`, year 2019 | recorded in `boundaries/ATTRIBUTION` | none — upstream records no attribution duty |
| `boundaries/world-adm0*.geojson` (2 files, 162 countries) | geoBoundaries gbOpen, assembled per country (its features carry geoBoundaries' own `iso3`/`shapeGroup`/`shapeName`; corrected — this register first guessed Natural Earth) | **MIXED, and ESTABLISHED per country as of 2026-09-28**: 162 upstream licences. **32** duty-free (28 Public Domain + 4 CC0) · **65** attribution-only · **65 SHARE-ALIKE CLASS** (58 ODbL 1.0 + 7 explicit ShareAlike: AGO GAB GEO GIN MMR NER TUR) | **Yes** — the per-country table (licence + licenseSource + boundaryID + boundarySource, fetched verbatim from the geoBoundaries API, 162 of 162, 0 failures) ships at `plugin-designs/my-upg-100-list/data/adm0/PROVENANCE.json`. `boundaries/ATTRIBUTION` references it by path rather than copying it | **OPEN, legal — not a measurement**: whether a redistributed boundary file is a "Derivative Database" under ODbL is unanswered, and half the duty-bearing countries turn on it. With the agent's data-licensing item |

View file

@ -52,7 +52,7 @@ once, never hand-written.
| **iframe / direct link** | point at the generated `<profile>/index.html` | zero-effort embeds, CMSs that only allow iframes, shared links |
Both are supported on **built** pages. The one "never iframe" rule applies only to the **dev
staging** script (`internal/staging.js`), which mounts elements directly because a dev-server
staging** script (the archived repo-root staging page, removed in afb998f17 — the plugin studio is the only entry now), which mounts elements directly because a dev-server
iframe crosses realms (`instanceof` breaks). Built pages have no such problem — the generated
staging (`staging-forge.js`) itself uses an iframe stage.

View file

@ -52,7 +52,7 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/`
| `library/components/legends/LegendMobile.vue` | Mobile bottom-sheet legend chrome (drag/collapse/detail) wrapping the same LegendRows/SemanticTreeLegend/PeopleGroupDetail. | All CSS lives in a useShadowStyles template literal — a stray backtick in its comments silently blanks the whole mobile stylesheet. |
| `library/components/legends/LegendRows.vue` | Flat-grid legend table: colored clickable filter rows, count/population columns, collapsible children, sticky title + totals footer. Emits filter-click. | ONE flat CSS grid with subgrid rows (spec: documentation/legend-spec.md); `.lrg-item` wrappers are display:contents on desktop only for LegendMobile compat — don't add layout to them. Never use scrollIntoView (host-page scroll regression 554bf10). |
| `library/components/legends/LegendTools.vue` | Floating pill toolbar (auto-fly tour + clusters toggle) above the desktop legend card. Emits legend-row-fly. | Only renders for doxa-regions / language-family legend types; must stay a SIBLING of LegendDesktop (own `.lt-` CSS namespace); flight/cluster state lives in injected mapStore; hidden below 768px. |
| `library/components/legends/SemanticTreeLegend.vue` | Hierarchical tabbed legend panel (collapsible, sortable, drill-up/down tree). Props: nodes, tabs, columns, open, exportEnabled; emits select/update:open/export. | Syncs selection via the per-instance mediator (usePplrInstance) when present, else local state; styles via useShadowStyles. Hidden kill-switch `SOW_EXPORT_BUTTON=false` hides the fully-wired Export menu across ALL bundles regardless of `:export-enabled`. |
| `library/components/legends/SemanticTreeLegend.vue` | Hierarchical tabbed legend panel (collapsible, sortable, drill-up/down tree). Props: nodes, tabs, columns, open, exportEnabled; emits select/update:open/export. | Syncs selection via the per-instance mediator (usePplrInstance) when present, else local state; styles via useShadowStyles. |
### components/map-controls/
@ -89,7 +89,6 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/`
|---|---|---|
| `library/composables/useAvatarGenerator.js` | Single-letter avatar generator (letter + 8-color palette by charCode). Exports generateAvatar, generateAvatarHTML, getAvatarStyle. | Pure functions, no reactivity; AVATAR_COLORS is hardcoded here, not in colors/_registry; generateAvatarHTML interpolates into raw HTML. |
| `library/composables/useCountryOutline.js` | Instant outline-only country highlight using Mapbox country-boundaries-v1 vector tiles. Returns showCountry(iso3)/clearCountry(). | Singleton source/layer per map (repeats swap the filter); worldview filter required or outlines draw 2-3x; camera framing is the caller's job; ops are try/catch no-ops pre-style-ready. |
| `library/composables/useDoxaSearch.js` | Local geocoder (localGeocoder for MapboxGeocoder) over 11 categories with tab-context ordering and Carmen-GeoJSON output. Returns search, searchGrouped, index, aggregates. | Call during setup (uses useI18n). Result shape is contract-bound to GeocoderComponent + research-map's onGeocoderAggregateResult; religions collapse to the legend's 10 families; ALLOWED_BY_CONTEXT keys must match TAB_SEARCH_CONTEXT strings in profiles. |
| `library/composables/useGeocoderSearch.js` | The single copy of geocoder-result → map-selection logic (kind-switch, per-kind filter builder, legend-tab routing, clear handling). Returns onPeopleGroupResult, onAggregateResult, onClear, clearGeocoderProgrammatic. | Kind→property map + filter builder live ONLY here — never re-implement in a profile; legendRouters injects divergent per-profile logic; busAdapter reroutes handlers to a non-map sink. Always clear via clearGeocoderProgrammatic (feedback-loop guard). |
| `library/composables/useMapClustering.js` | Three clustering modes (mapbox bubbles, MST lines, network lines) behind one API. Returns enabled, mode, setMode, applyClustering, clearClustering, cleanup, … | inject('mapStore') — never import stores directly; clear/toggle hides layers + setData(EMPTY_FC) instead of removing (GL heap-leak avoidance); only cleanup() removes sources/layers — call from host onBeforeUnmount. |
| `library/composables/useMapData.js` | Loads people-group data via injected DataSourceManager, canonicalizes languageFamily/region keys, builds derived legend datasets. Returns loadData, peopleGroups, normalizedPeopleGroups, regionsData, stats, …. | Pass dataSourceManager + markRaw in options. Language family MUST derive from ISO 639-3 code (langFamilyByIso), never localized labels; doxa-region registry reads are lazy and guarded on propertyKey==='doxaRegion'; regions map ISO alpha-3 → stable slug. |
@ -137,7 +136,7 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/`
|---|---|---|
| `library/data/iso-numeric-to-alpha2.json` | ISO 3166 numeric country code → alpha-2 lookup. | Currently has NO code consumers (docs mention only). Importable via `@map/data/*.json`. |
| `library/data/langFamilyByIso.json` | ISO 639-3 language code → language-family name lookup. | Generated from the EN API + langFamilyByLanguage.json; the canonical family-derivation source (labels are locale-fragile). |
| `library/data/langFamilyByLanguage.json` | English language name → family name lookup. | Its lookup logic (reversed-name fallback, stripped variants) is duplicated in 4 composables (useMapData, useMapLayers, useDoxaSearch, useLanguageFamilyLegendData) — keep them consistent. |
| `library/data/langFamilyByLanguage.json` | English language name → family name lookup. | Its lookup logic (reversed-name fallback, stripped variants) is duplicated in 4 composables (useMapData, useMapLayers, useLanguageFamilyLegendData) — keep them consistent. |
### i18n/
@ -186,9 +185,9 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/`
| `plugin-designs/doxa-research-map/index.js` | Bundle entry: globs profiles + colors, registers strategies, sets up the RTL plugin, defines `<doxa-research-map>`. | Globs are relative to THIS file — profile .vue at bundle root, strategies in src/colors/, or they silently don't register. Deliberately does NOT register legacy `<doxa-map>` (owned by doxa-simple-map; one-shot customElements.define). |
| `plugin-designs/doxa-research-map/index.html` | Staging showcase: gem-framed instances from a flat DOXA_SLOTS array, profile tabs, overview grid. | Slot `profile` must match a .vue filename at bundle root; profile + tk (+ ?locale=) auto-injected into config; ./index.js script tag rewritten to the IIFE at build. |
| `plugin-designs/doxa-research-map/research-map.vue` | The research-map profile (~2100 lines): 7-tab map (prayer/adoption/engagement/people-groups/regions/languages/religions) with SemanticTreeLegend, geocoder, filters, detail panel, lazy poster/packet export. | Tabs are hardcoded in ALL_TABS/FEATURES (NOT profile-config); config arrives only via injections; maxZoom pinned to 22 deliberately; heavy deps (poster, packet, clustering, detail panel, mobile legend) are lazy-loaded — keep them off the boot path. |
| `plugin-designs/doxa-research-map/src/colors/affinity-block.js` | Strategy mapping ROP1 affinity-block codes (A001–A017) to names and pin colors. | ROP1 code is the primary key (name lookups are lossy legacy); reads properties.affinityBlock falling back to `_raw.ROP1`; keep NAMES and PALETTE keys in sync; A016 intentionally absent. |
| `plugin-designs/doxa-research-map/src/colors/doxa-region.js` | Strategy coloring pins by DOXA/WAGF region; SLUG_PALETTE is authoritative. | Color by slug, never localized display labels (break in non-EN locales); uses @map/utils/geoUtils generateColorFromString as fallback. |
| `plugin-designs/doxa-research-map/src/colors/resource.js` | Binary green/black coloring by gospel-resource availability + per-resource palette for the legend. | Default coloring is intentionally binary despite the per-resource PALETTE; resource values must literally equal 'Available' (properties or _raw CSV columns via getCSVColumn); legacy aliases kept for compat. |
| `library/colors/affinity-block.js` | Strategy mapping ROP1 affinity-block codes (A001–A017) to names and pin colors. | ROP1 code is the primary key (name lookups are lossy legacy); reads properties.affinityBlock falling back to `_raw.ROP1`; keep NAMES and PALETTE keys in sync; A016 intentionally absent. |
| `library/colors/doxa-region.js` | Strategy coloring pins by DOXA/WAGF region; SLUG_PALETTE is authoritative. | Color by slug, never localized display labels (break in non-EN locales); uses @map/utils/geoUtils generateColorFromString as fallback. |
| `library/colors/resource.js` | Binary green/black coloring by gospel-resource availability + per-resource palette for the legend. | Default coloring is intentionally binary despite the per-resource PALETTE; resource values must literally equal 'Available' (properties or _raw CSV columns via getCSVColumn); legacy aliases kept for compat. |
### doxa-simple-map/
@ -221,7 +220,6 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/`
| `internal/generate-library-index.mjs` | Scans live library/ code and regenerates documentation/reference-library-index.md (one-line import catalog). | Output is DERIVED — never hand-edit the index; rerun after any library/ change; descriptions come from each file's first header-comment line. |
| `internal/drop-portability-check.mjs` | CLI gate (`check:drop`): scans built .html/.json for root-relative refs and fails the build if any would break subpath/Drop hosting. | Defaults treeDir to `public/<name>` from the build config — build first; only checks .html/.json and a fixed manifest key list (iframeUrl\|scriptUrl\|url\|embedUrl\|output) — new URL keys need adding here. |
| `internal/inline-parse-check.mjs` | Post-build verifier (`check:parse`): syntax-checks every inline classic `<script>` in the built HTML via `node --check`. | Only matches bare `<script>` (no attributes), checked as classic scripts — import.meta or a literal `</script>` in snippets is exactly the bug class it catches. |
| `internal/staging.js` | Dev-only "Embeddable JAMstack Maps" showcase script: auto-discovers bundles via import.meta.glob, renders bundle tabs + live map tiles with lazy WebGL-guarded mounting and a locale switcher. | A bundle appears only if index.js exists and its tag is regex-scraped from customElements.define; map slots come from parsing window.*SLOTS / profile-config attrs. Never iframe the maps (cross-realm instanceof fails); MAX_LIVE=4 with forced WEBGL_lose_context eviction. |
| `internal/staging-forge.js` | Client script for the generated staging pages: semantic bundle/profile/instance tree sidebar + live map iframe stage + token paste-once UX. | Must stay file://-safe (no import/export; manifest inlined as window.FORGE.manifest); the DEV-HMR block is stripped by generate-tree.mjs — keep the DEV-HMR-START/END markers intact. |
| `internal/staging-forge.css` | Dark "Embeddable JAMstack Maps" theme for the generated staging pages (tree sidebar, token bar, gem-framed stage, mobile layout). | Selectors are a contract with the staging HTML/JS generator (which sets --gem/--gem-rgb per node); pure presentation for internal pages. |
| `internal/staging-reference-v2.html` | Hand-authored reference mockup of the staging-root tree page (nested details, lazy iframes) that generate-tree.mjs's template replicates. | Not served or built — spec-by-example; rules encoded: inline manifest (file:// safe), lazy iframe src on toggle, tk never baked, normal-flow header. |
@ -239,7 +237,6 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/`
| `vite.config.js` | Vite config building one IIFE bundle per invocation (BUNDLE env var) from auto-discovered plugin-designs/; serves all bundles in dev. | Build requires `BUNDLE=<name>` (`npm run build` loops all); bundle discovery needs BOTH index.html AND index.js at profile root (`_`/`.` dirs skipped); `@map` alias → library/; jspdf externalized to an esm.sh CDN URL in builds; output name from vite.doxa-maps-build-config.json. |
| `vite.doxa-maps-build-config.json` | The single committed build-config: output tree name ("doxa-maps"), extra copy destinations (paths), embedBaseUrl, Mapbox tokenUrl/token. | Edit this ONE file then `bun run build`; empty embedBaseUrl keeps manifest URLs tree-relative (drop-portability gate); token ladder: per-map prop > ?tk= > baked token > window.MAP_TOKEN_URL > tokenUrl > same-origin /api/maps/token. tokenUrl is a URL, not a secret. |
| `package.json` | Root npm/bun manifest: scripts (dev, build, build:bundle, clean, tree, check:drop, check:parse) + deps (vue 3, pinia, vue-i18n, topojson-client, world-atlas, jspdf; vite 5 toolchain). | `build` iterates plugin-designs/*/ setting BUNDLE per invocation — adding a folder auto-builds; internal/ scripts run as part of build (check:drop currently invoked twice, likely unintentional). Bun-first toolchain. |
| `index.html` | Root staging page ("Embeddable JAMstack Maps") listing every bundle as tabs and lazy-mounting each bundle's maps in live frames; logic in /internal/staging.js. | Loads mapbox-gl v3.24.0 + geocoder globally ONCE so custom elements share window.mapboxgl in the same realm; pinned CDN versions must match library expectations. |
---

View file

@ -40,9 +40,14 @@ plugin-designs/<your-plugin>/ one FOLDER = one BUNDLE (the folder name is th
- **Elements live inside.** One bundle registers one tag named after the folder, and can hold more
elements for its own screens to nest (see `plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json`).
- **Folders starting with `_` are skipped** by the build: a free place for work in progress.
- **New plugins appear in the Plugin Studio at once, and in the Research Gallery's Experimental
section automatically.** Moving one into a production section is a deliberate edit to the
gallery's curated sections (`plugin-designs/doxa-maps-spa/doxa-maps-spa.vue`), not your call alone.
- **New plugins appear in the Plugin Studio at once**, sorted into the rail by **capability**, with
a translated "experimental" prefix until they are marked production. There is no Experimental
*section* to be promoted out of — the agent replaced it with capability tiers
(`plugin-designs/plugin-studio/plugin-studio.vue:630`, and `:642` for the label). Stage and
capability are different axes; see `references/glossary.md`.
- **Your rail entry comes from `<profile>.meta.json` `"label"`**, and
`internal/check-studio-rail.mjs:68` fails a plugin whose label is just its own id. Give it a
human name.
## 2. Start by copying the starter
@ -58,12 +63,11 @@ Then rename exactly these, and nothing else:
3. the `.vue` filenames (`template-map-a.vue` → `<screen>.vue`); delete `template-map-b.vue` if
you need one screen.
`template-map-a.vue` is a real minimal map: it draws `data/places.geojson` on a basemap with one
translated title, and already follows the page's engine, language (with right to left) and token,
falling back to keyless MapLibre. **Replace `data/places.geojson` with your own points** and you have
a working map to build on. Its title key (`template.title`) lives in the library catalogues. Your own
translated text needs its own catalogue file in `library/i18n/locales/`, which is a library change
(section 3). Until that lands, plain English in your `.vue` is fine.
`template-map-a.vue` is a real minimal map: it draws `data/places.geojson` with one translated
title, and already follows the page's engine, language (with right to left) and token, falling
back to keyless MapLibre. **Replace `data/places.geojson` with your own points** and you have a
working map. Your own translated text needs a catalogue file in `library/i18n/locales/`, which is
a library change (section 3); until that lands, plain English in your `.vue` is fine.
Keep the entry's `configureApp` exactly as the starter has it: it installs Pinia, installs i18n
**and** provides it as `appI18n`, and provides `profileModules`. The element's root
@ -170,6 +174,17 @@ levels 1 and 2 are detected on their own.
| 4 | composite element (`nested-elements-mfe`) | nests other elements, each still embeddable alone, wired by props and events | `plugin-designs/dashboard-plus-grid/` (the Big Picture tab) |
| 5 | app shell (`nested-mfe-spa`) | nests whole apps | `plugin-designs/doxa-maps-spa/` (the Research Gallery) |
**Your declaration is checked, not merely trusted.** `internal/check-capability-derived.mjs`
derives the level from what your plugin actually `composes` and fails when an explicit `"type"`
disagrees with it — a `meta.json` that contradicts the structure means one of the two is stale.
Run `bun run check:capability-derived`. Details and the two traps in `references/targets.md`.
**Presets merge over the profile's defaults, they do not replace them** — a preset page's config is
the profile's `<profile>.meta.json` `config`, then the preset's `props` over it, key by key. Set a
key in `props` only to change it. That is rule 3 of `documentation/PRESET-PLUGIN-STANDARD.md`,
asserted by `bun run check:preset-config-merge`; **read that standard before shipping presets.**
If two presets need different *code*, they are not presets — make another profile.
## 8. Learn from the reference plugins
The seven production plugins are the reference: each shows what a good plugin does. Before you
@ -187,63 +202,65 @@ line number, because lines move.
| Interop: one element reacts to another's events | Simple Research: `plugin-designs/research-map-plus-grid/research-map-plus-grid.vue` | the map listening for the grid's `groups-filtered` event |
| Composite element (capability 4) | Big Picture: `plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json` | `"composes"`: two elements, each still embeddable alone |
| One bundle, several embeddable screens | UPG-100: `plugin-designs/my-upg-100-list/` | `upg-100-mfe`, `upg-100-list` and `poster-wall` from one bundle |
| App shell (capability 5) | Research Gallery: `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue` | every production plugin as a tab, and the Experimental folder filled from the manifest |
| App shell (capability 5) | Research Gallery: `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue` | every plugin as a tab, filled from the manifest |
`emissivePaint` and `labelFont` (section 5) have no production-plugin exemplar, because the
production plugins get them through the library's own layers. The starter uses both directly:
read the pin and label layers in `plugin-designs/template-bundle/template-map-a.vue`.
## 9. Develop in the Plugin Studio
## 9. Develop, build, land
```bash
bun install # once
bun run dev # the Plugin Studio at http://localhost:5173/
bun run dev # the Plugin Studio at http://localhost:5173/
BUNDLE=<your-plugin> bun x vite build # just your bundle → public/dx-maps/<your-plugin>/
git add -A && git commit -m "…" && git pull --rebase origin main && git push origin HEAD:main
```
- Your plugin appears in the studio as soon as its folder exists, and edits reload live.
- One screen on its own: `http://localhost:5173/__leaf/<your-plugin>/<screen>`.
- Test the universal props from the studio header: switch the **engine**, the **language**
(include Arabic), and the **token** (none, a token, a token service). Your plugin must work in
every combination.
- `bun run check:dev-assets` checks that every map's data files load under the dev server.
- Your plugin appears in the studio as soon as its folder exists; edits reload live. One screen
alone: `http://localhost:5173/__leaf/<your-plugin>/<screen>`.
- **Test every combination from the studio header**: both engines × with and without a token ×
English and Arabic.
- **Build only your own bundle.** `BUILD_ALLOW=1 bun run build` rewrites *every* plugin's output;
on a team one agent owns it.
- **A rejected push is normal** — someone landed first. Rebase and push again; measured at 95%
rejected, zero conflicts. Stage only your own files.
## 10. Build: one step
**Done means**: your bundle builds and appears in `manifest.json`; it works under both engines,
with and without a token, in English and Arabic; every new string is in all 11 catalogues
(`bun run check:i18n-parity`); your expressions parse under MapLibre (section 5, rule 7); and you
changed nothing in `library/` or another plugin's folder.
```bash
BUNDLE=<your-plugin> bun x vite build # just your bundle → public/dx-maps/<your-plugin>/
BUILD_ALLOW=1 bun run build # every bundle, the tree, the manifest, the studio
```
Full loop, the gate list, and working when your agent runs on a server: `references/workflow.md`.
The full build rewrites every plugin's output. It refuses to run without `BUILD_ALLOW=1`, and
on a team one agent owns it. Build only your own bundle unless you are that agent.
`public/dx-maps/manifest.json` then lists your plugin with ready-to-paste embed snippets.
## 10. The three outputs
## 11. The three outputs
One built folder, three ways to ship it: the **web folder** (`public/dx-maps/`, iframe or script
tag from any host), the **mobile app** (the same folder wrapped by Capacitor, `bun run phone`), and
the **desktop app** (the same folder in Tauri, `bun run tauri:build`, a proof of concept).
| Output | What it is | How |
|---|---|---|
| **1. The web folder** | `public/dx-maps/`: every plugin, embeddable by **iframe** or **script tag**, from any host | copy the folder; snippets are in `manifest.json` |
| **2. The mobile app** | the same folder wrapped by Capacitor (`capacitor.config.json` `webDir: public/dx-maps`) | `bun run phone` (Android) |
| **3. The desktop app** | the same folder in Tauri (`deployment/src-tauri/`), a proof of concept | `bun run tauri:build` |
**Your plugin runs everywhere unless you say otherwise.** A `<profile>.meta.json` `"targets"` list
narrows that, and it is read, not decorative: the Research Gallery skips a bundle that does not
list `web` (`plugin-designs/doxa-maps-spa/doxa-maps-spa.vue:605`). Declare it only for a plugin
that genuinely cannot work in a plain web embed — Rudder needs a local speech engine. Details:
`references/targets.md`.
WordPress (`deployment/wordpress/`) embeds the web folder's snippets; it's experimental.
## Going deeper
## 12. Done means
| Read | For |
|---|---|
| `references/glossary.md` | bundle, profile, preset, element; stage vs capability; rail labels |
| `references/targets.md` | the five capabilities in full, how they are derived and checked, `targets`, the three outputs |
| `references/backend.md` | when your plugin needs a service; databases and migrations; the user tiers (design intent, not built) |
| `references/workflow.md` | the full develop → build → check → land loop |
- `BUNDLE=<your-plugin> bun x vite build` succeeds, and your plugin is in `manifest.json`.
- It works in the Plugin Studio under **both engines**, **with and without a token**, in
**English and Arabic**.
- Every new string is in all 11 catalogues (`bun run check:i18n-parity`).
- Your layer expressions parse under MapLibre (section 5, rule 7).
- You changed nothing in `library/` or in another plugin's folder, or you handed that change to
the build owner.
In the repo itself:
## References (all in this repo)
- `plugin-designs/README.md`: authoring guide, production vs experimental
- `plugin-designs/README.md`: authoring guide
- `plugin-designs/template-bundle/`: the starter, and its README
- `documentation/reference.md`: the `@map/…` library menu and the profile types
- `documentation/PRESET-PLUGIN-STANDARD.md`: presets in full — the six rules, and how they are discovered
- `documentation/how-to.md`: recipes, including swapping the map engine
- `documentation/HOWTO-join-the-table.md`: working alongside other people
- `library/composables/useMapProvider.js`: engine resolution and engine helpers
- `library/maps/label-font.js`: fonts per engine
- `library/i18n/index.js`: languages, `tStatic`, right to left

View file

@ -0,0 +1,91 @@
# Backends, data, and users
Most plugins need no backend. A map that draws its own data files, fetches a public API, or reads
`data/` inside its bundle is complete without one. Reach for this page only when your plugin needs
a service or stored data.
## What exists today
`backend/` holds exactly two things:
| Folder | What it is |
|---|---|
| `backend/pocketbase/` | the database: migrations, and the local instance `bun run dev` starts |
| `backend/rudder-engine/` | the local speech engine for the Rudder voice plugin |
A plugin that needs one names it in its `<profile>.meta.json` `"backend"` key, as
`plugin-designs/rudder-voice/rudder-voice.meta.json` does with `"backend": "backend/rudder-engine"`.
A plugin that needs a local backend almost certainly also needs `"targets"` without `web`, which
keeps it out of the Research Gallery — see `targets.md`. Rudder declares both.
## The database rule: schema is a commit, never a click
**You never publish a database.** What travels between people is the *shape* — tables and fields —
as migration files committed to `backend/pocketbase/pb_migrations/`. Adding a field is a normal
change: edit, commit the migration, push it like any other file.
This is deliberate. A rule that opens data to the public arrives as **a commit someone can read**
rather than as a setting somebody clicked. `documentation/HOWTO-join-the-table.md` is the full
explanation.
**New collections start locked.** Your plugin's data is not readable until a migration opens it, so
nothing is public by accident.
### Which database your laptop talks to
By default, your own. `bun run dev` starts a local instance, keeps its data in `.pb/`, and gives
you an admin screen at `/api/pb/_/`. A fresh clone plus `bun run dev` is the whole setup.
Pointing at the team's shared test data is opt-in, one line in `.env.local`:
```
DX_PB_URL=https://<your team's dev server>/pb
```
`DX_PB_PORT` is the only other one, and only if the log says port 8090 is in use. The full set of
environment names is in `.env.example`: `VITE_MAPBOX_TOKEN`, `VITE_API_BASE_URL`, `DX_SYNC_REMOTE`,
`DX_SYNC_BRANCH`, `DX_SYNC_INTERVAL`, `DX_PB_URL`, `DX_PB_PORT`. Every line is optional; empty means
the all-local default.
---
## User tiers — DESIGN INTENT, NOT BUILT
> **Read this heading literally.** What follows is a *planned* model recorded in the project's
> architecture notes under the heading "Open load-bearing assumptions (DX base; unproven)". **None
> of it is implemented in this repo.** Do not design your plugin around it, do not write code that
> assumes a tier exists, and do not tell anyone a plugin "supports" these. It is here so you
> recognise the vocabulary when it comes up, and so you do not invent a competing scheme.
The intended tiers, which parallel the five plugin capabilities:
| Tier | Intended meaning |
|---|---|
| anonymous | reaches the plugin by URL, no sign-in |
| access-code | temporary access without an account |
| plugin user | authenticated against the database |
| base OIDC member | signed in through the platform's identity provider |
| plugin admin | administers one plugin |
| PB plugin owner | owns a plugin's data |
| PB plugin writer | writes a plugin's data, with **no access to other plugins' data** |
Quantum Forms is named as the reference plugin that would exercise every tier.
**Source.** This is recorded in `ARCHITECTURE-EQUATION.md` §6, which lives in the project's QA
cycle **outside this code repository** (`internal-tracker/03-deployment-cycle/`). It is quoted here rather
than linked because a reader who clones this repo will not have that file. If you need the
authoritative text, ask whoever maintains the QA cycle — do not reconstruct it from this summary.
### What to do in the meantime
If your plugin needs any notion of "who is this" today, use what is actually built: PocketBase auth
plus a migration that opens exactly the collection you need, and nothing wider. Do not hand-roll a
permission model to bridge the gap — the last tier above ("no access to other plugins' data") is the
property a hand-rolled scheme tends to break, and breaking it crosses plugins.
## Related
- `targets.md` — the `targets` and `backend` meta keys
- `workflow.md` — landing a migration on main like any other file
- `documentation/HOWTO-join-the-table.md` — the three databases, in full

View file

@ -0,0 +1,79 @@
# Glossary
The words this repo uses, and the file that decides each one. When a definition here and the
code disagree, the code is right — these are pointers, not a second source of truth.
## The shape
| Word | What it means | Decided by |
|---|---|---|
| **bundle** | One folder under `plugin-designs/`. Builds to exactly one JavaScript file. The folder name is the bundle name. | `internal/plugin-gallery/discover.mjs` |
| **profile** | One `.vue` file, flat in the bundle folder. One profile = one screen = one embeddable page. | `internal/plugin-gallery/discover.mjs` |
| **element** | The custom HTML tag a bundle registers. One bundle usually registers one tag; it may register more for its own screens to nest. | your `index.js` |
| **instance** (preset) | One row of `<name>.instances.json`. Same code, different settings, its own page. | `internal/plugin-gallery/discover.mjs:126-138` |
| **capability** | A number 1–5 saying how composed a profile is. See `targets.md`. | `internal/check-capability-derived.mjs` |
| **the tree** | The build output under `public/dx-maps/` — every bundle, its staging pages, and `manifest.json`. | `internal/generate-tree.mjs` |
| **the library** | `library/`, imported as `@map/…`. Shared by every plugin. | `documentation/reference.md` |
| **profile-config** | The JSON attribute an embedder puts on your tag to set the universal props. | `library/ProfileLoader.vue` |
## Presets, precisely
A preset is **not** a variant of your code. It is a row in `<name>.instances.json` giving a
preset `id`, `title` and `props`. The build writes one page per row.
**Presets merge over the profile's defaults, they do not replace them.** This is rule 3 of
`documentation/PRESET-PLUGIN-STANDARD.md`, and it is asserted on the built tree by
`bun run check:preset-config-merge`:
> A preset page's config is the profile's `<profile>.meta.json` `config`, then the preset's
> `props` over it, key by key (a shallow merge). A default the preset does not repeat is kept:
> set it in `props` only to change it.
You can see the merge order in the spread at `internal/generate-tree.mjs:1094` (the embed page) —
```js
config: { assetsBase: assetsBaseFor(4), ...(profile.extraConfig || {}), ...inst.props },
```
— and at `:1147` (the manifest row). The profile's own config is written first, the preset's props
second, so a key the preset sets wins and a key it omits survives. The comment directly above
records the bug that forced the rule: "a preset lost its profile's `pinHint:false`".
Read that standard before shipping presets — it also covers the two accepted file shapes, and the
silent failure mode (a filename not ending `.instances.json`, or an `instances` key that is not an
array, types the bundle `single` with no error).
If two presets need **different code**, they are not presets — make another profile.
## Stage vs capability — two different axes
These get confused, so keep them apart:
- **Capability (1–5)** is *how composed* a plugin is. Derived and checked by
`internal/check-capability-derived.mjs`.
- **Stage (`production` / experimental)** is *how finished* it is. It is an attribute on the
plugin's row, not a place it lives.
There is no "Experimental section" to be promoted out of. The agent replaced that section with
capability tiers on 2026-09-27 — see the comment at `plugin-designs/plugin-studio/plugin-studio.vue:630`
("★ EVERY PLUGIN IS CAPABILITY 1-5 … replacing the Experimental section"). Line 642 shows what
replaced it: a non-production row simply gets a translated `stageExperimental` prefix on its
label, in a rail organised by capability.
## Rail labels
The Plugin Studio's left rail shows the `label` from `<profile>.meta.json`. It is not optional in
practice: `internal/check-studio-rail.mjs:68` fails a plugin whose label is just its own id —
```
if (r.label === r.profile || r.label === r.bundle)
fail(`${r.key} shows a bare id ("${r.label}") — give its <profile>.meta.json a "label"`)
```
So "give it a label" is a gate, not a convention. Run `bun run check:studio-rail`.
## Related
- `targets.md` — capabilities, `targets`, and where a plugin is allowed to run
- `backend.md` — data and the user tiers (design intent, not built)
- `workflow.md` — the loop from empty folder to landed on main

View file

@ -0,0 +1,103 @@
# Capabilities and targets
Two separate questions about a plugin: **how composed is it** (capability 1–5) and **where can it
run** (`targets`). Only the first is enforced.
## Capability 1–5
Pick the lowest level that does the job.
| Level | `type` | What it is | Example |
|---|---|---|---|
| 1 | `single` | one screen, one element | `plugin-designs/prayer-walk-planner/` |
| 2 | `parameterized` | one screen, several named presets from `<screen>.instances.json` | `plugin-designs/doxa-simple-map/` |
| 3 | `monolithic` | many views fused inside one element | `plugin-designs/doxa-research-map/` |
| 4 | `nested-elements-mfe` | nests other elements, each still embeddable alone | `plugin-designs/dashboard-plus-grid/` |
| 5 | `nested-mfe-spa` | nests whole apps | `plugin-designs/doxa-maps-spa/` |
### It is DERIVED, and your declaration is CHECKED against it
Levels 1 and 2 are inferred: a bundle with an `*.instances.json` is `parameterized`, otherwise
`single` (`internal/plugin-gallery/discover.mjs:44-52`). Levels 3–5 you declare in
`<screen>.meta.json` `"type"`.
Declaring is not the end of it. `internal/check-capability-derived.mjs` derives the capability
from what your plugin actually `composes` and **fails when an explicit `type` disagrees**. Its
own header states the contract:
> A profile that composes anything derives cap-4, or cap-5 if it composes a cap-4 — and an
> EXPLICIT type must AGREE with that. The declaration stays authoritative; it is not overridden,
> it is checked.
The defect it exists for: research-dashboard nested ten views with no `meta.json` and shipped as
capability 1, because a missing file is indistinguishable from a deliberate choice. Every surface
that shows a tier — the manifest, the gallery, the studio rail — reads that one field, so the
understatement propagated everywhere.
Run `bun run check:capability-derived`. Two related gates: `check:capability-citations` and
`check:capability-exemplars`.
### Two traps the gate documents
Worth knowing because they will bite you the same way:
- **`composes` is per-profile at source but hoisted to the bundle in the manifest.** Deriving from
the bundle row applies a composite's edges to its plain sibling leaves — gospel-resources has one
composite and four single leaves, and all four wrongly derived as cap-4.
- **Refs appear in three forms in the same field**: `'bundle/profile'`, a bare `'bundle'`, and an
element tag. Resolving only the first demoted plugin-studio from 5 to 4.
## `targets` — where the plugin may run, and it has teeth
A `<profile>.meta.json` may carry `"targets"`, naming where the plugin is meant to run:
```json
{ "type": "single", "label": "Rudder", "targets": ["desktop"], "backend": "backend/rudder-engine" }
```
**Omitting it means "everywhere", and that is the right default for almost every plugin.** The
whole point of the build is one folder that runs as a web embed, a mobile app and a desktop app.
**Declaring it has a real consequence**, so declare it only when you mean it:
- `internal/profile-meta.mjs:28` reads the field (string entries only) and `:36` carries it into
the manifest, so every consumer of the manifest sees it.
- **The Research Gallery skips a bundle whose every profile is a widget or lacks the `web`
target** — `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue:605`:
```js
if (profiles.every(p => p.widget || (Array.isArray(p.targets) && !p.targets.includes('web')))) continue
```
So `"targets": ["desktop"]` removes your plugin from the gallery. That is the feature, not a
bug: it keeps a plugin that cannot work on a static host out of a surface that is served from
one. `bun run check:gallery-embed` covers the gallery's embeds.
The worked example is Rudder: its speech model runs in a local backend started by `bun run dev`
and run by Tauri as a sidecar, so "a website has no local engine" (its `_comment`). It is
correctly absent from the gallery and makes no network call on a static host.
- **Say why in `_comment`** when you declare it — the next reader needs the reason, not just the list.
- **A `backend` key alongside it** names the folder that must be running. See `backend.md`.
- **Do not add `targets` "for documentation".** Adding it without `web` silently removes you from
the gallery.
## Where a plugin actually runs
The three outputs, all from the same built folder:
| Output | What it is | How |
|---|---|---|
| Web folder | `public/dx-maps/`, embeddable by iframe or script tag from any host | copy the folder; snippets in `manifest.json` |
| Mobile app | the same folder wrapped by Capacitor (`capacitor.config.json` `webDir`) | `bun run phone` |
| Desktop app | the same folder in Tauri (`deployment/src-tauri/`), a proof of concept | `bun run tauri:build` |
WordPress (`deployment/wordpress/`) embeds the web folder's snippets; it is experimental.
Embeddability is checked, unlike `targets`: `bun run check:embed-targets` runs
`internal/check-embed-targets-exist.mjs`.
## Related
- `glossary.md` — bundle, profile, preset, stage vs capability
- `backend.md` — plugins that need a service, and the user tiers

View file

@ -0,0 +1,97 @@
# The workflow: empty folder to landed on main
## 1. Develop in the Plugin Studio
```bash
bun install # once
bun run dev # the Plugin Studio at http://localhost:5173/
```
- Your plugin appears in the studio as soon as its folder exists; edits reload live.
- One screen on its own: `http://localhost:5173/__leaf/<your-plugin>/<screen>`.
- Test the universal props from the studio header: switch the **engine**, the **language**
(include Arabic), and the **token** (none, a token, a token service). Your plugin must work in
every combination — that is the point of the header.
- `bun run check:dev-assets` checks that every map's data files load under the dev server.
Your plugin's rail entry comes from `<profile>.meta.json` `"label"`, and
`bun run check:studio-rail` fails a bare id. Give it a human name.
## 2. Build
```bash
BUNDLE=<your-plugin> bun x vite build # just your bundle → public/dx-maps/<your-plugin>/
BUILD_ALLOW=1 bun run build # every bundle, the tree, the manifest, the studio
```
The full build rewrites **every** plugin's output. It refuses to run without `BUILD_ALLOW=1`, and
on a team one agent owns it. **Build only your own bundle unless you are that agent.**
`public/dx-maps/manifest.json` then lists your plugin with ready-to-paste embed snippets.
## 3. Check
Run the gates that cover what you touched. The ones most often relevant:
| Gate | What it protects |
|---|---|
| `bun run check:i18n-parity` | every new key exists in all 11 catalogues |
| `bun run check:i18n-never-translated` | a key that was copied rather than translated |
| `bun run check:capability-derived` | your declared `type` agrees with what you compose |
| `bun run check:studio-rail` | your rail entry is a label, not a bare id |
| `bun run check:embed-targets` | the embed targets in the manifest exist |
| `bun run check:dev-assets` | data files load under the dev server |
`package.json` holds the full list; they are all prefixed `check:`.
## 4. Land it on main
Three commands, in this order, every time:
```bash
git add -A && git commit -m "what you changed"
git pull --rebase origin main
git push origin HEAD:main
```
**A rejected push is normal, not an error.** Someone landed first: run the middle command again and
push again. In a ten-writer stress test at a far faster rate than a human works, 95% of pushes were
rejected and retried, **zero** became merge conflicts, averaging 1.1 retries per edit
(`documentation/HOWTO-join-the-table.md`).
That result is the one-folder-per-person rule paying off: everyone pushes to one branch, so you
collide on *who goes first*, never on the same lines. A real conflict means two people edited one
file — stop and talk to them; that is a coordination problem, not a git problem.
**Stage only your own files.** `git add -A` is safe when you own your folder and nothing else is
dirty; check `git status` first if you are unsure. Never commit another agent's uncommitted work.
## 5. Done means
- `BUNDLE=<your-plugin> bun x vite build` succeeds, and your plugin is in `manifest.json`.
- It works in the Plugin Studio under **both engines**, **with and without a token**, in
**English and Arabic**.
- Every new string is in all 11 catalogues (`bun run check:i18n-parity`).
- Your layer expressions parse under MapLibre (SKILL.md section 5, rule 7).
- You changed nothing in `library/` or in another plugin's folder, or you handed that change to the
build owner.
## Working when your agent runs on a server
If your coding agent runs somewhere other than your laptop, one line in `.env` or `.env.local`
makes your local dev server follow the branch the agent pushes to:
```
DX_SYNC_REMOTE=origin
```
A full repo URL works too. It only ever fast-forwards, and only when all three are true: you are on
that branch, you have no unsaved edits, and you have no local commits. Otherwise it leaves your
files alone and says `main is N commits ahead` in the log. So **push your own work often**, or you
will drift. `documentation/CLOUD-DEVELOPMENT-ENVIRONMENT.md` covers this and the shared-container
alternative.
## Related
- `glossary.md` — the vocabulary
- `targets.md` — capabilities and where a plugin runs
- `backend.md` — databases and migrations