doxa-maps/internal/readme-shots.mjs

216 lines
12 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: docs/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, '..', 'docs', '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}`
// Live token so maps paint fully.
let TK = ''
try {
const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })
TK = r.ok ? ((await r.json()).token || '') : ''
} catch {}
console.log(TK ? `token: acquired (${TK.length} chars) — maps will paint` : '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,
} = {}) {
const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: dsf })
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 docs/.
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.)
await shot('cap1-single-element', '/doxa-maps/doxa-vision-card/doxa-vision-card/index.html',
{ tokenFree: true, wait: 3500, 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 (upg-100-mfe is the cat-4 composite; list is the pages) ----
await shot('cap4-upg100-mfe', '/doxa-maps/my-upg-100-list/upg-100-mfe/index.html', { wait: 8000 })
await shot('cap4-upg100-list-page1', '/doxa-maps/my-upg-100-list/upg-100-list/index.html', { wait: 7000 })
// "page 2" — scroll the list down to show a later page of groups
await shot('cap4-upg100-list-page2', '/doxa-maps/my-upg-100-list/upg-100-list/index.html', {
wait: 7000,
before: async (page) => {
try { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) } catch {}
// try clicking a "next" control if one exists
for (const sel of ['button:has-text("Next")', '[aria-label="Next"]', '.pagination-next', 'button.next']) {
try { const el = await page.$(sel); if (el) { await el.click(); break } } catch {}
}
await page.waitForTimeout(1500)
}
})
// ---- 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.
await shot('cap5-doxa-maps-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html', { wait: 10000 })
// ---- 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>&lt;iframe&gt;</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.
await shot('deploy1-iframe', '/__readme-iframe-host.html',
{ vp: { width: 1000, height: 700 }, wait: 11000, countFrames: true })
// ---- DEPLOY2: mobile — the SPA at phone viewport ----
// Same correction as cap5: shoot the cap-5 ELEMENT at phone viewport, not the gallery page.
await shot('deploy2-mobile-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html',
{ vp: { width: 390, height: 844 }, wait: 10000, dsf: 3 })
// ---- DEPLOY3: research dashboard ----
// ★ NOT `waitFor: CANVAS` (2026-09-21). research-dashboard OWNS NO MAP — it is an SVG-charts
// surface (measured: 1083 elements, 10 <svg>, ZERO <canvas>). Waiting for a canvas could only ever
// time out, so this shot failed for 20s on every run and left a STALE image behind. The page was
// never broken; the EXPECTATION was wrong about what this surface is. The minElements readiness
// guard already proves the surface rendered, without asserting a technology it does not use.
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 docs/.
if (failures.length) {
console.error(`\nREADME-SHOTS: FAIL — ${failures.length} shot(s) did not render:`)
failures.forEach((f) => console.error(' ' + f))
console.error(' docs/readme-images/ may still hold STALE images for these surfaces.')
process.exit(1)
}
console.log('DONE — shots in docs/readme-images/')