plugin-designer skill: fix stale gallery claims, split depth into references/
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

SKILL.md claimed new plugins land in a Research Gallery Experimental section.
That section was replaced by capability tiers (plugin-studio.vue:630); stage is
now a label prefix, not a place. Both stale lines corrected.

Adds what the skill never said:
- capability is DERIVED from composes and an explicit type is CHECKED against it
  (check-capability-derived.mjs), with its two traps;
- rail labels come from meta, and check-studio-rail.mjs:68 fails a bare id;
- presets merge OVER the profile's meta config, key by key -- rule 3 of
  documentation/PRESET-PLUGIN-STANDARD.md, asserted by check:preset-config-merge
  (generate-tree.mjs:1094, the comment there records the pinHint bug);
- targets is read (profile-meta.mjs:28) and consequential: the Research Gallery
  skips a bundle that does not list web (doxa-maps-spa.vue:605).

Depth moves to references/: glossary, targets, backend, workflow. backend.md
carries the PocketBase user tiers marked DESIGN INTENT, NOT BUILT -- quoted
rather than linked, because their source lives outside this repo.

Verified against this tree, rebased on main: 61 path citations resolve, 10 line
citations land on the code they claim.

README: one line pointing an agent at the skill.
This commit is contained in:
Doxa Maps 2026-09-28 03:15:51 +00:00
parent bbcc52cbcc
commit 53363ba80d
6 changed files with 435 additions and 45 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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