Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts: # public/doxa-maps/index.html
540
README.md
|
|
@ -1,165 +1,80 @@
|
|||
# dx-maps — one build, maps that embed everywhere
|
||||
|
||||
> **The map cruncher.** Many hand-built maps go in; one shared library, one repo and one build
|
||||
> come out — and every map fans out to five deployment targets from the same artifacts.
|
||||
>
|
||||
> ```text
|
||||
> map · map · map · map · map · map · map (7 production maps, once 7 codebases)
|
||||
> \ \ | | | / /
|
||||
> +----- plugin-designs/ ------+ one folder per plugin, free zone
|
||||
> |
|
||||
> library/ (@map) shared, parameterized, high-bar zone
|
||||
> |
|
||||
> ONE bun run build
|
||||
> |
|
||||
> doxa-maps/ (drop-anywhere tree: bundles + leaf pages + manifest.json)
|
||||
> / | \ \ \
|
||||
> iframe JS-embed mobile WordPress desktop
|
||||
> (proven) (proven) (built) (built) (built)
|
||||
> ```
|
||||
**Write a map once in `plugin-designs/<your-map>/`, run one build, and drop it on any site** —
|
||||
iframe, script embed, mobile app — or open every map at once as one app. Five plugin
|
||||
capabilities, five deployment methods, one shared library, one build.
|
||||
|
||||
**A Vite bundler that turns each map into a self-contained web component.** Write a map in
|
||||
`plugin-designs/<your-map>/`, run one build, and get a folder you can drop on any host — iframe it,
|
||||
script-embed it, wrap it as a mobile app, or open every map at once as one app.
|
||||
| What dx-maps set out to do | Proof |
|
||||
|---|---|
|
||||
| **One build → one folder** you can drop on any host | `bun run check:drop` |
|
||||
| **Every map embeddable** from a copy-paste snippet | [deployment methods](#the-5-deployment-methods) · `bun run check:embed-targets` |
|
||||
| **Every map interoperable** through props and events | `bun run verify:interop` |
|
||||
| **One app nests every map** — and each stays embeddable on its own | [capability 5](#capability-5--app-shell-the-research-gallery) · `bun run check:composite-reg` |
|
||||
| **5 capabilities × 5 deployment methods** | `bun run verify:5-caps` |
|
||||
| **Shared library, private plugins** — reuse without breaking each other | `bun run check:library-index` |
|
||||
| **Many agents, one green `main`** | [agent swarm](#build-it-with-an-agent-swarm) |
|
||||
|
||||
---
|
||||
|
||||
## What dx-maps is for — and how each objective is met
|
||||
## The 5 plugin capabilities
|
||||
|
||||
Maps multiply a prayer movement only when they can live on *every* partner's site, not just one.
|
||||
dx-maps exists to make that cheap. It set out to meet seven objectives; each one is met by something
|
||||
you can open or run, not by a claim.
|
||||
A capability is **how much a plugin nests**, from one element (1) to an app of apps (5). Every
|
||||
screenshot is a real bundle from this repo's build.
|
||||
|
||||
| # | Objective | How it is met | See it / check it |
|
||||
|---|-----------|---------------|-------------------|
|
||||
| 1 | **One build → one folder.** Seven maps that were seven codebases become one repo and one build. | `bun run build` writes a single drop-anywhere tree, `public/doxa-maps/`, with a `manifest.json` describing every bundle. | `bun run check:drop` · `bun run check:settled` |
|
||||
| 2 | **Every map is embeddable.** Any map, on any site, with a copy-paste snippet. | Each bundle is one custom element; `manifest.json` ships an iframe snippet and an SRI-pinned `<script>` snippet for it. | [Deployment methods](#3-the-5-deployment-methods) · `bun run check:embed-targets` |
|
||||
| 3 | **Every map is interoperable.** Maps talk to each other through props and events. | Composites wire their children with props, events and shared layouts — no copy-pasted code. | `bun run verify:interop` |
|
||||
| 4 | **One app nests every map — and each stays independently embeddable.** | The **Research Gallery** is a single capability-5 plugin that nests all seven production maps. | [The Research Gallery](#1-the-research-gallery--five-capabilities-in-one-plugin) · `bun run check:composite-reg` |
|
||||
| 5 | **5 capabilities × 5 deployment methods.** Anything you build, at any tier, ships everywhere. | A plugin's tier is read from its files; every tier builds to the same tree, and the tree feeds all five methods. | [Capabilities](#2-the-5-plugin-capabilities--most-advanced-first) · `bun run verify:5-caps` |
|
||||
| 6 | **A clean shared/private boundary.** Reuse without breaking each other. | `library/` holds shared, parameterized pieces (generated index); anything map-specific stays in its own `plugin-designs/` folder. | [reference-library-index.md](./documentation/reference-library-index.md) · `bun run check:library-index` |
|
||||
| 7 | **Many builders, one green `main`.** The repo is built to be worked by a swarm of agents at once. | One agent per plugin, one worktree each, and every change landed through a merge queue. | [Build it with an agent swarm](#5-build-it-with-an-agent-swarm) |
|
||||
### Capability 5 — app shell: the Research Gallery
|
||||
|
||||
Plus one practical objective: **a token-free option.** Mapbox is the default engine; setting a single
|
||||
prop, `provider: "maplibre"`, swaps in MapLibre so a partner running many embeds needs no Mapbox key.
|
||||
`bun run check:provider-switch` proves the switch really changes the engine.
|
||||
|
||||
## Contents
|
||||
|
||||
1. **[The Research Gallery](#1-the-research-gallery--five-capabilities-in-one-plugin)** — the showcase: five capabilities in one plugin.
|
||||
2. **[The 5 plugin capabilities](#2-the-5-plugin-capabilities--most-advanced-first)** — what you can build, most advanced first.
|
||||
3. **[The 5 deployment methods](#3-the-5-deployment-methods)** — where a plugin can run.
|
||||
4. **[The Plugin Studio](#4-the-plugin-studio)** — ★ where you *work*, and every command to get it running. Start here if you are new.
|
||||
5. **[Build it with an agent swarm](#5-build-it-with-an-agent-swarm)** — how to run many agents on one laptop without collisions.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Research Gallery — five capabilities in one plugin
|
||||
|
||||
The Research Gallery (`plugin-designs/doxa-maps-spa/`) is the product a user opens. It is **one
|
||||
capability-5 plugin** — one custom element, `<doxa-maps-spa>` — and every tab inside it is a
|
||||
**real, independently embeddable plugin** from a lower tier. Nothing is copied into it and nothing is
|
||||
framed: each tab loads its own bundle and mounts that bundle's own custom element.
|
||||
One plugin, one element (`<doxa-maps-spa>`), and every tab inside it is a real plugin from a lower
|
||||
tier that you can still embed on its own. `plugin-designs/doxa-maps-spa/`
|
||||
|
||||

|
||||
|
||||
This is how five different capabilities compose into one:
|
||||
<details>
|
||||
<summary><b>What the Research Gallery is made of — all five capabilities in one plugin</b></summary>
|
||||
|
||||
```text
|
||||
Research Gallery <doxa-maps-spa> capability 5 — app shell
|
||||
│
|
||||
Research Gallery <doxa-maps-spa> capability 5
|
||||
├─ Dashboards
|
||||
│ ├─ Big Picture dashboard-plus-grid capability 4 — composite
|
||||
│ │ ├─ research dashboard research-dashboard capability 3 — monolithic
|
||||
│ │ └─ people-groups grid people-groups-grid capability 2 — prop-table
|
||||
│ ├─ Simple Research research-map-plus-grid capability 4 — composite
|
||||
│ │ ├─ research map doxa-research-map capability 3 — monolithic
|
||||
│ │ └─ people-groups grid people-groups-grid capability 2 — prop-table
|
||||
│ └─ Regions regions-mfe capability 4 — composite
|
||||
│ ├─ regions list regions-list capability 1 — single
|
||||
│ └─ region dashboards region-dashboards capability 1 — single
|
||||
│ ├─ Big Picture dashboard-plus-grid capability 4
|
||||
│ │ ├─ research dashboard research-dashboard capability 3
|
||||
│ │ └─ people-groups grid people-groups-grid capability 2
|
||||
│ ├─ Simple Research research-map-plus-grid capability 4
|
||||
│ │ ├─ research map doxa-research-map capability 3
|
||||
│ │ └─ people-groups grid people-groups-grid capability 2
|
||||
│ └─ Regions regions-mfe capability 4
|
||||
│ ├─ regions list regions-list capability 1
|
||||
│ └─ region dashboards region-dashboards capability 1
|
||||
├─ Prayer Mobilization
|
||||
│ ├─ UPG-100 List upg-100-mfe capability 4 — composite
|
||||
│ │ ├─ list upg-100-list capability 1 — single
|
||||
│ │ └─ poster wall poster-wall capability 1 — single
|
||||
│ └─ Prayer Progress doxa-simple-map (prayer) capability 2 — prop-table preset
|
||||
│ ├─ UPG-100 List upg-100-mfe capability 4
|
||||
│ │ ├─ list upg-100-list capability 1
|
||||
│ │ └─ poster wall poster-wall capability 1
|
||||
│ └─ Prayer Progress doxa-simple-map capability 2
|
||||
├─ Gospel Research
|
||||
│ ├─ Cities with Internet connectivity-map capability 1 — single
|
||||
│ └─ Gospel Resources resources-map capability 1 — single
|
||||
└─ Experimental (folder) every other bundle, discovered automatically from manifest.json
|
||||
│ ├─ Cities with Internet connectivity-map capability 1
|
||||
│ └─ Gospel Resources resources-map capability 1
|
||||
└─ Experimental every other bundle, discovered automatically
|
||||
```
|
||||
|
||||
What that buys you:
|
||||
</details>
|
||||
|
||||
- **Seven production maps, one URL.** The three sections above are the seven curated tabs.
|
||||
- **Every tab is still a standalone embed.** Want only the UPG-100 list on a partner site? Embed
|
||||
`upg-100-mfe` — or just its `upg-100-list` leaf. Taking the whole app apart costs nothing.
|
||||
- **Proving the whole proves the parts.** When the Research Gallery was embedded cross-origin on a
|
||||
foreign host page (see [deployment](#3-the-5-deployment-methods)), every nested plugin rode along.
|
||||
- **New plugins appear on their own.** Any bundle not placed in a named section lands in
|
||||
*Experimental*, read from the built `manifest.json`. A new folder in `plugin-designs/` shows up with
|
||||
zero edits to the shell.
|
||||
### Capability 4 — composite: plugins wired together
|
||||
|
||||
The tab order and grouping live in one place, `NAV_GROUPS` in
|
||||
[`doxa-maps-spa.vue`](./plugin-designs/doxa-maps-spa/doxa-maps-spa.vue); `bun run check:spa-tab-order`
|
||||
guards it.
|
||||
Nests lower-tier plugins and wires them with props and events. `plugin-designs/my-upg-100-list/`
|
||||
|
||||
---
|
||||

|
||||
|
||||
## 2. The 5 plugin capabilities — most advanced first
|
||||
| List (cap 1, embeddable alone) | Poster wall (cap 1, embeddable alone) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
A "capability" is **how much a plugin nests** — not how hard it is to use. The tour below starts from
|
||||
the most advanced thing this repo produces (an app of apps) and walks down to the simplest (one
|
||||
element), because the top of the ladder is the point: every rung below it stays independently
|
||||
embeddable. Every screenshot is a real bundle from this repo's build output.
|
||||
### Capability 3 — multi-view: many views, one element
|
||||
|
||||
| # | Capability | What it is | Example bundle |
|
||||
|---|------------|------------|----------------|
|
||||
| 5 | **Nested-SPA / app shell** | Many SPAs nested into one deep-linkable app. | `doxa-maps-spa` |
|
||||
| 4 | **Nested-element SPA** | A composite that nests *other plugins* and wires them with props/events. | `big-picture` |
|
||||
| 3 | **Monolithic element** | One `root.vue` nests many views internally → one element. | `doxa-research-map` |
|
||||
| 2 | **Prop-table element** | One `root.vue` + a preset table → many maps from one plugin. | `doxa-simple-map` |
|
||||
| 1 | **Single element** | One folder → one bundle → one custom element. | `all-people-groups` |
|
||||
|
||||
> The tier is decided by the files, not by how simple a folder looks: an `*.instances.json` makes a
|
||||
> bundle **cap-2** however light it is, and a `meta.json` `type` declares tiers 3/4/5. The authoritative,
|
||||
> generated answer lives in
|
||||
> **[documentation/reference-capability-exemplars.md](./documentation/reference-capability-exemplars.md)**
|
||||
> — cite that file rather than remembering.
|
||||
|
||||
### Capability 5 — nested-SPA / app shell
|
||||
|
||||
Every plugin in one deep-linkable page. Two cap-5 apps ship, and the difference matters when you embed
|
||||
one: **`doxa-maps-spa` (the Research Gallery, [section 1](#1-the-research-gallery--five-capabilities-in-one-plugin))
|
||||
is what an end user gets; `plugin-studio` is what a developer works in.**
|
||||
|
||||
*`plugin-studio` — the developer studio*, grouping every built plugin by capability (5 → 1). It is
|
||||
auto-generated: the build discovers every folder in `plugin-designs/`, so a new plugin appears in the
|
||||
studio without any wiring. This is [section 4](#4-the-plugin-studio).
|
||||
|
||||

|
||||
|
||||
### Capability 4 — nested-element SPA
|
||||
|
||||
A composite that nests **lower-tier plugins** and wires them with props and events. Each inner plugin
|
||||
stays independently embeddable, and the composite is embeddable too. `plugin-designs/my-upg-100-list/`
|
||||
ships three leaves:
|
||||
|
||||
| Composite (`upg-100-mfe`) | List (`upg-100-list`) | Poster wall (`poster-wall`) |
|
||||
|---------------------------|-----------------------|-----------------------------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 3 — monolithic element
|
||||
|
||||
A whole multi-view map from one `root.vue`. The trade-off: you cannot embed just one of its tabs.
|
||||
`plugin-designs/doxa-research-map/`
|
||||
|
||||

|
||||
|
||||
### Capability 2 — prop-table element
|
||||
### Capability 2 — presets: one plugin, many maps
|
||||
|
||||
One `root.vue`, many maps. `plugin-designs/doxa-simple-map/` ships a preset prop-table
|
||||
(`simple-map.instances.json`) that renders it three ways — each a standalone embeddable page from the
|
||||
**same** code.
|
||||
One `root.vue` plus a preset table. `plugin-designs/doxa-simple-map/`
|
||||
|
||||
| Prayer | Engagement | Adoption |
|
||||
|--------|------------|----------|
|
||||
|
|
@ -167,319 +82,130 @@ One `root.vue`, many maps. `plugin-designs/doxa-simple-map/` ships a preset prop
|
|||
|
||||
### Capability 1 — single element
|
||||
|
||||
One folder, one `.vue`, one embeddable element. `plugin-designs/all-people-groups/` shows every people
|
||||
group on one map.
|
||||
One folder, one `.vue`, one element. `plugin-designs/all-people-groups/`
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 3. The 5 deployment methods
|
||||
|
||||
Ordered easiest to hardest. **Status is stated honestly** — "proven" means it has been run and seen, and
|
||||
nothing below is upgraded past what the files support.
|
||||
|
||||
| # | Method | Status | Command / install |
|
||||
|---|--------|--------|-------------------|
|
||||
| 1 | **iframe** on any site | ✅ **proven** | `bun run build`, then paste the `embed.iframe` snippet from `manifest.json` |
|
||||
| 2 | **Mobile app** (Capacitor, Android) | 🛠️ built, not shot | `bun run check:mobile` (no toolchain) · `bun run phone` needs Android SDK + JDK 17 + `adb` |
|
||||
| 3 | **JavaScript embed** (`<script src>` + SRI) | ✅ **proven** | `bun run build`, then paste the `embed.script` + `integrity` snippet |
|
||||
| 4 | **WordPress plugin** (shortcode + block) | 🛠️ built, contract-proven, not shot | `bun run check:wp-embed`, then install `deployment/wordpress/doxa-maps-embed/` |
|
||||
| 5 | **Desktop app** (Tauri) | 🛠️ built, not shot | `bun run check:tauri` (no toolchain) · `cargo tauri build` needs Rust + webview libs |
|
||||
|
||||
**What "not shot" means:** the code exists and its contract test passes, but nobody has captured it
|
||||
running on a real device or install. Each is blocked by a missing local toolchain, not by missing code —
|
||||
Android SDK for method 2, a live WordPress for method 4, and a GUI for method 5.
|
||||
|
||||
Methods **1 and 3 are the two proven-live paths.** If you need a map on a site today, use one of those.
|
||||
|
||||
Both were **certified cross-origin on 2026-09-24** — the entire Research Gallery, embedded on a
|
||||
foreign host page, every inner plugin riding along (prove the whole = prove the parts):
|
||||
|
||||
| Method 1 — iframe on a host site | Method 3 — JS embed on a host site |
|
||||
|----------------------------------|-------------------------------------|
|
||||
|  |  |
|
||||
|
||||
> For a JS embed on another site the host page sets `window.MAP_TOKEN` (nested elements read it)
|
||||
> and the serving layer must send `Access-Control-Allow-Origin: *` — the tree is fetched
|
||||
> cross-origin by `manifest.json`-composed surfaces.
|
||||
|
||||
The full per-method command matrix, with every prerequisite, is in
|
||||
**[documentation/README-SECTION-3-cli-commands.md](./documentation/README-SECTION-3-cli-commands.md)**.
|
||||
|
||||
### The Mapbox token (read this before you file a bug)
|
||||
|
||||
The maps use Mapbox by default, which needs a `pk.` token. **The token works differently in dev than in
|
||||
the built tree**, and diagnosing one from the other is the most common way to lose an hour.
|
||||
|
||||
| Where | How it gets a token |
|
||||
|---|---|
|
||||
| **Dev server** (`bun run dev`) | Baked into the page for you — from `.env` (`VITE_MAPBOX_TOKEN`) if present, otherwise from `token` in `vite.doxa-maps-build-config.json`. |
|
||||
| **Built pages** (`bun run serve`, or hosted) | **Not baked.** Each page resolves a token at runtime: `?tk=<TOKEN>` on the URL, a token you paste into the page's own input box (kept in `localStorage`), or a token endpoint (`tokenUrl`). |
|
||||
|
||||
So on a built leaf page you typically need:
|
||||
|
||||
```
|
||||
http://localhost:4173/doxa-maps/demo-map/demo-map/index.html?tk=pk.your_token_here
|
||||
```
|
||||
|
||||
To use your own token in dev, create a `.env` — it is **gitignored**, so it never reaches the repo:
|
||||
|
||||
```bash
|
||||
printf 'VITE_MAPBOX_TOKEN=%s\n' 'pk.your_token_here' > .env
|
||||
```
|
||||
|
||||
**If a basemap is an empty grey void while the pins still draw, that is the token failing** — not a
|
||||
broken app.
|
||||
|
||||
**Or skip the token entirely.** A public `pk.` token can be restricted to specific URLs in the Mapbox
|
||||
dashboard, but a JAMstack embed still has nowhere to hide one. So the engine is one prop: set
|
||||
`provider: "maplibre"` on an instance (or add `?provider=maplibre` to a leaf page) and the map renders
|
||||
on MapLibre with no Mapbox key. `plugin-designs/doxa-research-map/research-map.instances.json` ships a
|
||||
ready-made MapLibre instance. The trade-off is deliberate: under MapLibre the place-search bar is
|
||||
hidden, because the geocoder would still bill the Mapbox key the instance exists to avoid.
|
||||
> Which bundle sits at which tier is generated, not remembered:
|
||||
> [documentation/reference-capability-exemplars.md](./documentation/reference-capability-exemplars.md).
|
||||
|
||||
---
|
||||
|
||||
## 4. The Plugin Studio
|
||||
## The 5 deployment methods
|
||||
|
||||
★ **This is where you work — and the keyword is INTEROPERABILITY.**
|
||||
### 1. iframe — the prayer map on any site ✅ production
|
||||
|
||||
The Plugin Studio is one place to **view all of your plugins at once** and **edit all of them in one
|
||||
place using your agent**. Every plugin appears in a single view, sorted **highest capability down to
|
||||
lowest**, so you can see the whole catalogue instead of hunting through folders.
|
||||
One `<iframe>` tag, no build step. Copy the `embed.iframe` snippet from `manifest.json`.
|
||||
|
||||
That sorting is the point. Once you can see every plugin side by side, you can tell your agent **which
|
||||
ones to group together** — any number of them — and compose them into **higher-capability plugins**
|
||||
using **props, events and layouts**. Group cap-1 and cap-2 elements and you get a cap-4 nested-element
|
||||
SPA; group those and you get a cap-5 app shell. That is exactly how the Research Gallery was built.
|
||||

|
||||
|
||||
### Get it running
|
||||
### 2. Mobile app — Simple Research on a phone ✅ built
|
||||
|
||||
Four steps from nothing to every map in one place. You need **git**, **[Bun](https://bun.sh)** and
|
||||
**Node ≥ 18**.
|
||||
The Simple Research dashboard (research map + people-groups grid), shipped on its own as a
|
||||
Capacitor app. `bun run check:mobile` · `bun run phone` (needs Android SDK + JDK 17)
|
||||
|
||||
```bash
|
||||
# 1. Clone (the repo root IS the studio — there is no subfolder to cd into)
|
||||
git clone https://git.nuxtbase.app/doxa-life/doxa-maps.git
|
||||
cd doxa-maps
|
||||
<p align="center"><img src="documentation/readme-images/deploy2-mobile-simple-research.png" alt="Method 2 — the Simple Research dashboard at phone size" width="320"></p>
|
||||
|
||||
# 2. Install dependencies
|
||||
bun install --frozen-lockfile
|
||||
### 3. JavaScript embed — the Research Gallery on dx-life ✅ production
|
||||
|
||||
# 3. Optional — your own Mapbox token for dev (see section 3)
|
||||
printf 'VITE_MAPBOX_TOKEN=%s\n' 'pk.your_token_here' > .env
|
||||
The whole capability-5 app on the dx-life research page, from one `<script src>` and one element —
|
||||
replacing the separate research map, list and regions dashboards the page used to carry. Copy the
|
||||
`embed.script` snippet (SRI-pinned) from `manifest.json`.
|
||||
|
||||
# 4. Open the Plugin Studio
|
||||
bun run dev
|
||||
```
|
||||

|
||||
|
||||
Then open **http://localhost:5173/** — that root URL *is* the Plugin Studio, with every plugin in one
|
||||
spot. (If port 5173 is busy Vite picks another one and prints it; read the line it logs.)
|
||||
### 4. WordPress / Disciple Tools plugin — 🧪 proof of concept
|
||||
|
||||
> **You do not need to build first.** The build output in `public/` is committed, so a fresh clone has
|
||||
> plugins to show the moment the dev server starts.
|
||||
A shortcode + block plugin in `deployment/wordpress/`; its contract test passes
|
||||
(`bun run check:wp-embed`). Proven, not production-ready.
|
||||
|
||||
Two things worth knowing:
|
||||
### 5. Desktop app (Tauri) — 🧪 proof of concept
|
||||
|
||||
- **`/` is the studio; `/__gallery` is the old staging gallery**, kept but demoted to an explicit URL.
|
||||
- **Edits hot-reload.** The studio mounts the real `plugin-studio` custom element through its own
|
||||
`index.js` — the same path the built bundle takes — so what you see is the real element, not a preview.
|
||||
A desktop binary and `.deb` build from `deployment/src-tauri/` (`bun run check:tauri`). Proven,
|
||||
not production-ready.
|
||||
|
||||
### Every command you need
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `bun run dev` | The Plugin Studio with hot reload, at http://localhost:5173 |
|
||||
| `bun x vite --port 5299 --strictPort` | A dev server on your own port (see the swarm rules below) |
|
||||
| `BUNDLE=<name> bun x vite build` | Build **one** bundle, without cleaning anyone else's output |
|
||||
| `BUILD_ALLOW=1 bun run build` | The full production tree in `public/doxa-maps/` (~4 minutes, takes a lock) |
|
||||
| `bun run serve` | Serve the built tree at http://localhost:4173, the way production does |
|
||||
| `bun run check:settled` | Exit 0 = the built tree is safe to measure or publish right now |
|
||||
| `bun run gates:pre` / `bun run gates:post` | The static and post-build verification gates |
|
||||
| `bun run verify:5-caps` · `bun run verify:interop` | Prove the five capabilities, and cap-4 interoperability, in a real browser |
|
||||
| `bun run shots` | Regenerate the README screenshots |
|
||||
|
||||
The full list — every deployment command with its prerequisites — is in
|
||||
[documentation/README-SECTION-3-cli-commands.md](./documentation/README-SECTION-3-cli-commands.md).
|
||||
|
||||
### Work with your agent
|
||||
|
||||
The studio's companion is your coding agent. The loop:
|
||||
|
||||
1. Open the studio (`bun run dev`) and look at the catalogue, sorted cap-5 → cap-1.
|
||||
2. Tell your agent which plugins to **group** — e.g. *"combine the research map and the people-groups
|
||||
grid into one composite."*
|
||||
3. The agent writes a new folder in `plugin-designs/<name>/`, wiring the children with props, events and
|
||||
a layout from `library/layouts/`.
|
||||
4. Refresh the studio. The new, higher-capability plugin is there.
|
||||
|
||||
**The whole contract is: write your app inside `plugin-designs/<your-map>/`.** There is no registration
|
||||
step and no per-plugin wiring — the build discovers every folder under `plugin-designs/`. Copy
|
||||
`plugin-designs/template-bundle/` to start a new one.
|
||||
|
||||
Two shared layouts do the composing, both in `library/layouts/`:
|
||||
|
||||
- **`VerticalTabsLayout`** — one element visible at a time, lazy-mounted on first open.
|
||||
- **`StackLayout`** — the opposite: elements stacked top-to-bottom at natural height, so the page
|
||||
scrolls through them.
|
||||
|
||||
### The production build
|
||||
|
||||
The dev server is for working; **you do not need a build to see a change.** When you want the
|
||||
deployable tree:
|
||||
|
||||
```bash
|
||||
BUILD_ALLOW=1 bun run build # ~4 minutes; writes public/doxa-maps/
|
||||
bun run serve # serve the BUILT tree at http://localhost:4173
|
||||
```
|
||||
|
||||
**`BUILD_ALLOW=1` is required on purpose.** Without it the build refuses (exit 75) and prints cheaper
|
||||
alternatives, because a full build wipes the output tree that every other agent in a swarm is
|
||||
reading. On your own machine, with no one else working, set it.
|
||||
|
||||
The build also takes a **lock**: if another build is already running it fails fast rather than
|
||||
corrupting the shared output. Set `BUILD_LOCK_WAIT=600` to queue behind it instead.
|
||||
|
||||
**The lock protects a second WRITER. It does not protect a READER.** Every build begins by deleting
|
||||
`public/doxa-maps/`, so while a build runs the tree is transiently near-empty — and anything reading
|
||||
it then gets a confident, wrong answer ("that bundle is missing"). Run `bun run check:settled` before
|
||||
any browser check, any publish, and any claim about what the tree contains, and **grade it by exit
|
||||
code**: like every gate here, it prints explanatory prose after its verdict, so `| tail -1` will
|
||||
mislead you. Do not substitute a `pgrep` check — a process list samples an instant, while the lock
|
||||
knows the queue.
|
||||
> **Mapbox token:** the dev server bakes one in; built pages take `?tk=pk.…`, a pasted token, or a
|
||||
> token endpoint. Or skip Mapbox entirely: `provider: "maplibre"` on any instance renders on MapLibre
|
||||
> with no key. Every command and prerequisite:
|
||||
> [documentation/README-SECTION-3-cli-commands.md](./documentation/README-SECTION-3-cli-commands.md).
|
||||
|
||||
---
|
||||
|
||||
## 5. Build it with an agent swarm
|
||||
## Build it with an agent swarm
|
||||
|
||||
This repo is designed for **many builders working in parallel without stepping on each other** — and
|
||||
the builders can be coding agents on your own laptop. It is **trunk-based development with a merge
|
||||
queue**, the same model as GitHub's merge queue, GitLab's merge trains and Rust's bors, arranged as
|
||||
concentric rings:
|
||||
Trunk-based development with a **merge queue** — the model behind GitHub's merge queue and GitLab's
|
||||
merge trains. Many agents build in parallel, each in its own worktree; changes land on `main` one
|
||||
at a time, rebased and gated, so `main` stays green.
|
||||
|
||||

|
||||
|
||||
- **`main` — the trunk.** The one shared branch. Everything converges here, and it is always green.
|
||||
- **Merge queue.** Changes land **one at a time, in order.** Each change is rebased onto the latest
|
||||
`main` — its author resolves any conflicts — and lands only if the gates pass. A change that fails
|
||||
leaves the queue; the next one goes. Nothing writes to `main` except the queue.
|
||||
- **Plugin Studio.** One repo and one shared `library/`, with every plugin visible in one place, so
|
||||
each builder can see what already exists and reuse it instead of rebuilding it.
|
||||
- **Agents.** **One agent per plugin, each in its own git worktree.** Each owns its plugin end to end;
|
||||
its private components stay inside its own `plugin-designs/` folder.
|
||||
- **Maintainer.** One reviewer — human or agent — keeps everyone honoring the model: plugins built in
|
||||
the studio, shared components genuinely shared, and every change going through the queue.
|
||||
|
||||
The effect is cooperative: every agent races to land working features, but the only way to land is
|
||||
through the queue, one change at a time, **leaving `main` green.**
|
||||
|
||||
The diagram's source is [`documentation/readme-images/src/delegation-model.svg`](./documentation/readme-images/src/delegation-model.svg).
|
||||
|
||||
### Spin one up on your laptop
|
||||
|
||||
**1. One worktree per agent**, as sibling folders, so no two agents share a working directory:
|
||||
|
||||
```bash
|
||||
git worktree add -b regions/work ../dx-maps-regions main
|
||||
git worktree add -b upg100/work ../dx-maps-upg100 main
|
||||
git worktree add -b maintainer/work ../dx-maps-maintainer main
|
||||
git worktree list
|
||||
```
|
||||
# one worktree + one agent per plugin
|
||||
git worktree add -b regions/work ../dx-maps-regions main
|
||||
git worktree add -b upg100/work ../dx-maps-upg100 main
|
||||
|
||||
**2. One agent per worktree, each owning one plugin.** Start your coding agent in each folder and give
|
||||
it a single `plugin-designs/<name>/` folder to own. The maintainer gets no plugin; it reviews what
|
||||
lands and pushes back when shared code is duplicated or a plugin reaches into another's folder.
|
||||
# one dev server per agent, each on its own port
|
||||
bun x vite --port 5301 --strictPort
|
||||
|
||||
**3. One dev server per agent, each on its own port, always with `--strictPort`:**
|
||||
|
||||
```bash
|
||||
bun install --frozen-lockfile # once per worktree
|
||||
bun x vite --port 5301 --strictPort # regions agent
|
||||
bun x vite --port 5302 --strictPort # upg100 agent
|
||||
```
|
||||
|
||||
Without `--strictPort`, Vite silently moves a busy port to the next one — and several gates have
|
||||
`5173` written into them, so a drifted server leaves those gates reporting a confident wrong answer
|
||||
about somebody else's page. With `--strictPort` the duplicate exits non-zero instead.
|
||||
|
||||
**4. One owner for the full build.** Agents edit source and check their own bundle with
|
||||
`BUNDLE=<name> bun x vite build`. Only one agent runs `BUILD_ALLOW=1 bun run build`, because every full build wipes
|
||||
the shared output tree first.
|
||||
|
||||
**5. Land through the merge queue.** On one machine, a file lock is a complete merge queue — agents
|
||||
wait their turn on it (`flock` ships with Linux; on macOS, `brew install flock`):
|
||||
|
||||
```bash
|
||||
# land through the queue: one at a time, rebased, gated
|
||||
flock /tmp/dx-maps-main.lock sh -c '
|
||||
git fetch origin &&
|
||||
git rebase origin/main &&
|
||||
bun run gates:pre &&
|
||||
git push origin HEAD:main
|
||||
'
|
||||
git fetch origin && git rebase origin/main &&
|
||||
bun run gates:pre && git push origin HEAD:main'
|
||||
```
|
||||
|
||||
The lock guarantees one landing at a time; the rebase makes the lander resolve every conflict; the
|
||||
gates keep `main` green. When several machines share one remote, use the hosting platform's merge
|
||||
queue instead (GitHub merge queue, GitLab merge trains), or protect `main` so only one builder at a
|
||||
time may push.
|
||||
One agent owns the full build (it wipes the shared output first); everyone else checks their own
|
||||
bundle with `BUNDLE=<name> bun x vite build`. A **maintainer** reviews what lands and keeps shared
|
||||
code shared. Agents joining this repo: read [AGENT-BRIEF.md](./AGENT-BRIEF.md).
|
||||
Diagram source: [`delegation-model.svg`](./documentation/readme-images/src/delegation-model.svg).
|
||||
|
||||
Agents joining this repo should also read [AGENT-BRIEF.md](./AGENT-BRIEF.md).
|
||||
---
|
||||
|
||||
## The Plugin Studio — ★ where you work
|
||||
|
||||
Every plugin in one place, sorted by capability. Pick any plugin to run it live, see what it is
|
||||
composed of, and copy its embed code. Tell your agent which plugins to **group**, and it composes
|
||||
them into a higher-capability plugin with props, events and a layout — that is how the Research
|
||||
Gallery was built. *(The studio is being redesigned; this screenshot tracks it.)*
|
||||
|
||||

|
||||
|
||||
### Get it running
|
||||
|
||||
You need **git**, **[Bun](https://bun.sh)** and **Node ≥ 18**.
|
||||
|
||||
```bash
|
||||
git clone https://git.nuxtbase.app/doxa-life/doxa-maps.git
|
||||
cd doxa-maps
|
||||
bun install --frozen-lockfile
|
||||
bun run dev # → http://localhost:5173/ is the Plugin Studio
|
||||
```
|
||||
|
||||
No build needed first — the built tree is committed. To start a plugin, copy
|
||||
`plugin-designs/template-bundle/` to `plugin-designs/<your-map>/`; the studio finds it automatically.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `bun run dev` | The Plugin Studio, hot-reloading, at http://localhost:5173 |
|
||||
| `BUNDLE=<name> bun x vite build` | Build one bundle only |
|
||||
| `BUILD_ALLOW=1 bun run build` | The full deployable tree in `public/doxa-maps/` (~4 min; refuses without the flag, to protect a shared tree) |
|
||||
| `bun run serve` | Serve the built tree at http://localhost:4173 |
|
||||
| `bun run check:settled` | Exit 0 = the built tree is safe to measure right now |
|
||||
| `bun run gates:pre` · `bun run gates:post` | The verification gates |
|
||||
| `bun run shots` | Regenerate these screenshots (`ONLY=<name>` for one) |
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
doxa-maps/
|
||||
├── plugin-designs/ YOUR maps live here — one folder per plugin (the FREE zone).
|
||||
│ ├── doxa-maps-spa/ the Research Gallery, capability 5 (nests the production maps)
|
||||
│ ├── plugin-studio/ the developer studio, capability 5 (nests every plugin)
|
||||
│ ├── dashboard-plus-grid/ capability-4 composite: research dashboard + people-groups grid
|
||||
│ ├── my-upg-100-list/ capability-4: UPG-100 list + poster wall
|
||||
│ ├── doxa-simple-map/ capability-2: one root.vue, three preset maps
|
||||
│ └── template-bundle/ copy this to start a new plugin
|
||||
├── library/ the SHARED zone (@map) — reusable, parameterized only.
|
||||
│ ├── layouts/ VerticalTabsLayout, StackLayout — how composites compose
|
||||
│ ├── components/ map controls, legends, search, poster, detail panels
|
||||
│ ├── composables/ map instance, shadow styles, print-in-place, retry
|
||||
│ └── styles/ colors/ data/ style registry, palettes, lookup tables
|
||||
├── data/ the public datasets the maps ship with
|
||||
├── deployment/ Capacitor (mobile), WordPress plugin, Tauri (desktop)
|
||||
├── documentation/ tutorial · how-to · reference · explanation + architecture
|
||||
├── internal/ the build generator + the verification gates
|
||||
└── public/doxa-maps/ the BUILD OUTPUT — the drop-anywhere tree (bundles, leaf pages,
|
||||
manifest.json). What gets served and embedded.
|
||||
plugin-designs/ YOUR maps — one folder per plugin, a free zone
|
||||
library/ the SHARED toolkit (layouts, components, composables) — a high bar
|
||||
public/doxa-maps/ the build output: the drop-anywhere tree + manifest.json
|
||||
deployment/ Capacitor · WordPress · Tauri
|
||||
internal/ the build generator and the gates
|
||||
documentation/ tutorial · how-to · reference · architecture — start at documentation/README.md
|
||||
```
|
||||
|
||||
**`plugin-designs/` is a free zone.** Your plugin is its own application in its own folder, building to
|
||||
its own output. Nothing you do there can break another author's map.
|
||||
|
||||
**`library/` is a HIGH BAR.** It is multi-tenant — reused by many maps and authors — so every edit is a
|
||||
breaking-change risk to everyone. Only contribute here when a component is genuinely reusable *and*
|
||||
parameterized, so other maps can use it. Keep map-specific, non-parameterized code in your own
|
||||
plugin-design. See [documentation/contributing/](./documentation/contributing/).
|
||||
|
||||
---
|
||||
|
||||
## More docs
|
||||
|
||||
Start at **[documentation/README.md](./documentation/README.md)** — the index of everything below.
|
||||
|
||||
| Read this | When |
|
||||
|---|---|
|
||||
| **[documentation/develop-a-plugin-and-see-it-live.md](./documentation/develop-a-plugin-and-see-it-live.md)** | ★ the golden path — zero to a plugin live, in 6 steps |
|
||||
| **[documentation/README-SECTION-3-cli-commands.md](./documentation/README-SECTION-3-cli-commands.md)** | the full per-method CLI + install matrix |
|
||||
| **[documentation/reference-capability-exemplars.md](./documentation/reference-capability-exemplars.md)** | which bundle is the capability-N example (generated) |
|
||||
| **[documentation/reference-library-index.md](./documentation/reference-library-index.md)** | the menu of reusable `library/` pieces you can import |
|
||||
| **[documentation/why.md](./documentation/why.md)** | 2 min — the problem, the idea, one diagram |
|
||||
| **[documentation/tutorial.md](./documentation/tutorial.md)** | build your first map, step by step |
|
||||
| **[documentation/how-to.md](./documentation/how-to.md)** | task recipes (embed, data, mobile) |
|
||||
| **[documentation/architecture/](./documentation/architecture/)** | the deep end — embeddability, app-wrappers, file map |
|
||||
|
||||
---
|
||||
|
||||
> **Bottom line: maps should be open source, run everywhere, and multiply.** This bundler makes each map
|
||||
> a self-contained file that embeds on your site, your partners' sites, WordPress, and mobile — from one
|
||||
> build. The Plugin Studio is where you compose them into something bigger; the Research Gallery is
|
||||
> what that looks like when you do.
|
||||
> **Maps should be open source, run everywhere, and multiply.** One build makes each map a
|
||||
> self-contained file for your site, your partners' sites and a phone. The Plugin Studio is where
|
||||
> you compose them into something bigger.
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
"build": "bash internal/build-lock.sh",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source check:backlog-refs",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1,015 KiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1.1 MiB |
BIN
documentation/readme-images/deploy2-mobile-simple-research.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
documentation/readme-images/deploy3-js-embed-dxlife.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 603 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
101
internal/check-backlog-refs.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-backlog-refs.mjs — a card may not point at a card that does not exist.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES. `depends_on` is the board's readiness mechanism: a card is ready when
|
||||
* its dependencies are done. A dependency naming a card that is NOT IN THE BACKLOG can never be
|
||||
* done, so the card can never become ready — and nothing says so. It reads as ordinary blocked
|
||||
* work forever.
|
||||
*
|
||||
* MEASURED 2026-09-24, three dangling references across the builder backlog:
|
||||
* IDEA-SLIM-MAP-SEARCHBAR-HOW-DO-FILTERS-FIT <- BUILD-PEOPLE-GROUP-SEARCH-…depends_on
|
||||
* IDEA-REGIONS-DASHBOARD-REVERSE-ENGINEER-DXLIFE <- two regions cards' depends_on
|
||||
* PRINCIPLE-MAPS-ARE-SECONDARY-LISTS-AND-GRIDS-ARE-PRIMARY <- two cards' parent
|
||||
* The first one matters most: that card is status:todo and waiting on something unfindable.
|
||||
* The last is cited by the loop prompt itself as an open card ("PRINCIPLE-MA"), so a reader is
|
||||
* being pointed at work that has no record.
|
||||
*
|
||||
* ★ SEVERITY IS NOT UNIFORM, so this gate grades it. A dangling `depends_on` on an OPEN card is a
|
||||
* FAILURE — that card is unreachable. A dangling `parent`, or a dangling anything on a card that is
|
||||
* already done, is REPORTED but does not fail: losing a parent is a filing problem, not a blocked
|
||||
* one, and failing on it would only teach people to delete the reference rather than restore the
|
||||
* card. Naming the difference is the point.
|
||||
*
|
||||
* ⚠ IT ONLY CHECKS REFERENCES THAT LOOK LIKE CARD IDS. Fields like `parent` also legitimately hold
|
||||
* prose in some cards ("the regions epic"), and treating those as broken links would drown the
|
||||
* real ones. The shape is SCREAMING-KEBAB, 9+ chars — the convention every id in this board uses.
|
||||
*
|
||||
* Usage: node internal/check-backlog-refs.mjs
|
||||
* SKIPs when _qa-cycle is absent (it lives outside every repo, by design).
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const QA = process.env.QA_CYCLE || resolve(HERE, '..', '..', '_qa-cycle')
|
||||
if (!existsSync(QA)) {
|
||||
console.log('BACKLOG-REFS: SKIP — _qa-cycle not present (expected in CI; it is in no repo).')
|
||||
console.log(' This gate reads the AGENTIC BACKLOGS, which live outside the repo and are not')
|
||||
console.log(' cloned with it. Absence is expected; it is NOT a pass.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const PHASES = ['01-ideation-research', '02-builder-cycle', '03-deployment-cycle']
|
||||
const REF_FIELDS = ['parent', 'depends_on', 'blocks', 'supersedes', 'supersedes_mechanism_of']
|
||||
// The id convention every card in this board follows. Prose in a `parent` field must NOT match.
|
||||
const ID_SHAPE = /^[A-Z][A-Z0-9-]{8,}$/
|
||||
|
||||
const cards = []
|
||||
for (const phase of PHASES) {
|
||||
const p = join(QA, phase, 'backlog.json')
|
||||
if (!existsSync(p)) continue
|
||||
const j = JSON.parse(readFileSync(p, 'utf8'))
|
||||
for (const c of j.cards || []) cards.push({ ...c, _phase: phase })
|
||||
}
|
||||
if (!cards.length) {
|
||||
console.log('BACKLOG-REFS: SKIP — no cards found in any phase backlog.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const ids = new Set(cards.map((c) => c.id))
|
||||
const OPEN = new Set(['todo', 'doing', 'in-progress', 'blocked'])
|
||||
|
||||
const blocking = [] // dangling depends_on on an OPEN card — the card can never become ready
|
||||
const noted = [] // everything else dangling — filing debt, reported not failed
|
||||
|
||||
for (const c of cards) {
|
||||
const f = c.fields || {}
|
||||
for (const key of REF_FIELDS) {
|
||||
const raw = f[key] !== undefined ? f[key] : c[key]
|
||||
if (!raw) continue
|
||||
for (const ref of (Array.isArray(raw) ? raw : [raw])) {
|
||||
if (typeof ref !== 'string' || !ID_SHAPE.test(ref)) continue
|
||||
if (ids.has(ref)) continue
|
||||
const entry = { from: c.id, key, ref, status: c.status || '(none)' }
|
||||
if (key === 'depends_on' && OPEN.has(String(c.status || '').toLowerCase())) blocking.push(entry)
|
||||
else noted.push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`BACKLOG-REFS: ${cards.length} card(s) across ${PHASES.length} backlog(s), `
|
||||
+ `${REF_FIELDS.length} reference field(s) checked.`)
|
||||
if (noted.length) {
|
||||
console.log(` ${noted.length} dangling reference(s) that do NOT block anything:`)
|
||||
for (const n of noted) console.log(` ${n.from} [${n.status}] .${n.key} -> ${n.ref}`)
|
||||
console.log(' Filing debt: a lost parent or a stale link on a finished card. Restore the card')
|
||||
console.log(' or drop the reference — but do not delete it just to quiet this line.')
|
||||
}
|
||||
|
||||
if (blocking.length) {
|
||||
console.error(`\nBACKLOG-REFS: FAIL — ${blocking.length} OPEN card(s) depend on a card that does not exist.`)
|
||||
for (const b of blocking) {
|
||||
console.error(` ✗ ${b.from} [${b.status}] depends_on ${b.ref}`)
|
||||
}
|
||||
console.error('\n A dependency that is not in the backlog can never be done, so these cards can')
|
||||
console.error(' never become ready — and nothing says so. They read as ordinary blocked work.')
|
||||
console.error(' Fix: restore the missing card, or remove the dependency if it is obsolete.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('BACKLOG-REFS: PASS — every open card\'s dependencies name a card that exists.')
|
||||
142
internal/check-poster-card-fits.mjs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-poster-card-fits — a poster card's print-options row must stay inside its own card.
|
||||
*
|
||||
* THE DEFECT THIS CATCHES, reported by the repo-hygiene agent off a fresh 1440px screenshot and
|
||||
* confirmed here: every poster card's print-options row (Prayer List · Card Deck · stock select ·
|
||||
* Poster Map) burst its card and overlapped the neighbours. MEASURED 2026-09-24 at 1440px:
|
||||
* 27 of 27 cards overflowing, worst 110px past a 212px card.
|
||||
*
|
||||
* ★ WHY SOURCE READING MISSED IT. `.pfs` already had `width:82%`, so the rule looks correct — a
|
||||
* constrained box. But flex items default to `min-width:auto` and REFUSE to shrink below their
|
||||
* intrinsic content width, so four controls pushed the row open regardless of the container's
|
||||
* width. The fix is flex-wrap plus min-width:0 on the children; the point for this gate is that
|
||||
* only GEOMETRY shows it.
|
||||
*
|
||||
* ⚠ IT ASSERTS TWO THINGS, because either alone is satisfiable by a broken UI:
|
||||
* 1. No print row intrudes into another card's box — the reported symptom.
|
||||
* 2. Every control still has a usable box. "Nothing overflows" is trivially true if the row
|
||||
* collapsed the buttons to zero width, which would be a worse product than the overflow.
|
||||
*
|
||||
* Usage: node internal/check-poster-card-fits.mjs [leafUrl] — SKIPs when nothing is served.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const DEFAULT_LEAF = (process.env.BASE_URL || 'http://127.0.0.1:4173')
|
||||
+ '/doxa-maps/my-upg-100-list/upg-100-mfe/index.html'
|
||||
const LEAF = process.argv[2] || DEFAULT_LEAF
|
||||
// ⚠ SWEEP WIDTHS. The report named 1440px, so this first guarded 1440px — but the defect is a
|
||||
// FLEX-SHRINK failure, and those are width-dependent: a narrower viewport gives the controls less
|
||||
// room, which is where the next regression appears. Guarding one width encodes the reporter's
|
||||
// sampling choice, not the property.
|
||||
const WIDTHS = (process.env.CARD_FIT_WIDTH || '390,768,1024,1440,1920')
|
||||
.split(',').map((n) => Number(n.trim())).filter(Boolean)
|
||||
|
||||
let reachable = false
|
||||
try { const r = await fetch(LEAF, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
|
||||
if (!reachable) {
|
||||
console.log(`POSTER-CARD-FITS: SKIP — nothing served at ${LEAF}.`)
|
||||
console.log(' Serve the built tree: python3 -m http.server 4173 --directory public (or set BASE_URL)')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const WALK = `(root, out) => { out = out || []; const rec = (r) => { for (const e of r.querySelectorAll('*')) { out.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return out }`
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
|
||||
async function measure(width) {
|
||||
const mobile = width < 500
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, isMobile: mobile, hasTouch: mobile })
|
||||
await page.goto(LEAF, { waitUntil: 'load', timeout: 45000 })
|
||||
await page.waitForTimeout(10000)
|
||||
const out = await page.evaluate(`(() => {
|
||||
const all = (${WALK})(document)
|
||||
const vis = (e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 }
|
||||
const cards = all.filter((e) => (e.className||'').toString().split(' ').includes('card')).filter(vis)
|
||||
const boxes = cards.map((c) => ({ c, b: c.getBoundingClientRect() }))
|
||||
let intrusions = 0, overflowing = 0, worst = 0, coveringPreview = 0, worstY = 0
|
||||
for (const { c, b } of boxes) {
|
||||
const row = c.querySelector('.pfs')
|
||||
if (!row) continue
|
||||
const rb = row.getBoundingClientRect()
|
||||
if (rb.right > b.right + 1 || rb.left < b.left - 1) {
|
||||
overflowing++
|
||||
worst = Math.max(worst, Math.round(Math.max(rb.right - b.right, b.left - rb.left)))
|
||||
}
|
||||
// ⚠ THE VERTICAL AXIS TOO — this measured only left/right, which is exactly how it passed a
|
||||
// driver-visible regression. Fixing the horizontal overflow with flex-wrap grew the row
|
||||
// 35px -> 78px, and because it sits at bottom:-14px the extra height pushed its TOP from
|
||||
// 216px up to 173px in a 238px card: over the preview image and the card's outline.
|
||||
// Horizontal clean, card visibly broken, gate green.
|
||||
const img = c.querySelector('img')
|
||||
if (img) {
|
||||
const ib = img.getBoundingClientRect()
|
||||
if (ib.height > 2 && rb.top < ib.bottom - 1) {
|
||||
coveringPreview++
|
||||
worstY = Math.max(worstY, Math.round(ib.bottom - rb.top))
|
||||
}
|
||||
}
|
||||
for (const o of boxes) {
|
||||
if (o.c === c) continue
|
||||
const ox = Math.min(rb.right, o.b.right) - Math.max(rb.left, o.b.left)
|
||||
const oy = Math.min(rb.bottom, o.b.bottom) - Math.max(rb.top, o.b.top)
|
||||
if (ox > 1 && oy > 1) intrusions++
|
||||
}
|
||||
}
|
||||
const ctrls = all.filter((e) => /\\b(pbtn|pstock)\\b/.test((e.className||'').toString())).filter(vis)
|
||||
const tiny = ctrls.filter((e) => { const b = e.getBoundingClientRect(); return b.width < 18 || b.height < 10 })
|
||||
return { cards: cards.length, rows: cards.filter((c) => c.querySelector('.pfs')).length,
|
||||
overflowing, worst, intrusions, coveringPreview, worstY,
|
||||
controls: ctrls.length, tiny: tiny.length }
|
||||
})()`)
|
||||
await page.close()
|
||||
return { width, ...out }
|
||||
}
|
||||
|
||||
const results = []
|
||||
try {
|
||||
for (const w of WIDTHS) results.push(await measure(w))
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
console.log('POSTER-CARD-FITS')
|
||||
console.log(` leaf : ${LEAF}`)
|
||||
for (const r of results) {
|
||||
console.log(` ${String(r.width).padStart(4)}px cards ${String(r.cards).padStart(2)}`
|
||||
+ ` · overflow ${r.overflowing}${r.worst ? ` (${r.worst}px)` : ''}`
|
||||
+ ` · neighbour ${r.intrusions}`
|
||||
+ ` · over-preview ${r.coveringPreview}${r.worstY ? ` (${r.worstY}px)` : ''}`
|
||||
+ ` · ${r.controls} control(s), ${r.tiny} too small`)
|
||||
}
|
||||
|
||||
const fails = []
|
||||
for (const r of results) {
|
||||
const at = `${r.width}px`
|
||||
// PRECONDITION: no cards means nothing was tested at this width — not a pass.
|
||||
if (!r.cards) { fails.push(`${at}: no poster cards rendered — wrong url, or the wall did not mount`); continue }
|
||||
if (!r.rows) { fails.push(`${at}: ${r.cards} card(s) but none carry a .pfs print-options row`); continue }
|
||||
if (r.intrusions) fails.push(`${at}: ${r.intrusions} print row(s) overlap a NEIGHBOURING card`)
|
||||
if (r.overflowing) fails.push(`${at}: ${r.overflowing} print row(s) extend past their own card (worst ${r.worst}px)`)
|
||||
// ⚠ "Nothing overflows" is trivially satisfied by a row whose buttons collapsed to nothing.
|
||||
if (r.tiny) {
|
||||
fails.push(`${at}: ${r.tiny} print control(s) shrank below a usable size — fitting the row by `
|
||||
+ 'crushing the buttons is not a fix')
|
||||
}
|
||||
// The row belongs BELOW the preview. Driver-reported 2026-09-24: "the download print options are
|
||||
// not showing up under the blue outline, they are overlapping where the blue outline would be".
|
||||
if (r.coveringPreview) {
|
||||
fails.push(`${at}: ${r.coveringPreview} print row(s) COVER the preview image `
|
||||
+ `(top sits ${r.worstY}px above the image's bottom edge) — growing the row taller to fit it `
|
||||
+ 'horizontally is not a fix')
|
||||
}
|
||||
}
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nPOSTER-CARD-FITS: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nPOSTER-CARD-FITS: PASS — at ${results.length} width(s) (${WIDTHS.join(', ')}px) every `
|
||||
+ 'print-options row sits inside its own card and below its preview, with every control still '
|
||||
+ 'big enough to press.')
|
||||
|
|
@ -76,6 +76,7 @@ await new Promise(r => setTimeout(r, 800))
|
|||
|
||||
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
|
||||
const fails = []
|
||||
const checked = {} // mode -> tabs actually driven; printed, and 0 is a FAIL (see the summary)
|
||||
try {
|
||||
for (mode of MODES) {
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
|
||||
|
|
@ -119,6 +120,7 @@ try {
|
|||
const needCanvas = !discovered
|
||||
const ok = v.state === 'ready' && (!needCanvas || v.canvas > 0)
|
||||
const why = v.state !== 'ready' ? `state ${v.state}${v.error ? ' — ' + v.error : ''}` : (needCanvas && !v.canvas ? 'mounted but painted no map canvas' : '')
|
||||
checked[mode] = (checked[mode] || 0) + 1
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] ${id}${why ? ' — ' + why : ''}`)
|
||||
if (!ok) fails.push(`[${mode}] ${id}: ${why}`)
|
||||
}
|
||||
|
|
@ -128,8 +130,14 @@ try {
|
|||
}
|
||||
} finally { await browser.close(); cleanup() }
|
||||
|
||||
// ★ A MODE THAT RAN NOTHING HAS NOT PASSED. A flag lost on the way in (package.json once ended
|
||||
// this entry in an inline # comment, so an appended --mode landed inside the comment) must not
|
||||
// read as "both modes green": each requested mode has to have driven at least one tab.
|
||||
for (const m of MODES) if (!checked[m]) fails.push(`[${m}] checked 0 tabs — this mode did not run`)
|
||||
const tally = MODES.map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
|
||||
if (fails.length) {
|
||||
console.log(`\nchecked: ${tally}`)
|
||||
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\ncheck-research-gallery-embed: PASS — the Research Gallery embeds on a foreign origin in ' + MODES.join(' + ') + ' mode')
|
||||
console.log(`\ncheck-research-gallery-embed: PASS — the Research Gallery embeds on a foreign origin (${tally})`)
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ async function shot(name, path, {
|
|||
// frame and loads no tiles of its own).
|
||||
if (mapboxReferer) await ctx.setExtraHTTPHeaders({ Referer: 'https://doxa.life/' })
|
||||
const page = await ctx.newPage()
|
||||
const url = BASE + path + (tokenFree ? '' : (path.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK))
|
||||
const url = (/^https?:/.test(path) ? path : BASE + path) + (tokenFree ? '' : (path.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK))
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
|
||||
if (waitFor) await page.waitForSelector(waitFor, { timeout: 20000 })
|
||||
|
|
@ -330,39 +330,24 @@ virtualPages.set('/__readme-iframe-host.html', `<!doctype html>
|
|||
await shot('deploy1-iframe', '/__readme-iframe-host.html',
|
||||
{ vp: { width: 1000, height: 700 }, wait: 11000, countFrames: true, mapboxReferer: false })
|
||||
|
||||
// ---- DEPLOY2: mobile — the SPA at phone viewport ----
|
||||
// Same correction as cap5: shoot the cap-5 ELEMENT at phone viewport, not the gallery page.
|
||||
// mapboxReferer:false for the same reason as the other two cap-5 shots — this IS the cap-5 shell,
|
||||
// just at a phone viewport, and it nests every tab in an iframe.
|
||||
await shot('deploy2-mobile-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html',
|
||||
{ vp: { width: 390, height: 844 }, wait: 10000, dsf: 3, mapboxReferer: false })
|
||||
// ---- DEPLOY2: mobile — the Simple Research dashboard as the phone app ----
|
||||
// The mobile app ships ONE dashboard, not the whole gallery rail: research-map-plus-grid (the cap-4
|
||||
// "Simple Research" composite — research map stacked over the people-groups grid), shot at a phone
|
||||
// viewport. It mounts no iframe, so it keeps the Referer the map tiles need.
|
||||
await shot('deploy2-mobile-simple-research', '/doxa-maps/research-map-plus-grid/research-map-plus-grid/index.html',
|
||||
{ vp: { width: 390, height: 844 }, wait: 12000, dsf: 3 })
|
||||
|
||||
// ---- DEPLOY3: research dashboard ----
|
||||
// ★ THE EARLIER NOTE HERE WAS WRONG, AND THE WAY IT WAS WRONG IS WORTH KEEPING (corrected
|
||||
// 2026-09-21). It read: "research-dashboard OWNS NO MAP — it is an SVG-charts surface (measured:
|
||||
// 1083 elements, 10 <svg>, ZERO <canvas>)". The MEASUREMENT was real; the INFERENCE inverted it.
|
||||
// research-dashboard absolutely owns a Mapbox map — src/components/dashboards/DashboardMapPanel.vue
|
||||
// imports useMapInstance, injects mapboxToken, and adds a vector source. Re-measured live, the page
|
||||
// DOES build a `mapboxgl-canvas` at 878x445.
|
||||
//
|
||||
// ★ "ZERO CANVAS" MEANT THE MAP FAILED TO INITIALISE, NOT THAT NO MAP EXISTS. Reading absence as a
|
||||
// property of the surface turned a symptom into an architectural "fact", and that fact then
|
||||
// justified relaxing the readiness check — so the shot stopped waiting for the very thing whose
|
||||
// absence was the bug, and the README's deploy-3 proof image has shown a BLANK GREY map pane since.
|
||||
// A weakened assertion that makes a failing check pass is how a defect gets promoted to a design note.
|
||||
//
|
||||
// WHY THE PANE IS STILL GREY (a real defect, tracked separately — not this file's to fix):
|
||||
// the panel sources `mapbox://mapbox.country-boundaries-v1`, and the shared pk. token is URL-
|
||||
// restricted to doxa.life. Measured: Referer doxa.life -> 200 (3/3), 127.0.0.1 -> 403, none -> 403.
|
||||
// The STYLE endpoint returns 200 from every origin, which is why other maps paint normally and only
|
||||
// the tileset-backed choropleth blanks. So the shot machine cannot paint this pane at all today.
|
||||
//
|
||||
// `waitFor: CANVAS` is therefore still wrong HERE — not because there is no map, but because the
|
||||
// canvas genuinely cannot be produced from this origin, and a gate that can never pass gets
|
||||
// weakened again by the next reader. minElements proves the surface rendered; check:shot-fidelity
|
||||
// is where "this machine cannot render what the shot must show" belongs.
|
||||
await shot('deploy3-research-dashboard', '/doxa-maps/research-dashboard/research-dashboard/index.html',
|
||||
{ waitFor: 'svg', wait: 10000, minElements: 200 })
|
||||
// ---- DEPLOY3: JS embed — the Research Gallery on a real host site ----
|
||||
// The whole cap-5 gallery JS-embedded on dx-life's research page (a <script src> plus one element,
|
||||
// the snippet manifest.json ships). The host is a SEPARATE app, so this shot runs only when its dev
|
||||
// server is up: DXLIFE_URL=http://127.0.0.1:<port> bun run shots. Without it the shot is skipped
|
||||
// and the committed image stays as it is.
|
||||
if (process.env.DXLIFE_URL) {
|
||||
await shot('deploy3-js-embed-dxlife', process.env.DXLIFE_URL.replace(/\/$/, '') + '/research',
|
||||
{ vp: { width: 1440, height: 1100 }, wait: 16000, tokenFree: true, minElements: 300 })
|
||||
} else if (!ONLY || ONLY.includes('deploy3-js-embed-dxlife')) {
|
||||
console.log(' SKIP deploy3-js-embed-dxlife (set DXLIFE_URL to a running dx-life dev server)')
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
server.close()
|
||||
|
|
|
|||
|
|
@ -433,10 +433,35 @@ useShadowStyles(
|
|||
'.hlabel{font-weight:600}' +
|
||||
'.card.is-last{box-shadow:0 0 0 2px #10b981,0 6px 18px rgba(0,0,0,.10)}' +
|
||||
'.card.is-last .tape-h{background:#10b981;color:#04241a;border-color:#10b981}' +
|
||||
// ⚠ min-width:0 ON THE CHILDREN — and NOT flex-wrap. width:82% bounds the fieldset, but flex
|
||||
// items default to min-width:auto and REFUSE to shrink below their intrinsic content width, so
|
||||
// four controls (Prayer List · Card Deck · stock select · Poster Map) burst the box regardless.
|
||||
// MEASURED 2026-09-24 at 1440px: all 27 cards overflowing, worst 110px past a 212px card.
|
||||
//
|
||||
// ★ flex-wrap:wrap FIXED THAT AND BROKE THE OTHER AXIS — driver-reported, same day. The row is
|
||||
// absolutely positioned at bottom:-14px, so wrapping it to three lines grew it 35px -> 78px and
|
||||
// pushed its TOP up from 216px to 173px into a 238px card: straight over the preview image and
|
||||
// the card's outline. Horizontal overflow traded for vertical. min-width:0 alone lets the
|
||||
// controls compress on ONE line, which is what the row was always meant to be.
|
||||
// The gate that passed this only measured horizontal edges; it now measures the row's top too.
|
||||
'.pfs{position:absolute;bottom:-14px;left:50%;transform:translateX(-50%);' +
|
||||
'width:82%;margin:0;padding:0 4px 3px;border:1px solid rgba(0,0,0,.14);' +
|
||||
'border-radius:2px;background:#fff;display:flex;gap:2px;justify-content:center;' +
|
||||
// ⚠ min-width:0 ON THE FIELDSET ITSELF. A <fieldset> gets `min-width:min-content` from the
|
||||
// UA stylesheet, which OVERRIDES width and max-width — measured: computed width 431px
|
||||
// inside a 212px card while the rule said 82%. That is the real reason `width:82%` read as
|
||||
// correct in source for so long and the row still burst the card.
|
||||
'width:82%;max-width:82%;min-width:0;box-sizing:border-box;margin:0;padding:0 4px 3px;' +
|
||||
'border:1px solid rgba(0,0,0,.14);' +
|
||||
'border-radius:2px;background:#fff;display:flex;flex-wrap:nowrap;gap:2px;justify-content:center;' +
|
||||
'box-shadow:0 1px 4px rgba(0,0,0,.10)}' +
|
||||
// Let every control shrink inside the row rather than push it open. overflow:hidden +
|
||||
// text-overflow keeps a squeezed label readable instead of spilling.
|
||||
'.pfs > .pbtn,.pfs > .pstock{min-width:0;max-width:100%;overflow:hidden;' +
|
||||
'text-overflow:ellipsis;white-space:nowrap}' +
|
||||
// ⚠ THE <select> IS THE ONE THAT WILL NOT SHRINK. Measured: with min-width:0 applied to
|
||||
// every child, .pstock still rendered 188px inside a 198px row because a <select> sizes to
|
||||
// its WIDEST OPTION and `flex:0 1 auto` keeps that as its basis. flex-basis:0 makes it share
|
||||
// the row like the buttons do; a cap keeps it from dominating the three text controls.
|
||||
'.pfs > .pstock{flex:1 1 0;max-width:38%}' +
|
||||
'.is-dark .pfs{background:#132019;border-color:rgba(110,231,183,.24)}' +
|
||||
'.pfs > legend{margin:0 auto;padding:0 5px;font-size:8px;text-transform:uppercase;' +
|
||||
'letter-spacing:.6px;font-weight:700;color:#8a93a0}' +
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
"check:poster-prints": "node internal/check-poster-prints.mjs",
|
||||
"check:provider-switch": "node internal/check-provider-switch.mjs # the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
|
||||
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs # the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
|
||||
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs # a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
|
||||
"check:share-policy": "node internal/check-share-policy.mjs",
|
||||
"check:zone-census": "node internal/check-zone-census.mjs # every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
|
||||
|
|
@ -146,9 +147,12 @@
|
|||
"check:synced-css": "node internal/check-synced-css-copies.mjs",
|
||||
"check:composite-reg": "node internal/check-composite-registration.mjs",
|
||||
"check:filter-width": "node internal/check-filter-width-stable.mjs",
|
||||
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs # the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes — bare <script>+<doxa-maps-spa> and <iframe> — with the layout inside the host box and every curated tab painting a map. Starts its own servers on OS-assigned ports; needs only a built tree. --mode js|iframe, --only <tab,tab>. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs # the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare <script>+<doxa-maps-spa> and <iframe>, with the layout inside the host box and every curated tab painting a map. The summary line prints the tab count PER MODE, and a mode that checked zero tabs is a FAIL. One mode only: check:gallery-embed:js or check:gallery-embed:iframe. Starts its own servers on OS-assigned ports; needs only a built tree. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:gallery-embed:js": "node internal/check-research-gallery-embed.mjs --mode js",
|
||||
"check:gallery-embed:iframe": "node internal/check-research-gallery-embed.mjs --mode iframe",
|
||||
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs # the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
|
||||
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
|
||||
"check:backlog-refs": "node internal/check-backlog-refs.mjs # a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.",
|
||||
"check:mjs-parse": "node internal/check-mjs-parses.mjs",
|
||||
"check:snapshot-guard": "node internal/check-snapshot-guard.mjs # every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
|
||||
"check:snapshot-guard:poison": "node internal/check-snapshot-guard.mjs --poison",
|
||||
|
|
@ -163,7 +167,7 @@
|
|||
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
|
||||
"check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source check:backlog-refs",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles",
|
||||
"check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs",
|
||||
"check:tabs-localized": "node internal/check-tabs-localized.mjs # a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element <doxa-maps-spa> renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's Composed-of panel shows it). Bundles not placed in a section appear under Experimental automatically. This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it).",
|
||||
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element <doxa-maps-spa> renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's \"Inside doxa-maps-spa\" tree shows it). Bundles not placed in a section appear under Experimental automatically. This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it).",
|
||||
"type": "nested-mfe-spa",
|
||||
"composes": [
|
||||
"dashboard-plus-grid/dashboard-plus-grid",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is a real cap-5 element, <plugin-studio>, the DEVELOPER STUDIO. Its vertical-tab sidebar is the composability ladder grouped by capability 5 -> 1; each row nests one plugin element (token-free elements mounted directly by their real tag; map elements nested as their own SPA in a lazy iframe) with its embed snippet. Unlike a static index.html gallery page, this is a compiled Vue custom element, so it supports all 5 deployment methods and can itself be nested/placed. This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it). Filename must be <profile>.meta.json where profile = the root .vue name (plugin-studio). generate-gallery.readSourceTiers() reads this and bakes capability 5 for the plugin, so it appears in the studio manifest as the app-shell (nested-mfe-spa). 'composes' lists the element tags it can nest (best-effort; the runtime tab list is read live from the built manifest, grouped by capability).",
|
||||
"type": "nested-mfe-spa",
|
||||
"composes": [
|
||||
"bp-big-picture",
|
||||
"dashboard-plus-grid",
|
||||
"people-groups-grid",
|
||||
"doxa-research-map",
|
||||
"doxa-simple-map",
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
ONE element, <plugin-studio>, renders a VERTICAL-TAB layout whose sidebar is the composability
|
||||
ladder grouped by CAPABILITY (5 → 1). Under each capability heading it lists every element that
|
||||
tier holds; clicking a row nests that element in the main slot. Below the tiers, one collapsible
|
||||
"Inside <composite>" section per composite draws its full composition tree, so a defect can be
|
||||
pinned on the exact nested element responsible. This is the DX viewing surface:
|
||||
tier holds; clicking a row nests that element in the main slot. Directly under each composite's
|
||||
row, its full composition tree is drawn indented, so a defect can be pinned on the exact nested
|
||||
element responsible. This is the DX viewing surface:
|
||||
the spot to SEE every plugin element work, in one place, ordered by capability, with the embed
|
||||
snippet for each — so a developer (or the Driver) can review each element and give feedback.
|
||||
|
||||
|
|
@ -94,7 +94,10 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Toolbar strip: the active element's identity, its COMPOSED-OF panel, and copy-embed. -->
|
||||
<!-- Toolbar strip: the active element's identity and copy-embed. Kept to ONE line on purpose —
|
||||
the plugin is what the studio is for, so it gets the rest of the main area. (A "Composed of"
|
||||
panel used to sit here; the Driver removed it 2026-09-24 because it took the plugin's space.
|
||||
Composition lives in the rail, indented under each composite.) -->
|
||||
<template #toolbar>
|
||||
<div class="ps-toolbar" v-if="active">
|
||||
<div class="ps-bar">
|
||||
|
|
@ -106,36 +109,6 @@
|
|||
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
|
||||
</div>
|
||||
|
||||
<!-- ★ COMPOSED OF — the per-entity composition surface. Renders ONLY for a composite (a
|
||||
bundle whose manifest entry carries `composes`); a leaf plugin shows nothing at all,
|
||||
which is the honest answer and costs a newcomer no reading.
|
||||
ONE ROW PER DIRECTLY-NESTED CHILD, maxDepth 1: name, capability badge, and a link to
|
||||
that child's OWN canonical entry in the rail. It is a list of LINKS, never copies. -->
|
||||
<div class="ps-composed" v-if="composedOf.length || composedUnresolved.length">
|
||||
<h2 class="ps-composed-h" :id="composedHeadingId">
|
||||
Composed of
|
||||
<span class="ps-composed-n">{{ composedOf.length }} nested {{ composedOf.length === 1 ? 'element' : 'elements' }}</span>
|
||||
<span class="ps-composed-warn" v-if="composedUnresolved.length"
|
||||
:title="'Declared in the manifest but not published as a plugin: ' + composedUnresolved.join(', ')">
|
||||
+{{ composedUnresolved.length }} not published
|
||||
</span>
|
||||
</h2>
|
||||
<ul class="ps-composed-list" :aria-labelledby="composedHeadingId">
|
||||
<li v-for="c in composedOf" :key="c.key">
|
||||
<button type="button" class="ps-composed-row" :data-child-key="c.key"
|
||||
:aria-label="'Open ' + c.label + ', capability ' + c.cap + ', ' + c.capLabel"
|
||||
@click="select(c.key)">
|
||||
<!-- No glyph here: the capability badge on the right carries the tier as readable
|
||||
text. (Emoji used to lead these rows and rendered as empty tofu boxes on a box
|
||||
with no emoji font; the rail now uses local SVG icons — see CAP_ICON.) -->
|
||||
<span class="ps-composed-name">{{ c.label }}</span>
|
||||
<span class="ps-composed-tag"><{{ c.tag }}></span>
|
||||
<span class="ps-composed-badge" :data-cap="c.cap">CAP {{ c.cap }} · {{ c.capShort }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ps-embed" v-if="embedOpen && active.embed">
|
||||
<label>script embed
|
||||
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
|
||||
|
|
@ -207,25 +180,6 @@ const PS_SHADOW_CSS = `
|
|||
.ps-bar-sub { display:block; font-size:12px; color:#6d766e; }
|
||||
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; white-space:nowrap; }
|
||||
.ps-btn:hover { background:#d1d4cd; }
|
||||
.ps-composed { border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; padding:9px 10px; }
|
||||
.ps-composed-h { margin:0 0 7px; font-size:11px; font-weight:600; letter-spacing:.07em; text-transform:uppercase; color:#6d766e; display:flex; align-items:baseline; gap:8px; }
|
||||
.ps-composed-n { font-weight:400; letter-spacing:0; text-transform:none; font-size:11px; color:#6d766e; }
|
||||
.ps-composed-warn { font-weight:400; letter-spacing:0; text-transform:none; font-size:11px; color:#6d766e; border:1px dashed #d1d4cd; border-radius:999px; padding:1px 7px; cursor:help; }
|
||||
/* ★ THE PANEL OWNS ITS OWN SCROLL, and this is load-bearing, not polish. VerticalTabsLayout is
|
||||
grid-template-rows:auto 1fr — the header (with #toolbar inside it) is the AUTO row and the body
|
||||
is 1fr — so a tall toolbar does not overflow, it SILENTLY SHRINKS THE PREVIEW. plugin-studio
|
||||
composes 10 children and doxa-maps-spa 6; ten un-capped rows would take ~330px straight out of
|
||||
the map below. Capping at ~4.5 rows keeps the panel a header, not a page: the count in the
|
||||
heading always tells the true total even when the list is scrolled. */
|
||||
.ps-composed-list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:4px; max-height:154px; overflow-y:auto; overscroll-behavior:contain; }
|
||||
.ps-composed-row { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:6px 8px; border:1px solid transparent; border-radius:8px; background:#f3f3f1; color:#3b463d; cursor:pointer; }
|
||||
.ps-composed-row:hover { background:#d1d4cd; border-color:#d1d4cd; }
|
||||
.ps-composed-row:focus-visible { outline:2px solid #92b195; outline-offset:1px; }
|
||||
.ps-composed-name { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ps-composed-tag { flex:1 1 auto; min-width:0; font:11px/1.35 ui-monospace,Menlo,monospace; color:#6d766e; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ps-composed-badge { flex:0 0 auto; font-size:10px; font-weight:600; letter-spacing:.04em; padding:3px 7px; border:1px solid #d1d4cd; border-radius:999px; background:#ffffff; color:#6d766e; white-space:nowrap; }
|
||||
.ps-composed-badge[data-cap="5"] { border-color:#92b195; color:#3b463d; background:#f3f3f1; }
|
||||
.ps-composed-badge[data-cap="4"] { border-color:#92b195; color:#3b463d; }
|
||||
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; }
|
||||
.ps-embed label { font-size:12px; color:#6d766e; display:flex; justify-content:space-between; align-items:center; }
|
||||
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #d1d4cd; border-radius:6px; background:#ffffff; color:#3b463d; padding:6px; resize:vertical; }
|
||||
|
|
@ -294,10 +248,6 @@ const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
|
|||
// These were emoji until 2026-09-24, and on a box with no emoji font (measured: this one) every rail
|
||||
// row led with an empty tofu box — the first thing a developer saw on every plugin.
|
||||
const CAP_ICON = { 5: 'monitor', 4: 'tree', 3: 'layers', 2: 'grid', 1: 'embed' }
|
||||
// The same five names, shortened to fit a badge beside a child's name in the Composed-of panel.
|
||||
// Same vocabulary as CAP_LABEL, just the distinguishing word — "App shell", not "App shell ·
|
||||
// nested-mfe-spa" — because the badge sits inline and the full label would wrap every row.
|
||||
const CAP_SHORT = { 5: 'App shell', 4: 'Composite', 3: 'Multi-view', 2: 'Preset', 1: 'Single' }
|
||||
|
||||
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
|
||||
// deactivate (see HEAVY_ELEMENTS use in the template + select()). They are NOT iframed: every
|
||||
|
|
@ -334,6 +284,14 @@ const HEAVY_ELEMENTS = new Set([
|
|||
'country-posters-map', 'key-cities-map', 'research-map-plus-grid'
|
||||
])
|
||||
|
||||
// SUPERSEDED BUNDLES — built, still embeddable, but NOT shown in the studio. big-picture is a dark
|
||||
// header of search + four filters over the people-groups grid with the grid's own (identical)
|
||||
// controls switched off: to a developer it is the grid a second time under a misleading name. The
|
||||
// Driver, 2026-09-24: "there should not be people groups grid twice … this is just a clone of people
|
||||
// groups grid". The real Big Picture is dashboard-plus-grid (the app shell's first tab), and
|
||||
// doxa-maps-spa's NOT_EXPERIMENTAL already hides big-picture for the same reason.
|
||||
const STUDIO_HIDDEN = new Set(['big-picture'])
|
||||
|
||||
// Load a bundle exactly once per URL; resolves after its define() has run.
|
||||
//
|
||||
// ★ A CLASSIC SCRIPT FIRES onload AND THEN FAILS, for a dev-server bundle. Measured 2026-09-22:
|
||||
|
|
@ -441,163 +399,54 @@ export default defineComponent({
|
|||
.filter(c => byCap[c] && byCap[c].length)
|
||||
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
|
||||
},
|
||||
// ★ COMPOSED OF — the composition model for the ACTIVE plugin only. One entry per DIRECTLY
|
||||
// nested child. Empty for a leaf, which is how the panel disappears for a non-composite.
|
||||
// The GROUPED rail model the shared VerticalTabsLayout paints: ONE list, highest capability to
|
||||
// lowest (Driver, 2026-09-24: "everything in studio organized by highest capability to lowest";
|
||||
// "the composability in the capability sidebar vertical tabs … to understand the interoperability
|
||||
// and the various elements that go into each plugin").
|
||||
//
|
||||
// ★ THE PANEL IS THE DEPTH-1 VIEW; the rail's composition trees (see compositionTrees) are the
|
||||
// full-depth one. The trees came back on 2026-09-24 as ALIAS rows — the reasoning below is why
|
||||
// they must never again be independent nodes.
|
||||
// ★ WHY COMPOSITION IS NOT SEPARATE NAVIGATION (researched over Storybook, Bit, Backstage, Figma,
|
||||
// Nx and Omlet, 2026-09-23). EVERY mature system SEPARATES THE NAVIGATION TREE FROM THE COMPOSITION GRAPH. None
|
||||
// of the six encodes "what composes what" as folder nesting, for one reason: a tree answers
|
||||
// WHERE DO I FIND IT, and it can only answer that if each thing lives in exactly one place.
|
||||
// What is INSIDE an entity is a different question and gets its own per-entity surface.
|
||||
// - Figma / Bit: a component has ONE canonical home; uses are references to it.
|
||||
// - Backstage: composition renders as a RELATIONS panel on an entity page, never as a
|
||||
// subfolder of the catalog tree.
|
||||
// - Storybook: the sidebar is capped at Category / Group / Component — two levels, as ours is.
|
||||
//
|
||||
// ★ AND WHY A MIRRORED CHILD MUST BE AN ALIAS. The cb07c55 folders listed each child inside every
|
||||
// composite that nested it as its own node: people-groups-grid appeared in FIVE rail folders
|
||||
// plus its own capability tier — six nav nodes for one plugin. No code was duplicated, but
|
||||
// IDENTITY was, which is precisely the "I can't name the plugin I'm reporting" problem. These
|
||||
// rows are LINKS to the one canonical entry (they call select() on the child's real key), so
|
||||
// the rail keeps exactly one node per plugin and this panel keeps the relationship.
|
||||
//
|
||||
// ★ maxDepth 1, deliberately. A nested cap-4 inside the cap-5 shell shows as ONE row here; it
|
||||
// does NOT expand its own children. Its breakdown is one click away, on its own page, where it
|
||||
// belongs. Anything deeper is a graph view, which is not a landing surface.
|
||||
//
|
||||
// ★ ONLY 8 of 21 bundles declare edges, and that is FINE — it is not a blocker and NOTHING here
|
||||
// fabricates one. Backstage runs at Spotify scale on hand-declared relations and its docs say
|
||||
// plainly that it "only displays relationships that have already been set up". A bundle with no
|
||||
// `composes` simply has no panel.
|
||||
//
|
||||
// ★ RESOLVING A `composes` ENTRY TO A ROW. Measured 2026-09-23: the manifest's `composes` arrays
|
||||
// are NOT one consistent shape —
|
||||
// - most entries are an ELEMENT TAG e.g. "people-groups-grid", "doxa-research-map"
|
||||
// - some (my-upg-100-list's and regions-dashboard's children) are a "<bundle>/<profile>" ref
|
||||
// that equals a ROW KEY verbatim: "my-upg-100-list/upg-100-list"
|
||||
// - one entry ("research-dashboard") is a BUNDLE name whose element tag is actually
|
||||
// "research-dashboard-map" — tag lookup misses, bundle lookup hits
|
||||
// so resolution tries, in order: exact row KEY → row TAG → row BUNDLE. First match wins (a tag
|
||||
// like "cities-internet-map" is shared by three profiles of one bundle; any one of them correctly
|
||||
// represents that child here). An unmatchable ref is DROPPED rather than rendered broken — a
|
||||
// manifest typo must cost one row, never the panel.
|
||||
composition() {
|
||||
const a = this.active
|
||||
const empty = { children: [], unresolved: [] }
|
||||
if (!a) return empty
|
||||
const b = this.manifestBundles.find(x => x.bundle === a.bundle)
|
||||
const refs = b && b.composes
|
||||
if (!refs || !refs.length) return empty
|
||||
const unresolved = []
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const ref of refs) {
|
||||
const row = this.resolveRef(ref, a.bundle)
|
||||
// ★ AN UNRESOLVABLE REF IS COUNTED, NOT SILENTLY SWALLOWED. Measured 2026-09-23:
|
||||
// big-picture declares composes:["bp-research-header","people-groups-grid"] but
|
||||
// "bp-research-header" is not a bundle, not any profile's element, and not any row key —
|
||||
// it is an internal sub-component of big-picture that was hand-declared here but never
|
||||
// published as its own plugin. Dropping the row is right (a dead link is worse than no
|
||||
// link), but rendering "1 nested element" under a manifest that declares 2 is a quiet
|
||||
// lie, and a quiet lie on the studio's own legibility surface is the exact failure this
|
||||
// panel exists to prevent. unresolved[] lets the heading say so out loud.
|
||||
if (!row) { unresolved.push(ref); continue }
|
||||
// Skip a self-reference and any ref that resolves to a row already listed: a panel that
|
||||
// named the same child twice would re-introduce, inside one screen, the identity problem
|
||||
// this whole surface exists to remove.
|
||||
if (row.key === a.key || seen.has(row.key)) continue
|
||||
seen.add(row.key)
|
||||
// ★ THE BADGE READS THE MANIFEST'S DECLARED TYPE, NEVER A HARDCODED MAP. row.cap was
|
||||
// produced by capFor(p.type) in rowsFromManifest straight from the manifest's profile
|
||||
// `type`, so retyping a profile in the manifest moves its badge with zero edits here. This
|
||||
// per-child badge is the one thing none of the six reference systems have — Backstage's
|
||||
// relations panel names a child but not its tier — so it has to be derived, not asserted.
|
||||
out.push({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tag: row.tag,
|
||||
cap: row.cap,
|
||||
capLabel: CAP_LABEL[row.cap] || ('capability ' + row.cap),
|
||||
capShort: CAP_SHORT[row.cap] || ('cap ' + row.cap)
|
||||
})
|
||||
}
|
||||
return { children: out, unresolved }
|
||||
},
|
||||
composedOf() { return this.composition.children },
|
||||
// Refs the manifest declares that resolve to no published plugin (see composition). Named in
|
||||
// the heading so the panel's count can never quietly disagree with the manifest.
|
||||
composedUnresolved() { return this.composition.unresolved },
|
||||
// Unique per active row so the list's aria-labelledby always points at THIS panel's heading.
|
||||
composedHeadingId() { return 'ps-composed-h' },
|
||||
// The GROUPED rail model the shared VerticalTabsLayout paints, in two parts (Driver, 2026-09-24:
|
||||
// "clear capability sections 5→1, every plugin reachable, composition tree legible"):
|
||||
//
|
||||
// 1. CAPABILITY SECTIONS, CAP 5 → CAP 1 — where each plugin LIVES. Exactly one node per plugin,
|
||||
// so every plugin has one name to report it by. Collapsible folders, open by default.
|
||||
// 2. COMPOSITION TREES — what each composite is MADE OF. One collapsible section per composite
|
||||
// (closed by default so the rail stays short), headed by the composite itself, with every
|
||||
// element nested into it drawn as an indented tree, ordered by capability at each level.
|
||||
// A child repeats in every section that nests it — the Driver's spec: "duplication across
|
||||
// sections is intended", because the question here is "what composes THIS plugin".
|
||||
// CAP 5 → CAP 1 sections, collapsible, open by default. Every plugin has exactly ONE canonical
|
||||
// row, in its own tier. Directly under a COMPOSITE's row sits its composition tree: every element
|
||||
// nested into it, indented by depth, ordered by capability at each level. So reading down the
|
||||
// rail never jumps back up the ladder — the tree lives with the plugin it explains.
|
||||
//
|
||||
// ★ HOW THE REPEATS STAY ONE IDENTITY. Every tree row is an ALIAS (VerticalTabsLayout `selects`):
|
||||
// clicking people-groups-grid under big-picture selects the SAME canonical row as clicking it in
|
||||
// CAP 2, highlights both, and shows the same Composed-of panel. afe6abe removed the earlier rail
|
||||
// folders because six independent nodes for one plugin meant there was no single name to report
|
||||
// it by; an alias has no separate identity, so the tree and the one-name rule now coexist.
|
||||
// people-groups-grid under dashboard-plus-grid selects the SAME canonical row as its own CAP 2
|
||||
// entry and highlights both, and arrow keys skip aliases. afe6abe removed the first rail folders
|
||||
// because six independent nodes for one plugin left no single name to report it by; an alias has
|
||||
// no separate identity, so nesting and the one-name rule coexist. The section count counts
|
||||
// canonical plugins only.
|
||||
navGroups() {
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
const rowModel = (r) => ({
|
||||
key: r.key,
|
||||
label: r.label,
|
||||
icon: r.icon,
|
||||
sub: '<code><' + esc(r.tag) + '></code>'
|
||||
})
|
||||
const tiers = this.groups.map(g => ({
|
||||
const treeOf = new Map(this.compositionTrees.map(t => [t.nodes[0].selects, t]))
|
||||
const rowsFor = (r) => {
|
||||
const out = [{ key: r.key, label: r.label, icon: r.icon, sub: '<code><' + esc(r.tag) + '></code>' }]
|
||||
const t = treeOf.get(r.key)
|
||||
if (t) {
|
||||
t.nodes.slice(1).forEach((n, i) => out.push({
|
||||
key: 'tree-' + r.key + '-' + i,
|
||||
selects: n.selects || ('none:' + r.key),
|
||||
depth: n.depth,
|
||||
label: n.label,
|
||||
badge: n.part ? 'part' : '',
|
||||
sub: n.part ? 'no standalone page' : 'CAP ' + n.cap + ' · <code><' + esc(n.tag) + '></code>'
|
||||
}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
return this.groups.map(g => ({
|
||||
key: 'cap-' + g.cap,
|
||||
badge: 'CAP ' + g.cap,
|
||||
label: g.label,
|
||||
count: g.rows.length,
|
||||
folder: true,
|
||||
open: true,
|
||||
rows: g.rows.map(rowModel)
|
||||
rows: g.rows.flatMap(rowsFor)
|
||||
}))
|
||||
// A composite is named by its BUNDLE here, not its profile: "Inside my-upg-100-list" says which
|
||||
// plugin you are looking into; "Inside upg-100-mfe" made you already know the answer.
|
||||
const trees = this.compositionTrees.map(t => ({
|
||||
key: 'tree-' + t.bundle,
|
||||
label: 'Inside ' + t.bundle,
|
||||
count: t.nodes.length - 1,
|
||||
folder: true,
|
||||
open: false,
|
||||
rows: t.nodes.map((n, i) => ({
|
||||
key: 'tree-' + t.bundle + '-' + i,
|
||||
// An empty selects (the studio's own head, or a part inside it) still gets a key no row
|
||||
// has, so it stays an alias: click is a no-op and arrow keys skip it.
|
||||
selects: n.selects || ('none:' + t.bundle),
|
||||
depth: n.depth || null,
|
||||
label: n.label,
|
||||
badge: n.part ? 'part' : '',
|
||||
sub: n.part
|
||||
? 'no standalone page'
|
||||
: 'CAP ' + n.cap + ' · <code><' + esc(n.tag) + '></code>'
|
||||
}))
|
||||
}))
|
||||
// Two row-less plain groups act as part titles, so the eye can tell "where each plugin lives"
|
||||
// from "what each composite is made of" — the two halves otherwise share one heading style.
|
||||
const title = (key, label) => ({ key, label, rows: [] })
|
||||
return [
|
||||
title('part-caps', 'Every plugin, by capability'), ...tiers,
|
||||
...(trees.length ? [title('part-trees', 'What each composite is made of'), ...trees] : [])
|
||||
]
|
||||
},
|
||||
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
|
||||
// or child lists live in this file, so a new composite appears here with zero edits.
|
||||
//
|
||||
// Depth is FULL, not 1 (the Composed-of panel is the depth-1 view). The Driver's acceptance test is
|
||||
// Depth is FULL. The Driver's acceptance test is
|
||||
// "can I point at a defect and NAME the single element responsible" — and a defect in the grid
|
||||
// inside big-picture inside this studio is two levels down, so a depth-1 list could not name it.
|
||||
// A child is expanded only when it is itself a composite (CAP 4+) whose bundle declares edges; a
|
||||
|
|
@ -625,31 +474,25 @@ export default defineComponent({
|
|||
kids.push(child)
|
||||
}
|
||||
kids.sort((a, b2) => b2.cap - a.cap || a.label.localeCompare(b2.label))
|
||||
for (const ref of parts) {
|
||||
nodes.push({ label: ref, part: true, selects: row.real ? row.key : '', depth: depth + 1 })
|
||||
}
|
||||
for (const k of kids) {
|
||||
nodes.push({ label: k.label, tag: k.tag, cap: k.cap, selects: k.key, depth: depth + 1 })
|
||||
if (!path.has(k.key)) walk({ ...k, real: true }, depth + 1, new Set([...path, k.key]), nodes)
|
||||
if (!path.has(k.key)) walk(k, depth + 1, new Set([...path, k.key]), nodes)
|
||||
}
|
||||
// Unpublished parts have no tier, so they sort LAST — below every capability — and open the
|
||||
// composite they live in.
|
||||
for (const ref of parts) nodes.push({ label: ref, part: true, selects: row.key, depth: depth + 1 })
|
||||
}
|
||||
for (const b of this.manifestBundles) {
|
||||
if (!b.composes || !b.composes.length) continue
|
||||
if (!b.composes || !b.composes.length || STUDIO_HIDDEN.has(b.bundle)) continue
|
||||
// The composite's own row: its highest-capability profile (rows are sorted cap desc).
|
||||
// ★ plugin-studio HAS NO ROW — it never lists itself as its own tab (mounting <plugin-studio>
|
||||
// inside itself is infinite regress). Its tree is still the one a developer most wants, so it
|
||||
// gets a head built from the manifest entry with an empty `selects`: select()'s dead-key guard
|
||||
// turns a click on it into a no-op rather than a blank pane.
|
||||
// plugin-studio has no row (it never lists itself), so it has no tree either: every row of
|
||||
// this rail already IS its composition.
|
||||
const own = this.rows.find(r => r.bundle === b.bundle)
|
||||
const p0 = (b.profiles && b.profiles[0]) || {}
|
||||
const head = own
|
||||
? { ...own, real: true }
|
||||
: { key: '', bundle: b.bundle, label: p0.profile || b.bundle, tag: p0.element || b.bundle, cap: capFor(p0.type || 'single'), real: false }
|
||||
const nodes = [{ label: head.label, tag: head.tag, cap: head.cap, selects: head.key, depth: 0 }]
|
||||
walk(head, 0, new Set([head.key]), nodes)
|
||||
if (nodes.length > 1) out.push({ bundle: b.bundle, label: head.label, cap: head.cap, nodes })
|
||||
if (!own) continue
|
||||
const nodes = [{ label: own.label, tag: own.tag, cap: own.cap, selects: own.key, depth: 0 }]
|
||||
walk(own, 0, new Set([own.key]), nodes)
|
||||
if (nodes.length > 1) out.push({ bundle: b.bundle, nodes })
|
||||
}
|
||||
out.sort((a, b) => b.cap - a.cap || a.bundle.localeCompare(b.bundle))
|
||||
return out
|
||||
},
|
||||
active() { return this.rows.find(r => r.key === this.activeKey) || null },
|
||||
|
|
@ -670,8 +513,8 @@ export default defineComponent({
|
|||
this.loadManifest()
|
||||
},
|
||||
methods: {
|
||||
// Resolve one `composes` entry to a studio row. The manifest's refs are NOT one shape (see
|
||||
// composition): an element TAG, a "<bundle>/<profile>" row KEY, a BUNDLE name whose tag differs,
|
||||
// Resolve one `composes` entry to a studio row. The manifest's refs are NOT one shape: an
|
||||
// element TAG, a "<bundle>/<profile>" row KEY, a BUNDLE name whose tag differs,
|
||||
// or a bare SIBLING profile name (gospel-resources composes "resources-map", "data-tables" — its
|
||||
// own profiles). Try sibling → key → tag → bundle; first match wins. null = published nowhere.
|
||||
resolveRef(ref, fromBundle) {
|
||||
|
|
@ -743,7 +586,7 @@ export default defineComponent({
|
|||
const out = []
|
||||
for (const b of (m.bundles || [])) {
|
||||
// never list the studio itself as one of its own tabs
|
||||
if (b.bundle === 'plugin-studio') continue
|
||||
if (b.bundle === 'plugin-studio' || STUDIO_HIDDEN.has(b.bundle)) continue
|
||||
for (const p of (b.profiles || [])) {
|
||||
const tag = p.element || (b.elements && b.elements[0]) || b.bundle
|
||||
const cap = capFor(p.type || 'single')
|
||||
|
|
@ -938,24 +781,6 @@ export default defineComponent({
|
|||
.ps-bar-sub { display: block; font-size: 12px; color: #6d766e; }
|
||||
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; white-space: nowrap; }
|
||||
.ps-btn:hover { background: #d1d4cd; }
|
||||
/* COMPOSED OF panel — mirror of the injected copy above. One row per directly-nested child, with a
|
||||
capability badge derived from the manifest's declared profile type. Keep the two copies in sync. */
|
||||
.ps-composed { border: 1px solid #d1d4cd; border-radius: 10px; background: #ffffff; padding: 9px 10px; }
|
||||
.ps-composed-h { margin: 0 0 7px; font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: #6d766e; display: flex; align-items: baseline; gap: 8px; }
|
||||
.ps-composed-n { font-weight: 400; letter-spacing: 0; text-transform: none; font-size: 11px; color: #6d766e; }
|
||||
.ps-composed-warn { font-weight: 400; letter-spacing: 0; text-transform: none; font-size: 11px; color: #6d766e; border: 1px dashed #d1d4cd; border-radius: 999px; padding: 1px 7px; cursor: help; }
|
||||
/* THE PANEL OWNS ITS OWN SCROLL — keep in sync with the injected copy above. The layout's header
|
||||
row is `auto`, so an uncapped 10-row panel does not overflow, it shrinks the preview beneath it
|
||||
by ~330px. The heading's count stays truthful when the list is scrolled. */
|
||||
.ps-composed-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; max-height: 154px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.ps-composed-row { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 6px 8px; border: 1px solid transparent; border-radius: 8px; background: #f3f3f1; color: #3b463d; cursor: pointer; }
|
||||
.ps-composed-row:hover { background: #d1d4cd; border-color: #d1d4cd; }
|
||||
.ps-composed-row:focus-visible { outline: 2px solid #92b195; outline-offset: 1px; }
|
||||
.ps-composed-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ps-composed-tag { flex: 1 1 auto; min-width: 0; font: 11px/1.35 ui-monospace, Menlo, monospace; color: #6d766e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ps-composed-badge { flex: 0 0 auto; font-size: 10px; font-weight: 600; letter-spacing: .04em; padding: 3px 7px; border: 1px solid #d1d4cd; border-radius: 999px; background: #ffffff; color: #6d766e; white-space: nowrap; }
|
||||
.ps-composed-badge[data-cap="5"] { border-color: #92b195; color: #3b463d; background: #f3f3f1; }
|
||||
.ps-composed-badge[data-cap="4"] { border-color: #92b195; color: #3b463d; }
|
||||
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #d1d4cd; border-radius: 8px; background: #f3f3f1; }
|
||||
.ps-embed label { font-size: 12px; color: #6d766e; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #d1d4cd; border-radius: 6px; background: #ffffff; color: #3b463d; padding: 6px; resize: vertical; }
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
10
public/doxa-maps/manifest.json
generated
|
|
@ -681,7 +681,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-jGD6GdGzjAWNQ/APgKvUZDcXvryVdhvFXGLWNVENRfjZMurTwcMGmOUXDGF3wvnh",
|
||||
"integrity": "sha384-4WnbHvh/qt/xIADBpWm8xKj0gcGj6hStHIGhrTLibVZtGGga8Bd/YXh31SD/tDCv",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -701,7 +701,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jGD6GdGzjAWNQ/APgKvUZDcXvryVdhvFXGLWNVENRfjZMurTwcMGmOUXDGF3wvnh\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4WnbHvh/qt/xIADBpWm8xKj0gcGj6hStHIGhrTLibVZtGGga8Bd/YXh31SD/tDCv\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -715,7 +715,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jGD6GdGzjAWNQ/APgKvUZDcXvryVdhvFXGLWNVENRfjZMurTwcMGmOUXDGF3wvnh\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4WnbHvh/qt/xIADBpWm8xKj0gcGj6hStHIGhrTLibVZtGGga8Bd/YXh31SD/tDCv\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -729,7 +729,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jGD6GdGzjAWNQ/APgKvUZDcXvryVdhvFXGLWNVENRfjZMurTwcMGmOUXDGF3wvnh\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4WnbHvh/qt/xIADBpWm8xKj0gcGj6hStHIGhrTLibVZtGGga8Bd/YXh31SD/tDCv\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -804,7 +804,7 @@
|
|||
"plugin-studio"
|
||||
],
|
||||
"composes": [
|
||||
"bp-big-picture",
|
||||
"dashboard-plus-grid",
|
||||
"people-groups-grid",
|
||||
"doxa-research-map",
|
||||
"doxa-simple-map",
|
||||
|
|
|
|||
58
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-jGD6GdGzjAWNQ/APgKvUZDcXvryVdhvFXGLWNVENRfjZMurTwcMGmOUXDGF3wvnh" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4WnbHvh/qt/xIADBpWm8xKj0gcGj6hStHIGhrTLibVZtGGga8Bd/YXh31SD/tDCv" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-jGD6GdGzjAWNQ/APgKvUZDcXvryVdhvFXGLWNVENRfjZMurTwcMGmOUXDGF3wvnh" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4WnbHvh/qt/xIADBpWm8xKj0gcGj6hStHIGhrTLibVZtGGga8Bd/YXh31SD/tDCv" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-jGD6GdGzjAWNQ/APgKvUZDcXvryVdhvFXGLWNVENRfjZMurTwcMGmOUXDGF3wvnh" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4WnbHvh/qt/xIADBpWm8xKj0gcGj6hStHIGhrTLibVZtGGga8Bd/YXh31SD/tDCv" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||