18 KiB
| name | description |
|---|---|
| plugin-designer | 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/<your-plugin>/ 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
├── <screen>.vue one .vue = one PROFILE (one screen), FLAT in the folder
├── <screen>.meta.json optional: {"type": "<capability>", "label": "…"}
├── <screen>.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/<your-plugin>/<your-plugin>.js
- Profiles are flat.
index.jsfinds them withimport.meta.glob('./*.vue'); a.vuein 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:642for the label). Stage and capability are different axes; seereferences/glossary.md. - Your rail entry comes from
<profile>.meta.json"label", andinternal/check-studio-rail.mjs:68fails a plugin whose label is just its own id. Give it a human name.
2. Start by copying the starter
cp -r plugin-designs/template-bundle plugin-designs/<your-plugin>
Then rename exactly these, and nothing else:
- the tag in
index.js— both thecustomElements.define('template-bundle', …)call and thecustomElements.get('template-bundle')guard; - the tag, the
profilestrings and the CSS selector inindex.html; - the
.vuefilenames (template-map-a.vue→<screen>.vue); deletetemplate-map-b.vueif 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= → <html lang> |
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 |
<your-plugin profile-config='{"profile":"<screen>","provider":"maplibre","locale":"ar"}'></your-plugin>
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:
-
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. -
Some APIs differ in shape.
getProjection()is{name}on Mapbox but{type}orundefinedon MapLibre;setProjectiontakes'mercator'on Mapbox but{type:'mercator'}on MapLibre;setConfigPropertyand light presets exist only on Mapbox. Wrap these calls intry, and never assume the result's shape. An uncaught throw silently skips everything after it. -
Mapbox-only paint properties drop the whole layer on MapLibre. Never write
'circle-emissive-strength'(or any*-emissive-strength) literally. Spread...emissivePaint(map, kind)fromuseMapProvider.js. -
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.jsinto every symbol layer. -
mapbox://sources don't load on MapLibre. The country polygons come from the Mapbox tilesetmapbox.country-boundaries-v1under Mapbox. Under MapLibre, use the local fileboundaries/world-adm0.geojsonunder the assets base. It names the country codeiso3, where the tileset saysiso_3166_1_alpha_3, so normalise once.library/composables/useMapLayers.js(the "region polygons, per engine" block) is the worked example. -
Load assets from the assets base, never a page-relative path. Use
resolveAssetsBase(profileConfig)from@map/utils/embedBase.js. A hard-coded./assetsworks in the built folder and breaks underbun run dev. -
Prove your expressions parse under MapLibre without a browser. MapLibre's own parser,
@maplibre/maplibre-gl-style-spec, is installed as a dependency ofmaplibre-gl. It is NOT declared inpackage.json, so a lockfile change can remove it: if the import fails, add it as a dev dependency rather than dropping the check.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/<code>/*.json, the same key in all 11 languages. Components uset('file.key'); code that runs outside the Vue app usestStatic(path, locale)from@map/i18n/index.js. - Add a key to all 11 catalogues at once.
bun run check:i18n-parityfails when one is missing;bun run check:i18n-never-translatedflags 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
diron it. Never writedocument.documentElement.dirfrom 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 <screen>.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 <screen>.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 <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 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_<locale> 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
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; 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 buildrewrites 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 <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.
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 <i18n-t>), deliberate English in dir="ltr", labels that follow a locale switch, the checks to run |
In the repo itself:
plugin-designs/README.md: authoring guideplugin-designs/template-bundle/: the starter, and its READMEdocumentation/reference.md: the@map/…library menu and the profile typesdocumentation/PRESET-PLUGIN-STANDARD.md: presets in full — the six rules, and how they are discovereddocumentation/how-to.md: recipes, including swapping the map enginedocumentation/HOWTO-join-the-table.md: working alongside other peoplelibrary/composables/useMapProvider.js: engine resolution and engine helperslibrary/maps/label-font.js: fonts per enginelibrary/i18n/index.js: languages,tStatic, right to left