Some checks are pending
build-gate / build (push) Waiting to run
Deployment method 2 now shows the UPG-100 prayer lists and posters at phone
size: one poster per row, print options wrapping in full. Shot from the
rebuilt bundle at 88a566f, not from source. The capability-4 shots are
retaken from the same build, so they show the print-option fix too.
386 lines
25 KiB
JavaScript
386 lines
25 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, writeFile } from 'node:fs/promises'
|
||
import { createHash } from 'node:crypto'
|
||
import { existsSync, readFileSync } from 'node:fs'
|
||
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 = []
|
||
// documentation/readme-images/shot-bundles.json: shot name -> the sha256 of the bundle it was shot
|
||
// from. Merged, so an ONLY= run updates just the shots it took.
|
||
const SHOT_BUNDLES = resolve(HERE, '..', 'documentation', 'readme-images', 'shot-bundles.json')
|
||
const shotBundles = existsSync(SHOT_BUNDLES) ? JSON.parse(readFileSync(SHOT_BUNDLES, 'utf8')) : {}
|
||
const ONLY = process.env.ONLY ? process.env.ONLY.split(',') : null
|
||
|
||
// 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,
|
||
} = {}) {
|
||
// ONLY=<name>[,<name>] retakes just those shots, leaving every other image untouched.
|
||
if (ONLY && !ONLY.includes(name)) return
|
||
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 = (/^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 })
|
||
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)`)
|
||
// Record WHICH BUNDLE this picture is of, by content hash, so check:shots can tell a current
|
||
// picture from a stale one without trusting any clock (see SHOT_BUNDLES below).
|
||
const lm = path.match(/^\/doxa-maps\/([^/]+)\//)
|
||
const bundleJs = lm && join(ROOT, 'doxa-maps', lm[1], `${lm[1]}.js`)
|
||
if (bundleJs && existsSync(bundleJs)) {
|
||
shotBundles[name] = { bundle: lm[1], sha256: createHash('sha256').update(readFileSync(bundleJs)).digest('hex') }
|
||
}
|
||
} 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).
|
||
// The rail is an Explorer tree (f63d75e): its plugin rows are .st-row[role=treeitem], and a
|
||
// .st-sec row is a section heading, not a plugin. The older .vtl-tab rail is kept as a
|
||
// fallback. Without a tree row, the walk would reach the NESTED gallery's own .vtl-tab buttons
|
||
// instead, and click a tab inside the plugin rather than a plugin in the rail.
|
||
await page.evaluate(() => {
|
||
const find = (test) => {
|
||
const seen = new Set(); let hit = null
|
||
;(function w(n) {
|
||
if (!n || seen.has(n) || hit) return
|
||
seen.add(n)
|
||
if (n.nodeType === 1 && test(n)) { hit = n; return }
|
||
if (n.shadowRoot) w(n.shadowRoot)
|
||
for (const c of (n.children || [])) w(c)
|
||
})(document.body)
|
||
return hit
|
||
}
|
||
const first = find((n) => n.matches('.st-row[role="treeitem"]:not(.st-sec)'))
|
||
|| find((n) => n.tagName === 'BUTTON' && /vtl-tab\b/.test((n.className || '').toString()))
|
||
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 UPG-100 list as the phone app ----
|
||
// The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because
|
||
// it was never built for phones). Shot once its owner marked the phone layout ready at 88a566f:
|
||
// posters one per row, the legend a bottom sheet flush with the map, print options wrapping.
|
||
await shot('deploy2-mobile-upg100', '/doxa-maps/my-upg-100-list/upg-100-mfe/index.html',
|
||
{ vp: { width: 390, height: 844 }, wait: 10000, dsf: 3 })
|
||
|
||
// ---- 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()
|
||
await writeFile(SHOT_BUNDLES, JSON.stringify(Object.fromEntries(Object.entries(shotBundles).sort()), null, 1) + '\n')
|
||
|
||
// ★ 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/')
|