The CAP 1 slot shot doxa-vision-card, which is not a single element: it ships doxa-vision-card.instances.json with two instances, so discover.mjs types it parameterized and manifest.json records "parameterized" — capability 2. The same error had already been found and fixed in verify-5-capabilities.mjs, whose own comment records that it had been sampling cap-2 twice and cap-1 never. That fix landed in the gate and never reached the README: two artifacts encoding one fact, with nothing forcing agreement. Fixing one copy was not fixing the fact. all-people-groups is a genuine cap-1 — one .vue, no instances file, manifest type single. The README now also says what decides the tier, since the folder's apparent simplicity is the trap: doxa-vision-card looks like the lightest thing in the repo. check:capability-citations compares every README capability claim against the manifest, blocking in CI. Its first matcher used \Z, which is not JavaScript, so it matched zero sections while still reporting a citation count — a gate reporting a number it had not verified. It now splits on headings and the count reflects real matches.
267 lines
16 KiB
JavaScript
267 lines
16 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.)
|
|
// ★ 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.
|
|
await shot('cap5-doxa-maps-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html', { wait: 10000 })
|
|
|
|
// ---- 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.
|
|
await shot('cap5-plugin-studio', '/doxa-maps/plugin-studio/plugin-studio/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><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.
|
|
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 ----
|
|
// ★ 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 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/')
|