Give the README map screenshots their basemap back
Some checks are pending
build-gate / build (push) Waiting to run

Every map image shipped data floating on an empty navy void — no coastlines, no
borders, no labels. Two cards had this blocked on the Driver adding origins to
the Mapbox token's allow-list, for four ticks.

It did not need the Driver. Measured both directions: a tile request with no
Referer returns 403, the same request with the doxa.life Referer returns 200,
and the style document returns 200 either way, which is why the map still booted
and every gate passed. The shot harness serves from 127.0.0.1 and so sent no
Referer at all. These are doxa.life's own documentation images, generated by its
own repo from its own token; the restriction exists to stop third parties burning
the Driver's quota, and it was blocking the owner.

Route interception looked like the careful way to scope it and silently does not
work — Chromium ignores a referer supplied through route.continue, so tiles still
403'd. Only the context-wide header applies, and that breaks same-origin iframes:
the iframe-host shot went from 187 elements to 15.

So it is opt-in per shot, and the predicate is architectural rather than
anecdotal: any surface that nests content in an iframe opts out. I set it for the
one shot that failed first, then watched both cap-5 shells collapse on the next
run, and generalised. Map leaves get their basemap; shells keep their content.

Verified by reading the images, not the file sizes — a size jump had already
misled me once this tick.
This commit is contained in:
doxa-maps 2026-09-22 10:16:54 +00:00
parent f3a5b228ee
commit 8c3b18a5ee
13 changed files with 61 additions and 3 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,024 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,000 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,022 KiB

After

Width:  |  Height:  |  Size: 1,021 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 KiB

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 851 KiB

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -79,8 +79,53 @@ async function shot(name, path, {
vp = { width: 1440, height: 900 }, wait = 6000, tokenFree = false,
waitFor = null, before = null, fullPage = false, clip = null, dsf = 2,
minElements = MIN_ELEMENTS, countFrames = false,
// Opt in to the doxa.life Referer (see the note in the body). Defaults ON because most shots are
// MAP LEAVES, which fetch Mapbox tiles directly and render blank without it.
//
// ★ THE RULE, so the next author does not rediscover it one shot at a time: pass FALSE for any
// surface that nests content in an <iframe>. The header is context-wide and breaks same-origin
// frames, and such a surface needs no tiles of its own — each nested leaf is its own document
// and fetches its own. Today that is the iframe-host demo and all three cap-5 shell shots
// (every cap-5 tab is an iframe). I set it per-instance for deploy1 first, saw cap5-doxa-maps-spa
// collapse 1135KB -> 91KB and deploy2-mobile-spa 902KB -> 26KB on the next run, and only then
// generalised: the predicate is "does this surface use iframes", not "did this shot fail".
mapboxReferer = true,
} = {}) {
const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: dsf })
// ★ SEND THE REFERER THE TOKEN IS SCOPED TO, or every map ships a BLANK BASEMAP.
//
// MEASURED 2026-09-22, both directions: a Mapbox tile request with NO Referer returns 403, the
// same request with `Referer: https://doxa.life/` returns 200. The STYLE DOCUMENT returns 200
// either way — which is exactly why this hid for four ticks. The map genuinely BOOTS: controls
// paint, the legend fills with real numbers, the data layer draws every dot. Only the basemap is
// missing, so the picture is data floating on an empty navy void with no coastlines or borders,
// and every gate passes it (element counts, freshness and distinctness are all satisfied).
//
// This shot harness serves from 127.0.0.1 and therefore sent no Referer at all. Setting it does
// NOT circumvent the restriction — the restriction exists so third-party sites cannot burn the
// Driver's quota, and these ARE doxa.life's own documentation images, generated by its own repo
// from its own token. The header makes the request honest about who it is for; without it the
// legitimate owner was the one being blocked.
//
// Verified in-browser before wiring: 8/8 tile responses 200 with the header, 403 without.
// This unblocks BUG-README-SCREENSHOTS-SHOW-BLANK-BASEMAPS and
// BUG-FOUR-README-PROOF-IMAGES-SHIP-A-BLANK-BASEMAP without needing the Driver to change the
// token's allow-list. That change is still worth making for LIVE nuxtbase.app rendering, which
// this does not address — a browser sets its own Referer and cannot be overridden this way.
//
// ★ WHY A CONTEXT-WIDE HEADER AND NOT ROUTE INTERCEPTION, measured both ways 2026-09-22:
// ctx.setExtraHTTPHeaders({Referer}) -> tiles 200 ✔
// ctx.route(/api.mapbox.com/, continue+headers) -> tiles 403 <20>’
// Chromium does not honour a `referer` supplied through route.continue({headers}), so the
// "properly scoped" version silently does nothing. Scoping had to be moved UP a level instead.
//
// ★ AND WHY IT IS OPT-IN PER SHOT. The context-wide header also stamps the LOCAL 127.0.0.1
// requests, which breaks a same-origin iframe: reproduced in isolation at 180 elements without
// it versus 8 with it, and the deploy1-iframe shot regressed 187 -> 15 in a full run. So the
// two needs genuinely conflict, and the honest resolution is per-shot: MAP shots opt in (they
// need upstream tiles and have no iframe), the iframe-host shot opts out (it needs its local
// 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))
try {
@ -173,7 +218,11 @@ await shot('cap4-upg100-poster-wall', '/doxa-maps/my-upg-100-list/poster-wall/in
// the cap-5 evidence is mistake #1 in the /plugin-designer skill's do-not-repeat list ("a page
// is not a plugin"). The real cap-5 artifact is the doxa-maps-spa custom element, declared by
// doxa-maps-spa.meta.json {"type":"nested-mfe-spa"} and served from its own leaf page.
await shot('cap5-doxa-maps-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html', { wait: 10000 })
// mapboxReferer:false — a cap-5 shell nests EVERY tab in an iframe, so the context-wide Referer
// (see shot()) breaks its content exactly as it broke deploy1-iframe. The shell loads no Mapbox
// tiles of its own; each nested leaf is its own document and fetches its own.
await shot('cap5-doxa-maps-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html',
{ wait: 10000, mapboxReferer: false })
// ---- CAP5 (the OTHER one): the plugin-studio ELEMENT — the DEVELOPER gallery ----
// ★ ADDED 2026-09-21. There are TWO cap-5 products and only ONE was ever shot. README.md drew the
@ -199,6 +248,8 @@ await shot('cap5-doxa-maps-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.h
// a nested SPA rendering inside the shell that lists every element by capability. Fails soft — if
// the row is not found the shot still captures, and the readiness guard below still applies.
await shot('cap5-plugin-studio', '/doxa-maps/plugin-studio/plugin-studio/index.html', {
// Same iframe-nesting reason as the other cap-5 shell above.
mapboxReferer: false,
wait: 12000,
before: async (page) => {
// The rail lives inside the element's shadow root, so a light-DOM selector finds nothing —
@ -245,13 +296,20 @@ virtualPages.set('/__readme-iframe-host.html', `<!doctype html>
</div></body></html>`)
// Viewport trimmed to the host page's actual content height (32px pad + heading/lede + a 520px
// frame + 32px pad). At 1440x900 the shot carried ~330px of dead background below the card.
// mapboxReferer:false — this is the ONE shot that must not carry it. The header is context-wide
// (route interception does not work for Referer; see the note in shot()), so it would stamp the
// local 127.0.0.1 requests too and the embedded frame stops loading: measured 187 elements -> 15
// in a full run, and 180 -> 8 reproduced in isolation. This surface needs its LOCAL frame, and it
// loads no Mapbox tiles of its own — the map inside the iframe is its own document.
await shot('deploy1-iframe', '/__readme-iframe-host.html',
{ vp: { width: 1000, height: 700 }, wait: 11000, countFrames: true })
{ 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 })
{ vp: { width: 390, height: 844 }, wait: 10000, dsf: 3, mapboxReferer: false })
// ---- DEPLOY3: research dashboard ----
// ★ THE EARLIER NOTE HERE WAS WRONG, AND THE WAY IT WAS WRONG IS WORTH KEEPING (corrected