Two related fixes, both about a measurement that had outlived the thing it
measured.
The shot harness fetched its Mapbox token from doxa.life/api/maps/token on every
run. That token belongs to the Mapbox account `doxalife` and is URL-restricted
to doxa.life, which is why the harness also had to spoof a Referer header to get
any tiles at all. Meanwhile the built bundles carry a different token entirely —
`gosystems`, baked in from vite.doxa-maps-build-config.json — and that one is not
restricted. Measured with no Referer: mapbox-streets-v8, country-boundaries-v1
and bathymetry-v2 all return 200 on the baked token and 403 on the fetched one.
The harness was doing extra work to use the weaker of the two.
It now reads the build config first and keeps the fetch as a fallback. Beyond
simply working, a README image is evidence about the SHIPPED artifact, so it
should render with the credential the artifact ships with; rendering with a
different token means the picture stops proving what a reader downloads. The
local read is also deterministic — the network path degraded silently, setting
the token to empty when doxa.life was slow, after which every shot was taken
token-limited and still written to disk.
check:shot-fidelity had the same problem one layer up. It fired its tileset
findings whenever any file referenced a mapbox:// tileset, and printed
"Referer doxa.life -> 200 · 127.0.0.1 -> 403 · no referer -> 403" as though it
were a live reading. It was one, on 2026-09-21, against the token that shipped
then. After the token swap in 1b6316a the sentence kept printing — warning about
a blank void while the shots came out fully painted. A gate that states a
measured fact has to re-measure it, or it becomes a confident lie at exactly the
moment someone fixes what it describes. It now fetches one real tile with the
baked token and no Referer, and suppresses the tileset findings when that
returns 200.
Network failure is deliberately not treated as a restriction. An offline box
cannot tell "restricted" from "unreachable", and asserting a restriction that
may not exist is the defect being repaired.
Negative-tested: pointing the probe at the restricted doxalife token makes the
gate report RESTRICTED and fire all three findings; the shipped token makes it
report REACHABLE and suppress them. The first attempt at that test proved
nothing and is worth recording — the poison never applied, because doxa.life
returns 403 to Python's default user agent, so the copy was never written and
the unmodified gate ran and "passed". A negative test that cannot fail is the
same defect in miniature. Re-run with a browser UA, it discriminated correctly.
The 12 regenerated screenshots are the point of all this. cap3-monolithic-research
was read as an image, not inferred from the generator's exit code: it now shows
coastlines, country borders, place labels, ocean names and Mapbox/OpenStreetMap
attribution, with the 2,107 data dots sitting on real geography instead of an
empty void. Note the PNG got 67KB SMALLER while gaining all of that, so file size
was never going to answer the question.
check:shot-fonts still fails, and that is pre-existing and honest: this box has no
emoji font, so six bundles shoot tofu boxes. Verified by stashing these edits and
re-running — exit 1 either way. The markup is correct; the fix is a font on the
shot machine, never removing emoji from the product.
375 lines
24 KiB
JavaScript
375 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* readme-shots.mjs — capture the README screenshot family from the BUILT tree.
|
||
*
|
||
* Serves public/ on a local port (same static server shape as smoke.mjs), then
|
||
* drives headless chromium to screenshot each capability + deploy surface.
|
||
* A live Mapbox token is fetched from https://doxa.life/api/maps/token and passed
|
||
* as ?tk= so maps fully paint (network is up). Token-free surfaces (vision-card,
|
||
* template) render regardless.
|
||
*
|
||
* Output: documentation/readme-images/*.png
|
||
*/
|
||
import { chromium } from 'playwright'
|
||
import { createServer } from 'node:http'
|
||
import { readFile, stat, mkdir } from 'node:fs/promises'
|
||
import { join, extname, resolve, dirname } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||
const ROOT = resolve(HERE, '..', 'public')
|
||
const OUT = resolve(HERE, '..', 'documentation', 'readme-images')
|
||
await mkdir(OUT, { recursive: true })
|
||
|
||
const TYPES = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript',
|
||
'.json':'application/json', '.css':'text/css', '.webp':'image/webp', '.png':'image/png',
|
||
'.svg':'image/svg+xml', '.geojson':'application/json', '.csv':'text/csv', '.woff2':'font/woff2' }
|
||
|
||
// In-memory pages (e.g. the iframe host demo). Served without touching public/, so no agentic
|
||
// scratch lands in the studio tree and nothing is lost to the next build's clean.
|
||
const virtualPages = new Map()
|
||
|
||
const server = createServer(async (req, res) => {
|
||
try {
|
||
const reqPath = decodeURIComponent(req.url.split('?')[0])
|
||
if (virtualPages.has(reqPath)) {
|
||
res.writeHead(200, { 'content-type': 'text/html' })
|
||
return res.end(virtualPages.get(reqPath))
|
||
}
|
||
let p = join(ROOT, reqPath)
|
||
if ((await stat(p)).isDirectory()) p = join(p, 'index.html')
|
||
res.writeHead(200, { 'content-type': TYPES[extname(p)] || 'application/octet-stream' })
|
||
res.end(await readFile(p))
|
||
} catch { res.writeHead(404).end('not found') }
|
||
})
|
||
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
||
const BASE = `http://127.0.0.1:${server.address().port}`
|
||
|
||
// ★ SHOOT WITH THE TOKEN THE BUNDLES ACTUALLY SHIP, not one fetched over the network.
|
||
//
|
||
// This used to fetch https://doxa.life/api/maps/token unconditionally. That token belongs to the
|
||
// Mapbox account `doxalife` and is URL-restricted to doxa.life, which is why the shot harness had
|
||
// to spoof a Referer to make tiles load at all. Meanwhile the BUILT BUNDLES carry a different
|
||
// token — `gosystems`, baked in at build time from vite.doxa-maps-build-config.json — which is not
|
||
// URL-restricted. Measured 2026-09-23 with no Referer at all: mapbox-streets-v8,
|
||
// country-boundaries-v1 and bathymetry-v2 all return 200 on the baked token and 403 on the fetched
|
||
// one. So the harness was doing extra work to use the weaker of the two.
|
||
//
|
||
// Two reasons to prefer the baked token beyond it simply working. A README image is evidence about
|
||
// the SHIPPED artifact, so it should render with the credential the artifact ships with; rendering
|
||
// with a different token means the picture no longer proves what a reader downloads. And reading
|
||
// the build config is local and deterministic — the network path degrades silently, setting TK to
|
||
// '' when doxa.life is slow, after which every shot is taken token-limited and still written to
|
||
// disk. The fetch is kept only as a fallback for a tree whose build config has no baked token.
|
||
let TK = ''
|
||
let TK_SRC = ''
|
||
try {
|
||
const cfg = JSON.parse(await readFile(new URL('../vite.doxa-maps-build-config.json', import.meta.url), 'utf8'))
|
||
if (typeof cfg.token === 'string' && cfg.token.startsWith('pk.')) { TK = cfg.token; TK_SRC = 'build config (the token the bundles ship)' }
|
||
} catch {}
|
||
if (!TK) {
|
||
try {
|
||
const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })
|
||
TK = r.ok ? ((await r.json()).token || '') : ''
|
||
if (TK) TK_SRC = 'doxa.life/api/maps/token (fallback — needs the Referer header below)'
|
||
} catch {}
|
||
}
|
||
console.log(TK ? `token: ${TK.length} chars from ${TK_SRC}` : 'token: NONE — maps token-limited')
|
||
|
||
// ★ SwiftShader — WITHOUT THESE ARGS EVERY MAP SHOT IS TAKEN WITH NO GL CONTEXT.
|
||
// This script was written under the belief "this VPS has no WebGL", which was a LAUNCH-FLAG
|
||
// property, not a machine property. A bare chromium.launch() yields no WebGL, so mapbox-gl
|
||
// never paints and the README documents blank maps. Same flags as internal/verify-map-paint.mjs.
|
||
const browser = await chromium.launch({ args: [
|
||
'--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--no-sandbox',
|
||
] })
|
||
|
||
// ★ A SHOT THAT FAILS MUST NOT SILENTLY PRODUCE A README IMAGE. Collected and asserted at the end.
|
||
const failures = []
|
||
|
||
// mapbox canvas selector to wait on (leaf pages mount a custom element with a canvas)
|
||
const CANVAS = 'canvas'
|
||
|
||
// Readiness floor. Declared BEFORE shot() because it is a default parameter value; leaving it
|
||
// below the function relied on shot() not being CALLED until later, which is true today but is a
|
||
// trap for anyone who adds a call higher up.
|
||
// A login wall renders ~67 elements; a real plugin leaf renders hundreds. Per-shot overrides via
|
||
// `minElements` (cap-1 is legitimately 34) and `countFrames` (iframe hosts) handle the outliers.
|
||
const MIN_ELEMENTS = Number(process.env.MIN_ELEMENTS || 40)
|
||
|
||
// path is relative to the tree root (public/). tk appended unless tokenFree.
|
||
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 {
|
||
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
|
||
if (waitFor) await page.waitForSelector(waitFor, { timeout: 20000 })
|
||
if (before) await before(page)
|
||
await page.waitForTimeout(wait)
|
||
|
||
// ★ READINESS GUARD — a screenshot of a blank page is still a valid PNG, so the file
|
||
// existing proves nothing. Walk shadow roots (a composite's content is NOT in the light
|
||
// DOM; a single-level querySelectorAll lies about every cap-4/5 surface) and require a
|
||
// real element count before anything is written to documentation/.
|
||
const countIn = (frame) => frame.evaluate(() => {
|
||
const deep = (root, out = []) => {
|
||
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) deep(el.shadowRoot, out) }
|
||
return out
|
||
}
|
||
return deep(document).length
|
||
})
|
||
// ★ deep() CANNOT CROSS AN IFRAME. An iframe is a separate document, not a shadow root, so a
|
||
// host page whose whole point is the embedded map counts only its own chrome (~12 elements).
|
||
// For those shots, count the frames too — otherwise the guard rejects a working page.
|
||
let ready = await countIn(page)
|
||
if (countFrames) for (const f of page.frames()) { if (f !== page.mainFrame()) ready += await countIn(f).catch(() => 0) }
|
||
if (ready < minElements) throw new Error(`rendered only ${ready} elements (<${minElements}) — surface did not load`)
|
||
|
||
const opts = { path: join(OUT, name + '.png') }
|
||
if (fullPage) opts.fullPage = true
|
||
if (clip) opts.clip = clip
|
||
await page.screenshot(opts)
|
||
console.log(' OK', name + '.png', `<- ${path} (${ready} elements)`)
|
||
} catch (e) {
|
||
console.log(' ERR', name, e.message.split('\n')[0])
|
||
failures.push(name + ': ' + e.message.split('\n')[0])
|
||
} finally { await ctx.close() }
|
||
}
|
||
|
||
// ---- CAP1: single element (token-free vision card) ----
|
||
// minElements lowered DELIBERATELY: a single vision card is legitimately the smallest surface in
|
||
// the family. MEASURED 2026-09-20: 34 elements, shadow root mounted, 1440x900, 0 console errors --
|
||
// small, not broken. (document.body.innerText is EMPTY here and that is also fine: innerText does
|
||
// not reach into shadow DOM, which is why the element COUNT is the readiness signal, not text.)
|
||
// ★ CORRECTED 2026-09-21 — this slot shot doxa-vision-card, which is NOT a cap-1 single element.
|
||
// It ships doxa-vision-card.instances.json with TWO instances (default, frontier), so discover.mjs
|
||
// types it PARAMETERIZED and manifest.json agrees: type "parameterized" = cap-2. The README
|
||
// captioned it "Capability 1 — a single-element plugin (doxa-vision-card)".
|
||
//
|
||
// ★ THE IDENTICAL ERROR WAS ALREADY FOUND AND FIXED IN verify-5-capabilities.mjs, whose own comment
|
||
// records it: that gate "had been reporting '5/5 capability levels' while sampling cap-2 TWICE and
|
||
// cap-1 NEVER", and it switched to all-people-groups. The README never got the same correction — a
|
||
// fix landed in ONE artifact while the OTHER artifact encoding the same fact kept the bug. That is
|
||
// this repo's recurring shape: two copies of one claim with nothing forcing agreement.
|
||
//
|
||
// all-people-groups is a genuine cap-1: ONE .vue, NO instances.json, manifest type "single".
|
||
// It DOES own a map, so it needs a token and longer to settle than the token-free vision card did.
|
||
await shot('cap1-single-element', '/doxa-maps/all-people-groups/all-people-groups/index.html',
|
||
{ wait: 8000, minElements: 25 })
|
||
|
||
// ---- CAP2: prop-table -> 3 shots from ONE root.vue (prayer/engagement/adoption) ----
|
||
await shot('cap2-prayer', '/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html', { waitFor: CANVAS, wait: 8000 })
|
||
await shot('cap2-engagement', '/doxa-maps/doxa-simple-map/doxa-simple-map/engagement/index.html', { waitFor: CANVAS, wait: 8000 })
|
||
await shot('cap2-adoption', '/doxa-maps/doxa-simple-map/doxa-simple-map/adoption/index.html', { waitFor: CANVAS, wait: 8000 })
|
||
|
||
// ---- CAP3: monolithic research map ----
|
||
await shot('cap3-monolithic-research', '/doxa-maps/doxa-research-map/research-map/index.html', { waitFor: CANVAS, wait: 9000 })
|
||
|
||
// ---- CAP4: nested-element SPA. The bundle ships THREE profiles, one shot each:
|
||
// upg-100-mfe (the cap-4 composite) · upg-100-list (the list) · poster-wall.
|
||
// There are no "list pages" — see the note below the second shot. ----
|
||
await shot('cap4-upg100-mfe', '/doxa-maps/my-upg-100-list/upg-100-mfe/index.html', { wait: 8000 })
|
||
await shot('cap4-upg100-list', '/doxa-maps/my-upg-100-list/upg-100-list/index.html', { wait: 7000 })
|
||
|
||
// ---- CAP4's THIRD profile: the poster wall ----
|
||
// ★ REPLACED 'cap4-upg100-list-page2' 2026-09-21. That shot loaded the SAME URL as page 1 and
|
||
// tried to reach a "page 2" by scrolling to the bottom and clicking a Next control. NEITHER
|
||
// EXISTS: my-upg-100-list has NO pagination (no Next button, no pages), and window.scrollTo is a
|
||
// no-op because the map fills the viewport. Every attempt sat inside try/catch, so the failure
|
||
// was SILENT and the shot "succeeded" — producing a second copy of page 1. The two PNGs differed
|
||
// by 0.16% (render noise), so they were not byte-identical and no duplicate check caught them.
|
||
// The README then displayed the same image twice under "List — page 1" and "List — page 2".
|
||
//
|
||
// ★ THE SLOT WAS CHASING UI THAT DOES NOT EXIST while a REAL profile went unshot: this bundle
|
||
// ships THREE manifest-listed profiles (upg-100-mfe, upg-100-list, poster-wall) and poster-wall
|
||
// had no screenshot anywhere. Shoot the real third thing rather than fake a fourth.
|
||
await shot('cap4-upg100-poster-wall', '/doxa-maps/my-upg-100-list/poster-wall/index.html', { wait: 8000 })
|
||
|
||
// ---- CAP5: nested SPA — the doxa-maps-spa ELEMENT, not the gallery page ----
|
||
// ★ CORRECTED 2026-09-20. This shot previously pointed at /index.html, the AUTO-GENERATED
|
||
// GALLERY PAGE. That is a static preview page, NOT the capability-5 plugin — publishing it as
|
||
// 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.
|
||
// 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
|
||
// distinction correctly in prose — and even warned, one line above, that "Captioning a studio shot
|
||
// as the product SPA is a documented mistake (see /plugin-designer)" — then captioned its
|
||
// plugin-studio slot with cap5-doxa-maps-spa.png, the PRODUCT image. Both cap-5 figures showed the
|
||
// same app, and the studio had no screenshot anywhere in the repo.
|
||
//
|
||
// ★ THE FAILURE MODE IS NOT A TYPO — it is a MISSING ARTIFACT wearing a plausible one. A slot
|
||
// filled with the wrong real image looks exactly like a slot filled correctly; nothing 404s.
|
||
// The fix is to SHOOT THE MISSING THING, not to re-caption around the gap.
|
||
//
|
||
// plugin-studio is a real cap-5 element, not the static gallery page: plugin-studio.meta.json
|
||
// declares {"type":"nested-mfe-spa"}, the source calls defineCustomElement, and the built bundle
|
||
// carries customElements.define. Shot from its own leaf page for the same reason as above.
|
||
// ★ SELECT A ROW BEFORE CAPTURING (fixed 2026-09-22). This shot used to just load and wait, so it
|
||
// captured the studio's EMPTY STATE — a full rail beside a pane reading "Choose an element from the
|
||
// list." The README's flagship capability-5 image demonstrated nothing, and every shot gate passed
|
||
// it: freshness, distinctness and element counts are all satisfied by an empty pane. Found by
|
||
// READING the image, the same way the mobile shot's error card was found the day before.
|
||
//
|
||
// Clicks the first rail row (cap-5 → doxa-maps-spa), so the picture shows what the studio is FOR:
|
||
// 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 —
|
||
// click through an evaluated deep walk instead (the same seen-Set walk the readiness guard uses).
|
||
await page.evaluate(() => {
|
||
const seen = new Set(); let first = null
|
||
;(function w(n) {
|
||
if (!n || seen.has(n) || first) return
|
||
seen.add(n)
|
||
if (n.tagName === 'BUTTON' && /vtl-tab\b/.test((n.className || '').toString())) { first = n; return }
|
||
if (n.shadowRoot) w(n.shadowRoot)
|
||
for (const c of (n.children || [])) w(c)
|
||
})(document.body)
|
||
if (first) first.click()
|
||
})
|
||
await page.waitForTimeout(3000) // let the selected element mount before the main wait
|
||
},
|
||
})
|
||
|
||
// ---- DEPLOY1: iframe embed on a host page ----
|
||
// ★ FIXED 2026-09-20. This shot pointed at /__readme-iframe-host.html, a file the script's own
|
||
// comment said it would write and NEVER DID. The page 404'd, the old error path logged ERR and
|
||
// exited 0, so deploy1-iframe.png has been a screenshot of a 404 page since 2026-09-18.
|
||
// The host page is SERVED FROM MEMORY rather than written into public/: anything written there
|
||
// is agentic scratch in the studio tree and would be destroyed by the next build's clean.
|
||
const IFRAME_SRC = '/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html'
|
||
+ '?tk=' + encodeURIComponent(TK)
|
||
virtualPages.set('/__readme-iframe-host.html', `<!doctype html>
|
||
<html><head><meta charset="utf-8"><title>Embedding a Doxa map</title>
|
||
<style>
|
||
body { margin:0; font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||
background:#f3f3f1; color:#3b463d; }
|
||
.wrap { max-width:900px; margin:0 auto; padding:32px 24px; }
|
||
h1 { font-size:26px; margin:0 0 6px; }
|
||
p { color:#6d766e; margin:0 0 20px; }
|
||
.frame { border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#fff; }
|
||
iframe { display:block; width:100%; height:520px; border:0; }
|
||
code { background:#fff; border:1px solid #d1d4cd; border-radius:5px; padding:2px 6px; font-size:14px; }
|
||
</style></head>
|
||
<body><div class="wrap">
|
||
<h1>Any site can embed a Doxa map</h1>
|
||
<p>One <code><iframe></code> tag — no build step, no framework, no npm install.</p>
|
||
<div class="frame"><iframe src="${IFRAME_SRC}" title="Prayer map embedded in a host page"></iframe></div>
|
||
</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, 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 })
|
||
|
||
// ---- 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 })
|
||
|
||
await browser.close()
|
||
server.close()
|
||
|
||
// ★ EXIT NON-ZERO ON ANY FAILED SHOT. Previously this printed DONE unconditionally, so a run
|
||
// where every map came back blank still reported success and the stale PNGs stayed in documentation/.
|
||
if (failures.length) {
|
||
console.error(`\nREADME-SHOTS: FAIL — ${failures.length} shot(s) did not render:`)
|
||
failures.forEach((f) => console.error(' ' + f))
|
||
console.error(' documentation/readme-images/ may still hold STALE images for these surfaces.')
|
||
process.exit(1)
|
||
}
|
||
console.log('DONE — shots in documentation/readme-images/')
|