DRV1650-SR-EMBED-URL-RUNTIME-ORIGIN. The Driver copied
`research-map-plus-grid/research-map-plus-grid/index.html` from a page at
http://127.0.0.1:5192/, a link that works on no other site.
resolveShareUrl's tier 1 returns its input VERBATIM, correctly: a host handing over a
whole url has already decided. So a composite that BUILDS a tier-1 url takes on tier 2's
job, and both of mine wrote the same thing:
base ? base + '/' + leaf : leaf
On any host where the tree root cannot be derived from the script url — a dev server, an
ESM build, a tree served from a path that does not match `<name>/<name>.js` — base is ''
and the else branch hands tier 1 a bare relative path, which it then returns unresolved.
★ IT LOOKS RIGHT IN THE ONE PLACE ANYONE CHECKS. A relative share link WORKS WHEN
CLICKED, because the browser resolves it against the page you copied it from. It is the
EMBED that breaks — an <iframe src="…"> on a partner's page resolves against THEIRS — so
the failure is invisible exactly where it is tested and visible only where it ships.
research-map-plus-grid.vue:174 even documents the requirement ("It MUST be absolute,
which is the non-obvious part") four lines above the branch that violates it.
absoluteEmbedUrl() is tier 2's resolution made available to tier-1 builders: the
configured base when known, otherwise resolved against document.baseURI, which yields the
runtime origin. Never relative in a browser. The ladder is unchanged; only its last rung
is.
The gate has both arms, because either alone would rot: the RESOLVER must be absolute
with no base (the failing case, not the passing one), and no plugin may rebuild the
`: leaf` fallback. Negative-tested: removing the origin fallback, and re-introducing the
ternary in a plugin, each turn it red.
"Copy link didn't work" is most likely the same defect rather than a second one —
ShareButton's copy already falls back from navigator.clipboard to execCommand for
insecure contexts, so the copy almost certainly succeeded and produced a useless URL. If
it still fails after this, it is a separate bug and needs a browser to see.
Reported from a laptop running `bun run dev`: under the Plugin Studio, maps drew with no boundary
polygons and UPG-100 had no posters. The built tree was fine, and every gate measured the built tree.
Cause: a dev leaf page is /__leaf/<bundle>/<profile>. A plugin that falls back to the page-relative
'./assets' asked for /__leaf/<bundle>/assets/…, and the leaf route answered with its own HTML at
HTTP 200, so JSON.parse failed and the map drew without our data. /<tree>/assets/ was not served
in dev at all. The built leaf never hits this because its page passes assetsBase "../../assets".
- dev-plugin serves the pool at /__leaf/…/assets/ and /<tree>/assets/ too, and the dev leaf page
passes assetsBase "/assets" in its profile-config, as the built leaf passes its own.
- resolveAssetsBase's last rung is '/assets' under the dev server (import.meta.env.DEV, false in
every build); the built tree keeps './assets'.
- check:dev-assets (headless): starts a short-lived dev server for this checkout, requires
boundaries/previews/fonts/images/tables to answer 200 and not HTML at every prefix a dev page can
produce, and requires the dev leaf to carry the root-absolute assetsBase. It fails on the parent
commit (4 prefixes and both leaves) and passes here.
resolveAssetsBase computed `<treeRoot>/../assets` -- one path segment too high.
This reached the live site. MEASURED by publishing to the catalog and reverting:
leaf .../doxa-maps/my-upg-100-list/upg-100-list/index.html + '../../assets'
-> /plugin-assets/doxa-maps/assets 200
code root '/plugin-assets/doxa-maps' + '/../assets'
-> /plugin-assets/assets 404
The UPG-100 map went from "195 polys · 27/27 zones · 2107 pins" to
"⚠ boundary fetch 404 world-adm0.geojson" -- a basemap with none of our data on
it. Reverted within minutes; live is healthy.
★ THE COMMENT JUSTIFIED THE OFF-BY-ONE IN PROSE, which is how it survived. It
said the pool is "a SIBLING of the tree -- <root>/../assets -- which is why the
leaf page's generated value is '../../assets' (two up from
<tree>/<bundle>/<profile>/)". Two up from <tree>/<bundle>/<profile>/ is <tree>,
not the tree's parent. The arithmetic was wrong and the sentence made it read
deliberate. generate-tree stages assets INSIDE the tree on purpose -- "staged N
runtime asset(s) -> doxa-maps/assets", so a dropped folder is self-contained.
⚠ WHY FIFTEEN TICKS OF GREEN GATES MISSED IT, three independent reasons:
1. INVISIBLE LOCALLY. The dev web-root has BOTH public/assets (a sibling) and
public/doxa-maps/assets (inside), each holding the boundaries -- so the
wrong path RESOLVES on 127.0.0.1. The catalog publishes app/doxa-maps only;
no sibling exists there. No local run can tell the answers apart.
2. THE BYTE GATES PASSED. check:published and check:catalog-currency both went
GREEN on the broken publish -- 22 of 22 byte-identical, 0 stale. They prove
the bytes match, not that the page works.
3. NO GATE REFERENCED THE FUNCTION, while 10+ profiles across 6 bundles call
it.
So the fix ships with check:embed-base, which is PURE ARITHMETIC -- no browser,
no network, no served tree, so it cannot SKIP itself into irrelevance. It
computes what a leaf's own '../../assets' means and asserts the resolver agrees:
the oracle is what the generated HTML already says, not an opinion about where
assets belong. It also pins the explicit ladder rungs, so a narrow fix cannot
swallow a host-supplied assetsBase.
MUTATION-TESTED: restoring the original '/../assets' fails all four tree roots
(live catalog, dev, doxa.life drop-in, deep subpath) in milliseconds.
Eleven profiles across six bundles each carried the same line:
profileConfig?.assetsBase || './assets'
That default is right on a standalone leaf page, where the generated host passes
'../../assets' and the tree root is two segments up. It is wrong the moment the
element is nested in the cap-5 shell, because a relative path resolves against
the SHELL's page, not the child's. Measured in a browser on the built tree:
leaf /doxa-maps/gospel-resources/resources-map/index.html ... 0 failed
shell /doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html ...... 404 assets/tables/gospel-by-country.json
404 assets/boundaries/world-adm0.geojson
No build-time gate could see it: './assets' is a valid string in both hosts, and
only the host decides what it resolves to. check-composite-srcs proves a nested
src exists on disk, which is a different question.
resolveAssetsBase() in library/utils/embedBase.js now derives an absolute base
from the bundle's own script URL (or the treeBase the shell already forwards),
and deliberately REJECTS a relative assetsBase from config -- honouring it would
reintroduce the defect through the config meant to cure it.
The fix is in the child, not the shell, so a new bundle that fetches assets
works nested on day one with no edit to doxa-maps-spa.vue.
Verified: shell 404s 2 -> 0 on Gospel Resources; Cities & Internet, UPG-100 and
Big Picture also 0 asset 404s; leaf hosts unchanged at 0.
- internal/playground → internal/plugin-gallery; playground-smoke → plugin-gallery-smoke;
generate-spa → generate-gallery (scripts: gallery, smoke:gallery; smoke:playground stays as alias)
- generate-gallery exports renderGallery(model, { mode }) — the dev plugin serves the same page
at / from the discovered registry (dev mode imports the dev entries, ladder popovers on);
the build writes public/index.html from the manifest (byte-identical to before)
- Docs and probes use the gallery vocabulary
- ShareButton resolves its link from where the bundle is hosted (profile-config.shareUrl ->
embedBaseUrl -> window.MAP_EMBED_BASE_URL -> the bundle's own script URL); profiles pass
tree-relative embed pages, so links work in the playground, on doxa.life and on any CDN
- Generated leaf pages try token endpoints in order (override -> configured absolute URL ->
same-origin) and only then offer the paste-once box; build config points at the canonical
https://doxa.life/api/maps/token
- Dev playground extracted from vite.config.js into internal/playground/{discover,render,dev-plugin}.mjs;
serves /assets/* like the built site so data-driven maps initialise; passes assetsBase +
shareUrl to every element; header ladder with a Start-here primer and the five kinds of
mounting files (single-element, preset-element, multi-view-element, composite-element, app-shell)
- New gates: smoke:playground (every element boots, with status text + data diagnostics),
smoke:share (Share URL resolves and reopens the same map), sync:dropin (mirror public/ into a
host checkout, never credentials), deploy:dev (build + sync)
- MapToolbar + ShareButton on every map profile; UPG-100 composite (map + prayer lists) on the
pane layout; shared --map-radius token consumed by the legend components
- docs/reference.md: profile types table, profile-config assetsBase/shareUrl/embedBaseUrl, gates