From b94ba25c27a733dc9876e6608e9cb0c3b7dd55af Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 03:53:39 +0000 Subject: [PATCH] Update from source 7077c5550 --- README.md | 3 + deployment/cloud/README.md | 2 + deployment/cloud/admin.mjs | 54 +++++++-- deployment/cloud/build-cdn.sh | 35 ++++-- documentation/DEPLOY.md | 18 ++- documentation/THIRD-PARTY.md | 4 +- documentation/architecture/embeddability.md | 2 +- documentation/architecture/file-map.md | 13 +-- skills/plugin-designer/SKILL.md | 107 ++++++++++-------- skills/plugin-designer/references/backend.md | 91 +++++++++++++++ skills/plugin-designer/references/glossary.md | 79 +++++++++++++ skills/plugin-designer/references/targets.md | 103 +++++++++++++++++ skills/plugin-designer/references/workflow.md | 97 ++++++++++++++++ 13 files changed, 530 insertions(+), 78 deletions(-) create mode 100644 skills/plugin-designer/references/backend.md create mode 100644 skills/plugin-designer/references/glossary.md create mode 100644 skills/plugin-designer/references/targets.md create mode 100644 skills/plugin-designer/references/workflow.md diff --git a/README.md b/README.md index 8d7b392c..f66cb147 100644 --- a/README.md +++ b/README.md @@ -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 | | | diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index d65300d3..0de8d5a5 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -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:///pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https:///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:///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 | diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index 9d711ae0..f1559359 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -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) diff --git a/deployment/cloud/build-cdn.sh b/deployment/cloud/build-cdn.sh index 7a0d9de0..a256bc40 100755 --- a/deployment/cloud/build-cdn.sh +++ b/deployment/cloud/build-cdn.sh @@ -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/" diff --git a/documentation/DEPLOY.md b/documentation/DEPLOY.md index 9b5f1368..dcb6fe7c 100644 --- a/documentation/DEPLOY.md +++ b/documentation/DEPLOY.md @@ -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: *`. diff --git a/documentation/THIRD-PARTY.md b/documentation/THIRD-PARTY.md index a8888c22..6e0be7b3 100644 --- a/documentation/THIRD-PARTY.md +++ b/documentation/THIRD-PARTY.md @@ -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 | diff --git a/documentation/architecture/embeddability.md b/documentation/architecture/embeddability.md index 4709d1d3..c0b18499 100644 --- a/documentation/architecture/embeddability.md +++ b/documentation/architecture/embeddability.md @@ -52,7 +52,7 @@ once, never hand-written. | **iframe / direct link** | point at the generated `/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. diff --git a/documentation/architecture/file-map.md b/documentation/architecture/file-map.md index db71d5fd..ca34c9ca 100644 --- a/documentation/architecture/file-map.md +++ b/documentation/architecture/file-map.md @@ -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 ``. | 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 `` (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/` 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 `` 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=` (`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. | --- diff --git a/skills/plugin-designer/SKILL.md b/skills/plugin-designer/SKILL.md index 4945fca5..3219c26f 100644 --- a/skills/plugin-designer/SKILL.md +++ b/skills/plugin-designer/SKILL.md @@ -40,9 +40,14 @@ plugin-designs// 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 `.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` → `.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 `.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= bun x vite build # just your bundle → public/dx-maps// +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//`. -- 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//`. +- **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= bun x vite build # just your bundle → public/dx-maps// -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 `.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= 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 diff --git a/skills/plugin-designer/references/backend.md b/skills/plugin-designer/references/backend.md new file mode 100644 index 00000000..48b17e70 --- /dev/null +++ b/skills/plugin-designer/references/backend.md @@ -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 `.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:///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 diff --git a/skills/plugin-designer/references/glossary.md b/skills/plugin-designer/references/glossary.md new file mode 100644 index 00000000..6f45cab4 --- /dev/null +++ b/skills/plugin-designer/references/glossary.md @@ -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 `.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 `.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 `.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 `.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 .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 diff --git a/skills/plugin-designer/references/targets.md b/skills/plugin-designer/references/targets.md new file mode 100644 index 00000000..a822e122 --- /dev/null +++ b/skills/plugin-designer/references/targets.md @@ -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 `.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 +`.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 `.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 diff --git a/skills/plugin-designer/references/workflow.md b/skills/plugin-designer/references/workflow.md new file mode 100644 index 00000000..47534f94 --- /dev/null +++ b/skills/plugin-designer/references/workflow.md @@ -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//`. +- 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 `.meta.json` `"label"`, and +`bun run check:studio-rail` fails a bare id. Give it a human name. + +## 2. Build + +```bash +BUNDLE= bun x vite build # just your bundle → public/dx-maps// +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= 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