--- name: plugin-designer description: >- Use this skill to design and build a new map plugin in this repo's plugin-designs/ folder, or to add a screen to an existing one. Teaches the bundle = folder = one JS file pattern, reusing library/ before writing code, the universal props every plugin must honour (map engine, language, Mapbox token or token service), the rules for working under both MapLibre and Mapbox, localization and right-to-left, the five capability levels, the one build step, and the three outputs (web folder, mobile app, desktop app). Works directly with the Plugin Studio (`bun run dev`). --- # Plugin designer You build map plugins for this repo. A **plugin** is a folder under `plugin-designs/`; one build turns it into one JavaScript file that any page can embed as an HTML tag. Your plugin must work the same under both map engines, in every language, and with or without a Mapbox token. Run every command from the repo root (the folder with `package.json`). --- ## 1. The pattern: bundle = folder = one JS file ``` plugin-designs// one FOLDER = one BUNDLE (the folder name is the bundle name) ├── index.js entry: registers the custom element, finds the profiles ├── index.html a staging page that mounts the element ├── .vue one .vue = one PROFILE (one screen), FLAT in the folder ├── .meta.json optional: {"type": "", "label": "…"} ├── .instances.json optional: named presets of one screen (capability 2) ├── data/ optional: the bundle's own data files (imported, so they ship inside the bundle) └── src/ the bundle's PRIVATE code: components, colors, helpers builds to ─────────────► public/dx-maps//.js ``` - **Profiles are flat.** `index.js` finds them with `import.meta.glob('./*.vue')`; a `.vue` in a subfolder is not found. The filename (without `.vue`) is the profile's name. - **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**, 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 ```bash cp -r plugin-designs/template-bundle plugin-designs/ ``` Then rename exactly these, and nothing else: 1. the tag in `index.js` — both the `customElements.define('template-bundle', …)` call and the `customElements.get('template-bundle')` guard; 2. the tag, the `profile` strings and the CSS selector in `index.html`; 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` 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 (`library/ProfileLoader.vue`) cannot call `useI18n()`; it drives the language through the provided instance. Drop the provide and a host can no longer set your plugin's language. ## 3. Reuse `library/` first `library/` (imported as `@map/…`) is shared by every plugin. Its menu is `documentation/reference.md`. - **Reuse by import, never by copy.** `import { useMapInstance } from '@map/composables/useMapInstance.js'`. - **Your own code goes in your `src/`**, imported by relative path. Nothing there reaches another plugin. - **Don't edit `library/` to customise one plugin.** A library change reaches every plugin that imports it, so it needs every consumer rebuilt. On a team with a build owner, hand library changes to that owner rather than landing them yourself. - **Graduate late.** Move code into `library/` only when a second plugin needs the same thing and it takes props instead of hard-coded specifics. ## 4. The universal props — every plugin honours these A plugin never decides these for itself; it reads them from whoever embeds it. The Plugin Studio header sets all of them, which is how you test yours. | Prop | Values | Channels, first found wins | Default | |---|---|---|---| | **Map engine** (`provider`) | `mapbox` · `maplibre` · `auto` (any case) | `profile-config` `"provider"` → `?provider=` in the page address → `window.MAP_PROVIDER`; an explicit choice always wins | `auto`: Mapbox when a token is available (any row below), MapLibre otherwise. Confirm in `library/composables/useMapProvider.js`, never assume it | | **Language** (`locale`) | one of the 11 in `library/i18n/locales/` | `profile-config` `"locale"` (or `"lang"`) → `?lang=` / `?locale=` → `` | English | | **Mapbox token** (`tk`) | a public `pk.…` token | `profile-config` `"tk"` → `?tk=` on a generated page → `window.MAP_TOKEN`; generated pages also keep a token pasted once in the tree (browser storage) | none. The embed snippet's `YOUR_MAPBOX_TOKEN` placeholder counts as no token | | **Token service** (`tokenUrl`) | a URL that returns a `pk.` token | `profile-config` `"tokenUrl"` (the element asks it when it has no token; the studio header's field feeds this) → on generated pages also `window.MAP_TOKEN_URL` → the build config's `tokenUrl` → same-origin `/api/maps/token` | none | ```html ``` Read them through the library (`resolveMapProvider`, the injected `mapboxToken`, the i18n instance), never by parsing the URL yourself, and never fetch a token service yourself: the library asks it, accepts only a `pk.` answer, and a Mapbox map waits for it before it starts. **Your plugin must draw under MapLibre with no token at all.** `library/composables/useMapProvider.js` and `library/ProfileLoader.vue` are the authority when this table and the code disagree. ## 5. Two engines: rules that keep MapLibre and Mapbox equal Write every map feature once, so it works under both engines. These rules each come from a real bug: 1. **Ask the map which engine it is.** Use `engineFor(map)` / `isMapboxMap(map)` from `@map/composables/useMapProvider.js`, never a global that may still be defined after a switch. 2. **Some APIs differ in shape.** `getProjection()` is `{name}` on Mapbox but `{type}` or `undefined` on MapLibre; `setProjection` takes `'mercator'` on Mapbox but `{type:'mercator'}` on MapLibre; `setConfigProperty` and light presets exist only on Mapbox. Wrap these calls in `try`, and never assume the result's shape. An uncaught throw silently skips everything after it. 3. **Mapbox-only paint properties drop the whole layer on MapLibre.** Never write `'circle-emissive-strength'` (or any `*-emissive-strength`) literally. Spread `...emissivePaint(map, kind)` from `useMapProvider.js`. 4. **Fonts must exist on the engine's glyph server.** Under MapLibre, a Mapbox font name (`DIN Offc Pro`, `Arial Unicode MS`) gets a 404, and the layer draws **no text** with no error. Spread `...labelFont(map, {…})` from `@map/maps/label-font.js` into every symbol layer. 5. **`mapbox://` sources don't load on MapLibre.** The country polygons come from the Mapbox tileset `mapbox.country-boundaries-v1` under Mapbox. Under MapLibre, use the local file `boundaries/world-adm0.geojson` under the assets base. It names the country code `iso3`, where the tileset says `iso_3166_1_alpha_3`, so normalise once. `library/composables/useMapLayers.js` (the "region polygons, per engine" block) is the worked example. 6. **Load assets from the assets base, never a page-relative path.** Use `resolveAssetsBase(profileConfig)` from `@map/utils/embedBase.js`. A hard-coded `./assets` works in the built folder and breaks under `bun run dev`. 7. **Prove your expressions parse under MapLibre without a browser.** MapLibre's own parser, `@maplibre/maplibre-gl-style-spec`, is installed as a dependency of `maplibre-gl`. It is NOT declared in `package.json`, so a lockfile change can remove it: if the import fails, add it as a dev dependency rather than dropping the check. ```js import { expression, featureFilter } from '@maplibre/maplibre-gl-style-spec' const e = expression.createExpression(['match', ['get', 'status'], 'reached', '#2e7d32', '#c62828']) if (e.result !== 'success') throw new Error(e.value.map(x => x.message).join('; ')) const admits = featureFilter(['==', ['get', 'iso3'], 'TUN']).filter({ zoom: 2 }, { properties: { iso3: 'TUN' } }) ``` Run each paint and filter expression your layers use against a few real features from the data. ## 6. Localization and right to left - **Every word on screen comes from a catalogue** in `library/i18n/locales//*.json`, the same key in all 11 languages. Components use `t('file.key')`; code that runs outside the Vue app uses `tStatic(path, locale)` from `@map/i18n/index.js`. - **Add a key to all 11 catalogues at once.** `bun run check:i18n-parity` fails when one is missing; `bun run check:i18n-never-translated` flags a key that was copied, not translated. - **Names from the data stay as the data has them** (people groups, places); translate only the interface. - **Right to left belongs to your element, not the page.** Arabic mirrors the plugin's own box; the library sets `dir` on it. Never write `document.documentElement.dir` from a plugin: an embedded plugin that does flips the whole partner page. ## 7. Choose the capability level Pick the lowest level that does the job. Declare levels 3–5 in `.meta.json` `"type"`; levels 1 and 2 are detected on their own. | Level | Name (`type`) | What it is | Example | |---|---|---|---| | 1 | single element (`single`) | one screen, one element | `plugin-designs/prayer-walk-planner/` | | 2 | preset element (`parameterized`) | one screen, several named presets from `.instances.json` | `plugin-designs/doxa-simple-map/` | | 3 | multi-view element (`monolithic`) | many views fused inside one element | `plugin-designs/doxa-research-map/` | | 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 write a rule's code, read the plugin that shows it best. Find code by the name given here, not by line number, because lines move. | Rule | Read this | What to look at | |---|---|---| | Entry: i18n installed **and** provided; one bundle, two tags | Prayer Progress: `plugin-designs/doxa-simple-map/index.js` | `configureApp` and its `app.provide('appI18n', …)` | | Presets from one screen (capability 2) | Prayer Progress: `plugin-designs/doxa-simple-map/simple-map.instances.json` | Prayer / Engagement / Adoption as three embeddable pages | | Engine-asymmetric APIs, guarded | Regions: `plugin-designs/regions-dashboard/src/RegionCountryMap.vue` | `projectionOf` and `setProjectionOn` — both shapes, inside `try` | | Polygons both engines can draw; data from the assets base | Cities with Internet: `plugin-designs/cities-internet/connectivity-map.vue` | `resolveAssetsBase(…)`, the `boundaries/world-adm0.geojson` fetch (polygon data with no `mapbox://` source), and `resolveNamedStyle(…, provider)` for a basemap that matches the engine | | Interface text from the catalogues | Gospel Resources: `plugin-designs/gospel-resources/resources-map.vue` | its interface text goes through `t('…')` (about 80 calls) | | Basemap labels in the reader's language; right to left | Prayer Progress: `plugin-designs/doxa-simple-map/doxa-simple-map.vue` | the `name_` label switch and `isRTL` | | 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 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, build, land ```bash 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; 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. **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. Full loop, the gate list, and working when your agent runs on a server: `references/workflow.md`. ## 10. 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). **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`. ## Going deeper | 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 | | `references/i18n.md` | translating a plugin: whole-sentence keys, label-form counts, inline markup composed (no ``), deliberate English in `dir="ltr"`, labels that follow a locale switch, the checks to run | In the repo itself: - `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