Merge main into bundler-2/work

# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/my-upg-100-list/my-upg-100-list.js
#	public/doxa-maps/my-upg-100-list/poster-wall/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-list/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-mfe/index.html
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-24 03:17:16 +00:00
commit 874186660e
12 changed files with 229 additions and 13 deletions

View file

@ -0,0 +1,86 @@
// check-poster-raster — a poster with NO pre-rendered image must still download.
//
// ★ THE POINT: 378 posters ship as .webp — 41 MB in every clone, delivered to every
// reader whether or not they open one, and a publish was ALREADY rejected once at a
// 74 MB asset tree. Driver: "it would be a big win to not have to have 80mb of
// images in a public folder and let the posters get generated by the website."
//
// The fallback rasterises posters/<id>.html in the browser. This gate proves the
// generated image is REAL, because the failure mode here is silent and total:
//
// ⚠ AN SVG LOADED THROUGH <img> IS AN ISOLATED DOCUMENT. It cannot see the host
// page's CSS, so every var() falls back to its initial value — and `fill`'s initial
// value is BLACK. A poster that lost its stylesheet downloads as a black rectangle
// with no error, no exception and a perfectly normal file size. So this gate
// MEASURES PIXELS: mean luminance and colour variety, not "did it produce bytes".
//
// Usage: node internal/check-poster-raster.mjs [zoneId]
// PACKET_ORIGIN (default http://127.0.0.1:4898)
import { chromium } from 'playwright'
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
const ZONE = process.argv[2] || 'laos'
const HTML = `${ORIGIN}/library/scripts/posters/out/posters/${ZONE}.html`
const b = await chromium.launch()
const pg = await b.newPage({ viewport: { width: 1200, height: 900 } })
const errs = []; pg.on('pageerror', e => errs.push(String(e).slice(0, 140)))
await pg.goto(ORIGIN + '/')
const r = await pg.evaluate(async ({ ORIGIN, HTML }) => {
const { rasterPoster, looksRendered, inlineSvg } =
await import(`${ORIGIN}/library/packet/poster-raster.js`)
const res = await fetch(HTML)
if (!res.ok) return { error: `poster HTML ${res.status}` }
const html = await res.text()
// The stylesheet must actually travel with the svg.
const svg = inlineSvg(html)
const carriesCss = /<style>/.test(svg)
const blob = await rasterPoster(html, { scale: 2 })
const ok = await looksRendered(blob)
// Independent pixel read: sample a grid and count distinct coarse colours. A
// black rectangle has ~1; a real poster has many.
const bmp = await createImageBitmap(blob)
const c = document.createElement('canvas')
c.width = 200; c.height = 260
const x = c.getContext('2d')
x.drawImage(bmp, 0, 0, 200, 260)
const d = x.getImageData(0, 0, 200, 260).data
const seen = new Set()
let sum = 0
for (let i = 0; i < d.length; i += 4) {
sum += (d[i] + d[i + 1] + d[i + 2]) / 3
seen.add(`${d[i] >> 5},${d[i + 1] >> 5},${d[i + 2] >> 5}`)
}
return { bytes: blob.size, w: bmp.width, h: bmp.height, ok, carriesCss,
meanLuma: Math.round(sum / (d.length / 4)), colours: seen.size }
}, { ORIGIN, HTML })
await b.close()
if (r.error) { console.error(`POSTER-RASTER: FAIL — ${r.error}`); process.exit(1) }
const fails = []
if (!r.carriesCss) fails.push('the inlined SVG carries no <style> — every fill will render BLACK')
if (!r.bytes) fails.push('no image produced')
if (r.w < 1000 || r.h < 1000) fails.push(`rasterised at ${r.w}x${r.h} — too small to print`)
if (r.meanLuma < 60) fails.push(`mean luminance ${r.meanLuma} — the poster rendered dark/black`)
if (r.colours < 12) fails.push(`only ${r.colours} distinct colours — this is not a painted poster`)
if (!r.ok) fails.push('looksRendered() rejected the output')
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log(` zone : ${ZONE}`)
console.log(` rasterised : ${r.w} x ${r.h}`)
console.log(` image bytes : ${(r.bytes / 1024).toFixed(0)} KB`)
console.log(` stylesheet inline: ${r.carriesCss}`)
console.log(` mean luminance : ${r.meanLuma} (near 0 = black = CSS lost)`)
console.log(` distinct colours: ${r.colours}`)
if (fails.length) {
console.error(`\nPOSTER-RASTER: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nPOSTER-RASTER: PASS — ${ZONE} generates a real ${r.w}x${r.h} poster with no pre-rendered image.`)

View file

@ -161,8 +161,31 @@ async function exportPoster(item) {
exportState.value = { label: item.label, kind: 'Poster', phase: 'fetch',
message: 'fetching poster', pct: 20 }
try {
const res = await fetch(`${base.value}/posters/${item.id}.webp`)
if (!res.ok) throw new Error(`poster not found (${res.status})`)
let res = await fetch(`${base.value}/posters/${item.id}.webp`)
if (!res.ok) {
// ★ NO PRE-RENDERED IMAGE? GENERATE IT. The 378 .webp posters were 41 MB in
// every clone, shipped to every reader whether or not they opened one — and a
// publish was already rejected once at a 74 MB asset tree. The poster HTML is
// SMALLER than the image it produces (~115 KB gzipped vs ~213 KB) and it is
// the source, so the reader picks the resolution instead of being handed one.
// This is the same move the prayer packet already makes: generate on click,
// ship nothing. A 404 used to end here as "poster not found".
exportState.value = { ...exportState.value, phase: 'render',
message: 'generating the poster', pct: 45 }
const htmlRes = await fetch(`${base.value}/posters/${item.id}.html`)
if (!htmlRes.ok) throw new Error(`poster not found (${htmlRes.status})`)
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 })
// Fail LOUDLY rather than hand over a black rectangle: an SVG in an <img> is
// an isolated document, so a missing stylesheet makes every fill fall back to
// black with no error at all.
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank')
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`)
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
return
}
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
download(await res.blob(), `${props.downloadPrefix}-${item.id}-poster.webp`)
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }

View file

@ -0,0 +1,86 @@
// poster-raster.js — turn a poster's HTML/SVG into a downloadable image, in the
// browser, on click.
//
// ★ WHY: 378 posters were committed as .webp — 41 MB in every clone, shipped to
// every reader whether or not they open one. Driver: "it would be a big win to not
// have to have 80mb of images in a public folder and let the posters get generated
// by the website." A publish was ALREADY rejected once at a 74 MB asset tree
// (git-packed to 57 MiB), which is what not doing this costs.
//
// THE MOVE IS NOT A REWRITE. generate.mjs is 2,816 lines with 26 Node touchpoints;
// porting it to the browser would be a large, risky job. But the poster's shipped
// artefact does not have to be a RASTER — the browser can rasterise the SVG itself:
//
// posters/<id>.html ~115 KB gzipped (text, compresses well)
// posters/<id>.webp ~213 KB (already rasterised, one fixed size)
//
// So the HTML is SMALLER than the image it produces, and it is the source: the
// reader picks the resolution at download time instead of being handed one. The
// packet already fetches this same HTML to draw its map page, so the file is
// already a shipped, proven artefact.
//
// ⚠ AN SVG LOADED THROUGH <img> IS AN ISOLATED DOCUMENT. It cannot see the host
// page's CSS, so every var() falls back to its initial value — and `fill`'s initial
// value is BLACK. That is exactly how the packet's map first shipped as a solid
// black rectangle with no error. The stylesheet must be INLINED into the SVG, and
// the result must be checked by MEASURING the pixels, not by looking at the code.
/**
* Lift the <svg> out of a poster document with its stylesheet inlined, so it
* renders standalone.
*/
export function inlineSvg(posterHtml) {
const svg = (posterHtml.match(/<svg[\s\S]*?<\/svg>/) || [])[0];
if (!svg) throw new Error('poster HTML contains no <svg>');
const css = (posterHtml.match(/<style>([\s\S]*?)<\/style>/) || [])[1] || '';
// Strip any nested </style> so the injected block cannot terminate early.
const safe = css.replace(/<\/style>/gi, '');
return svg.replace(/<svg([^>]*)>/, `<svg$1><style>${safe}</style>`);
}
/**
* Rasterise a poster to a Blob in the browser.
* @param {string} posterHtml the poster document (posters/<id>.html)
* @param {object} opts scale (default 2), type ('image/webp'|'image/png'), quality
*/
export async function rasterPoster(posterHtml, { scale = 2, type = 'image/webp', quality = 0.85 } = {}) {
const svg = inlineSvg(posterHtml);
// encodeURIComponent, not btoa: the posters carry non-Latin-1 characters (people
// group and place names), and btoa throws on those.
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const img = new Image();
img.src = url;
await img.decode();
const w = Math.round(img.naturalWidth * scale);
const h = Math.round(img.naturalHeight * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
// White under the art: a transparent canvas prints as black on some viewers, and
// the poster's own paper colour only covers its drawn area.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
const blob = await new Promise((res) => canvas.toBlob(res, type, quality));
if (!blob) throw new Error('canvas.toBlob returned nothing');
return blob;
}
/**
* Is this canvas mostly black? A cheap guard against the isolated-document trap
* above, so a broken render fails LOUDLY instead of downloading a black rectangle.
*/
export async function looksRendered(blob) {
const bmp = await createImageBitmap(blob);
const c = document.createElement('canvas');
c.width = 40; c.height = 40;
const x = c.getContext('2d');
x.drawImage(bmp, bmp.width * 0.4, bmp.height * 0.45, 40, 40, 0, 0, 40, 40);
const d = x.getImageData(0, 0, 40, 40).data;
let sum = 0;
for (let i = 0; i < d.length; i += 4) sum += (d[i] + d[i + 1] + d[i + 2]) / 3;
return sum / (d.length / 4) > 40;
}

View file

@ -56,6 +56,7 @@
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"check:card-deck": "node internal/check-card-deck.mjs",
"check:poster-raster": "node internal/check-poster-raster.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-render": "node internal/check-packet-render.mjs",
"check:template-literal": "node internal/template-literal-check.mjs",

View file

@ -653,7 +653,17 @@ useShadowStyles(
// .container to the DETAIL routes too -- RegionDetail.vue:46 and CountryDetail.vue:35 are both
// class="container page-content ..." -- so the dashboard half is capped by the same rule.
// gap = their .stack--3xl (55.349px), the gap between every block on that page.
'.rd-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;' +
// ★ scrollbar-gutter: stable — ported from their base/_globals.scss html rule, and it is
// LOAD-BEARING for layout, not cosmetic. MEASURED at 390px: their container is 353.5px
// and ours was 367px, because their html reserves the scrollbar gutter always and our
// scroller did not. 13.5px, invisible at 1440 where both cap at 1200 — which is exactly
// why a desktop-only diff read 68/68 while every mobile width was wrong.
// Worse, it CASCADED: at 768 our container came out 707.2px and theirs 693.7px, and
// their .switcher flips at 700px of container — so their three goal cards stacked and
// ours stayed three-across. One missing property, a different layout.
// We cannot set this on the host document from inside a shadow root, and should not;
// this element's own scroller is the box every child measures against.
'.rd-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; scrollbar-gutter:stable; -webkit-overflow-scrolling:touch;' +
' padding:55.349px 0 73.6142px; display:flex; flex-direction:column; gap:55.349px;' +
' container-type:inline-size; }' +
'.rd-scroll > *{ box-sizing:border-box; width:calc(90% + 16px); max-width:1200px;' +

View file

@ -277,7 +277,17 @@ useShadowStyles(
// in research-dashboard.vue:284. Safe here because nothing in this element is position:fixed --
// inline-size containment is what made a fixed scrim pane-relative in check:scrim-viewport.
// Side padding is deliberately 0: the .container formula below supplies the gutter, as theirs does.
'.rl-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;' +
// ★ scrollbar-gutter: stable — ported from their base/_globals.scss html rule, and it is
// LOAD-BEARING for layout, not cosmetic. MEASURED at 390px: their container is 353.5px
// and ours was 367px, because their html reserves the scrollbar gutter always and our
// scroller did not. 13.5px, invisible at 1440 where both cap at 1200 — which is exactly
// why a desktop-only diff read 68/68 while every mobile width was wrong.
// Worse, it CASCADED: at 768 our container came out 707.2px and theirs 693.7px, and
// their .switcher flips at 700px of container — so their three goal cards stacked and
// ours stayed three-across. One missing property, a different layout.
// We cannot set this on the host document from inside a shadow root, and should not;
// this element's own scroller is the box every child measures against.
'.rl-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; scrollbar-gutter:stable; -webkit-overflow-scrolling:touch;' +
' padding:20px 0; display:flex; flex-direction:column; gap:23.5264px;' +
' container-type:inline-size; }' +
// ★ THE DESKTOP MAX-WIDTH, ported verbatim from DX-life's .container

File diff suppressed because one or more lines are too long

View file

@ -838,7 +838,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-+z7qRIJAEJa+7I4OzeL88//qZ/9lXLuYHQWDFI0Y5AUtnOUqXCSx8YE6Y8Nx0myd",
"integrity": "sha384-pUQBtbB29Jo9rUXZKWEPKnp+qU8zk70BS9rvrfxlb1J7cOJM4llb/jsqN9qZ07V7",
"elements": [
"regions-dashboard"
],
@ -858,7 +858,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-+z7qRIJAEJa+7I4OzeL88//qZ/9lXLuYHQWDFI0Y5AUtnOUqXCSx8YE6Y8Nx0myd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-pUQBtbB29Jo9rUXZKWEPKnp+qU8zk70BS9rvrfxlb1J7cOJM4llb/jsqN9qZ07V7\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -872,7 +872,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-+z7qRIJAEJa+7I4OzeL88//qZ/9lXLuYHQWDFI0Y5AUtnOUqXCSx8YE6Y8Nx0myd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-pUQBtbB29Jo9rUXZKWEPKnp+qU8zk70BS9rvrfxlb1J7cOJM4llb/jsqN9qZ07V7\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -886,7 +886,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-+z7qRIJAEJa+7I4OzeL88//qZ/9lXLuYHQWDFI0Y5AUtnOUqXCSx8YE6Y8Nx0myd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-pUQBtbB29Jo9rUXZKWEPKnp+qU8zk70BS9rvrfxlb1J7cOJM4llb/jsqN9qZ07V7\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-+z7qRIJAEJa+7I4OzeL88//qZ/9lXLuYHQWDFI0Y5AUtnOUqXCSx8YE6Y8Nx0myd" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-pUQBtbB29Jo9rUXZKWEPKnp+qU8zk70BS9rvrfxlb1J7cOJM4llb/jsqN9qZ07V7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-+z7qRIJAEJa+7I4OzeL88//qZ/9lXLuYHQWDFI0Y5AUtnOUqXCSx8YE6Y8Nx0myd" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-pUQBtbB29Jo9rUXZKWEPKnp+qU8zk70BS9rvrfxlb1J7cOJM4llb/jsqN9qZ07V7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-+z7qRIJAEJa+7I4OzeL88//qZ/9lXLuYHQWDFI0Y5AUtnOUqXCSx8YE6Y8Nx0myd" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-pUQBtbB29Jo9rUXZKWEPKnp+qU8zk70BS9rvrfxlb1J7cOJM4llb/jsqN9qZ07V7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: