The poster print preview rasterises too, and a deployment can ship sources without rasters
Some checks are pending
build-gate / build (push) Waiting to run

posters/*.webp is not shipped by default (42MB; bundling pushed the tree to 80MB,
git-packed to 57MiB, and the catalog rejected the push). The DOWNLOAD path has
handled that since d154a76 — PosterWall fetches posters/<id>.html and rasterises
it in the browser. The PRINT path did not: it handed an <img> a url it never
checked, so on a default drop the SAME poster downloaded fine and PRINTED as
"Poster not available yet." That is the bad first impression
IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF called out by name.

Three things were missing; all three are here:

1. printPoster now climbs the same ladder as the download path — probe the webp,
   and only on a miss fetch the .html, rasterise, and verify PIXELS before opening
   the dialog (an SVG in an <img> is an isolated document: a lost stylesheet prints
   a black square with no error). Blob urls are held until unmount, not revoked at
   print time — the preview <img> is still reading from them.

2. publish-html.mjs wrote into public/assets/posters, which is a GENERATED MIRROR
   that every `bun run tree` rmSync's. Its output never survived, which is a large
   part of why the fallback still had no input months after it shipped. Retargeted
   at data/posters, the tracked source the mirror is made from.

3. bundlePosters is one boolean over a directory holding 40.9MB of rasters AND the
   28 sources, so it could only ever ship both or neither — and "neither" is what
   made both full-size paths 404. posterSources:true ships the sources without the
   rasters, the combination that was previously inexpressible. DEFAULT UNCHANGED:
   28 sources are 27.9MB raw (6.9MB packed, measured with pack-objects), real
   weight in a published tree that needs a deployment decision, not a silent one.

VERIFIED AT THE RENDERED LAYER, not by reading source. check:poster-print-fallback
clicks the real control with every posters/*.webp ABORTED at the network layer —
the default-drop condition forced rather than hoped for — and asserts the HUD
enters "generating the poster" and settles clean. Mutation-tested: removing the
fallback produces 4 failures including "no posters/*.html was fetched". The other
branch is verified too — with a webp present it is used directly, zero html
fetches, no rasterise — so the extra probe is paid only on a miss.

★ THE GATE WAS WRONG THREE TIMES FIRST, each time blaming the product for a
harness fault, which is why it now asserts the HUD instead of an internal:
  - it matched /print/i, but the control reads "UPG prayer poster" — the product
    names the ARTEFACT, not the action;
  - it patched Document.prototype in the PARENT realm while openPrintFrame writes
    into the iframe's own;
  - openPrintFrame calls doc.open() AFTER append, and open() RESETS the document,
    discarding a write patched at append time.
Through all three the .webp/.html request counters looked corroborating, because
the wall loads both by itself. The HUD is what a user sees, survives a refactor of
the print internals, and cannot silently miss.

The 28 poster sources are committed because the fallback has no input without
them. city-* posters are deliberately NOT covered: they fetch OSM street tiles at
generation time, so they are not reproducible offline and their rasters must stay.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 08:07:22 +00:00
parent f47ee3dfbe
commit 3a468c2bc3
40 changed files with 10314 additions and 90 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/brazil.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/deaf.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/europe.html Normal file

File diff suppressed because one or more lines are too long

355
data/posters/laos.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/sudan.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,193 @@
#!/usr/bin/env node
// check-poster-print-fallback — the PRINT preview must survive a missing .webp.
//
// ★ THE ASYMMETRY THIS GUARDS. posters/*.webp is not shipped by default (42MB;
// bundling it pushed the tree to 80MB, git-packed to 57MiB, and the catalog
// rejected the push outright). The DOWNLOAD path has always handled that —
// PosterWall.vue fetches posters/<id>.html and rasterises it in the browser. The
// PRINT path did not: it handed an <img> a url it never checked, so on a default
// drop the SAME poster downloaded fine and printed as "Poster not available yet."
//
// Two paths, one missing file, different outcomes — and the difference was
// invisible to every existing gate, because check:poster-raster drives the
// RENDERER directly and never goes near the print button.
//
// ⚠ IT INTERCEPTS THE PRINTED HTML rather than reading the frame back.
// openPrintFrame calls window.print(), which BLOCKS in headless Chromium, and the
// frame self-removes on afterprint — reading it back measures a frame that has
// already gone. Same technique as check-deck-click.
//
// ⚠ IT FORCES THE MISS. The dev tree may or may not have the .webp; a gate whose
// result depends on that is measuring the fixture, not the product. So every
// request for posters/<id>.webp is failed at the network layer, which is exactly
// the default-drop condition, and the .html is left reachable.
//
// Usage: node internal/check-poster-print-fallback.mjs <wallUrl> [zoneId]
import { chromium } from 'playwright'
// SKIP, never fail, when the preconditions are absent. This gate needs a SERVED tree
// that carries posters/<id>.html (posterSources:true or bundlePosters:true). The
// default tree ships neither, and a clean checkout has no server at all — failing the
// build on that would only teach people to disable the gate. It says what is missing.
const WALL = process.argv[2] || process.env.POSTER_WALL_URL
if (!WALL) {
console.log('POSTER-PRINT-FALLBACK: SKIP — no wall url given.')
console.log(' Pass one, or set POSTER_WALL_URL. Needs a tree built with posterSources:true')
console.log(' and a server: `node library/scripts/posters/publish-html.mjs && bun run tree`.')
process.exit(0)
}
// The poster SOURCE must be reachable before any of this means anything. Without it
// the fallback has nothing to rasterise and every assertion below would report a
// product bug that is really a missing fixture.
const probeId = process.argv[3] || 'laos'
const srcUrl = new URL(`../assets/posters/${probeId}.html`, WALL).href
try {
const r = await fetch(srcUrl)
if (!r.ok) {
console.log(`POSTER-PRINT-FALLBACK: SKIP — ${srcUrl} is ${r.status}.`)
console.log(' The tree was not built with posterSources:true, so there is no poster source to rasterise.')
process.exit(0)
}
} catch {
console.log(`POSTER-PRINT-FALLBACK: SKIP — cannot reach ${srcUrl} (no server?).`)
process.exit(0)
}
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
// THE DEFAULT-DROP CONDITION, forced: the print-resolution image is absent, its
// HTML source is not. Anything else would leave the gate at the mercy of whether
// this particular checkout happens to carry 42MB of posters.
let webpBlocked = 0
let htmlServed = 0
await page.route('**/posters/*.webp', (route) => { webpBlocked += 1; route.abort() })
await page.route('**/posters/*.html', (route) => { htmlServed += 1; route.continue() })
await page.goto(WALL, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(8000)
// Capture what reaches the print frame instead of letting it print-and-vanish.
// ⚠ ASSERT THE HUD, NOT doc.write. Three separate attempts to intercept the printed
// document all captured nothing while the feature DEMONSTRABLY worked (the HUD ran
// "generating the poster" and finished clean): openPrintFrame writes into the iframe's
// OWN realm, calls doc.open() AFTER append — which resets the document and discards a
// patched write — and the frame self-removes on afterprint. A capture that silently
// misses reports a product bug that is not there.
// The HUD is the better assertion anyway: it is what a USER sees, it survives a
// refactor of the print internals, and it distinguishes the three outcomes that
// matter — rasterised / shipped-image / failed — which is exactly what this gate is for.
await page.evaluate(() => {
window.__HUD = []
window.print = () => {}
const walk = (root, out = []) => {
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
return out
}
window.__readHud = () => walk(document)
.filter((e) => e.childElementCount === 0 && e.tagName !== 'SCRIPT' && e.tagName !== 'STYLE')
.map((e) => (e.textContent || '').trim())
.filter((t) => t && /generating|preparing|saving|not available|failed|blank|error/i.test(t))
setInterval(() => { for (const t of window.__readHud()) if (!window.__HUD.includes(t)) window.__HUD.push(t) }, 120)
})
// Click the REAL print control, found by its accessible name through every shadow root.
const clicked = await page.evaluate(() => {
const walk = (root, out = []) => {
for (const el of root.querySelectorAll('*')) {
out.push(el)
if (el.shadowRoot) walk(el.shadowRoot, out)
}
return out
}
const all = walk(document)
const btn = all.find((el) => {
if (!(el instanceof HTMLElement)) return false
if (el.tagName !== 'BUTTON' && el.getAttribute('role') !== 'button') return false
const label = `${el.getAttribute('aria-label') || ''} ${el.getAttribute('title') || ''} ${el.textContent || ''}`
// ⚠ THE CONTROL IS NOT LABELLED "print". It reads "UPG prayer poster" — the
// product names the ARTEFACT, not the action. A /print/i matcher finds nothing
// and this gate then reports "nothing reached the print frame", which reads as a
// product bug and is purely the selector. Measured 2026-09-24: the .webp and
// .html request counters even LOOKED corroborating, because the wall loads both
// on its own. Match the poster control, and exclude its siblings by name.
return /poster/i.test(label) && !/list|deck|packet|show on map/i.test(label)
})
if (!btn) {
return { ok: false, labels: all.filter((e) => e.tagName === 'BUTTON')
.map((e) => (e.getAttribute('title') || e.textContent || '').trim().slice(0, 30)).filter(Boolean).slice(0, 14) }
}
btn.click()
return { ok: true }
})
let hud = []
let settled = false
if (clicked.ok) {
// Rasterising a 3000px poster takes real time; poll for the terminal state rather
// than guessing one timeout.
for (let i = 0; i < 45 && !settled; i++) {
await page.waitForTimeout(1000)
hud = await page.evaluate(() => window.__HUD || [])
// Terminal states: an explicit failure, or "generating" seen AND no longer showing.
const live = await page.evaluate(() => window.__readHud())
const failing = hud.some((t) => /not available|failed|blank|error/i.test(t))
const generated = hud.some((t) => /generating/i.test(t))
if (failing) { settled = true; break }
if (generated && !live.some((t) => /generating|preparing|saving/i.test(t))) { settled = true }
}
}
await browser.close()
const fails = []
if (!clicked.ok) {
fails.push(`no poster print control found in any shadow root (buttons seen: ${(clicked.labels || []).join(' | ')})`)
}
if (clicked.ok && !hud.length) {
fails.push('the poster control was clicked but the export HUD never reported anything — '
+ 'the click did not reach printPoster at all')
}
// THE LOAD-BEARING ASSERTIONS.
if (hud.some((t) => /Poster not available yet/i.test(t))) {
fails.push('the print preview opened on "Poster not available yet" — the old behaviour: '
+ 'the .webp 404ed and the rasterise fallback did not run')
}
if (hud.some((t) => /not available|failed|blank|error/i.test(t))) {
fails.push(`the print path ended in an error state: ${JSON.stringify(hud.filter((t) => /not available|failed|blank|error/i.test(t)))}`)
}
// With EVERY .webp aborted, a clean run is only possible via the rasteriser — so the
// "generating the poster" phase MUST have been entered. Its absence means the preview
// came from somewhere this gate did not intend, and proves nothing about the fallback.
if (clicked.ok && !hud.some((t) => /generating/i.test(t))) {
fails.push(`the fallback never entered its "generating the poster" phase (HUD: ${JSON.stringify(hud)}) — `
+ 'with every .webp aborted there is no other way to a working preview, so this did not exercise the fallback')
}
if (clicked.ok && !settled) {
fails.push('the print path never reached a terminal state within 45s')
}
if (webpBlocked === 0) {
fails.push('no posters/*.webp request was made at all — the print path never probed the '
+ 'image, so this gate did not exercise the fallback it exists to test')
}
if (htmlServed === 0) {
fails.push('no posters/*.html was fetched — the rasterise fallback never ran')
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log('POSTER-PRINT-FALLBACK')
console.log(` wall : ${WALL}`)
console.log(` .webp requests : ${webpBlocked} (all aborted — the default-drop condition)`)
console.log(` .html requests : ${htmlServed}`)
console.log(` print button : ${clicked.ok ? 'found and clicked' : 'NOT FOUND'}`)
console.log(` HUD : ${hud.length ? JSON.stringify(hud) : '(nothing)'}`)
if (fails.length) {
console.error(`\nPOSTER-PRINT-FALLBACK: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(` - ${f}`)
process.exit(1)
}
console.log('\nPOSTER-PRINT-FALLBACK: PASS — with every .webp missing, the print preview still '
+ 'opens on a real rasterised poster instead of "Poster not available yet".')

View file

@ -96,9 +96,14 @@ function readBuildConfig() {
// from previews/, so the tree stays visually complete without them; bundling both pushed the
// tree to 74 MB and the catalog server refused the pack. Turn on only for a fully offline drop.
const bundlePosters = cfg.bundlePosters === true
return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget, bundlePosters }
// Ship the poster SOURCES (<id>.html, what the browser rasterises) without the
// 40.9 MB of rasters. bundlePosters is a single boolean over a directory holding
// both, so it could not express this — and "neither" is what left export and print
// preview 404ing on a default drop. Implied by bundlePosters:true.
const posterSources = cfg.posterSources === true || bundlePosters
return { name, paths, embedBaseUrl, tokenUrl, token, assetsBaseUrl, apiBaseUrl, gatedProfiles, qaWidget, bundlePosters, posterSources }
} catch {
return { name: 'doxa-maps', paths: [], embedBaseUrl: '', tokenUrl: '', token: '', assetsBaseUrl: '', apiBaseUrl: '', gatedProfiles: [], qaWidget: true, bundlePosters: false }
return { name: 'doxa-maps', paths: [], embedBaseUrl: '', tokenUrl: '', token: '', assetsBaseUrl: '', apiBaseUrl: '', gatedProfiles: [], qaWidget: true, bundlePosters: false, posterSources: false }
}
}
const DEST_CONFIG = readBuildConfig()
@ -1481,17 +1486,47 @@ function main() {
// so the URL is fetched from INSIDE the tree; with images/ absent from this allowlist it
// 404s and the watermark is simply not there, with no error anywhere — the background just
// does not paint, which is even quieter than the font failure was.
const assetDirs = DEST_CONFIG.bundlePosters
// ★ posters/ CARRIES TWO VERY DIFFERENT PAYLOADS and bundlePosters is one boolean,
// so it could only ever ship both or neither:
// <id>.webp 378 files, 40.9 MB — the print-resolution rasters. This is the weight
// that pushed the tree to 80MB and got the push rejected.
// <id>.html 28 files, the poster SOURCE the browser rasterises when no .webp is
// present (library/packet/poster-raster.js). ~0.3 MB each.
// "Neither" is what a default drop got, and it is why BOTH full-size paths 404'd:
// export fell back to fetching <id>.html and that was not shipped either, so the
// fallback had nothing to fetch. posterSources ships the sources WITHOUT the rasters,
// which is the only combination the old boolean could not express.
// Default stays FALSE — 28 sources are ~27.9 MB raw, real weight that needs a
// deployment decision, not a silent default. This makes the choice available.
const posterExts = DEST_CONFIG.bundlePosters ? null // everything
: (DEST_CONFIG.posterSources ? ['.html'] : []) // sources only / nothing
const assetDirs = (posterExts === null || posterExts.length)
? ['previews', 'posters', 'fonts', 'images']
: ['previews', 'fonts', 'images']
for (const dirName of assetDirs) {
const src = join(pubAssets, dirName)
if (!existsSync(src)) { console.warn(`[generate-tree] WARN missing tree asset dir: assets/${dirName}`); continue }
if (dirName === 'posters' && posterExts !== null) {
// Selective copy: only the extensions this deployment asked for.
mkdirSync(join(treeAssets, dirName), { recursive: true })
let n = 0
for (const f of readdirSync(src)) {
if (!posterExts.some((e) => f.endsWith(e))) continue
cpSync(join(src, f), join(treeAssets, dirName, f))
n += 1
}
staged += n
continue
}
cpSync(src, join(treeAssets, dirName), { recursive: true })
staged += readdirSync(src).length
}
if (!DEST_CONFIG.bundlePosters) {
console.log('[generate-tree] posters/ NOT bundled (bundlePosters:false) — the wall renders from previews; set an absolute assetsBaseUrl to serve full posters on demand')
if (DEST_CONFIG.bundlePosters) {
console.log('[generate-tree] posters/ bundled in full (bundlePosters:true) — rasters + sources')
} else if (DEST_CONFIG.posterSources) {
console.log('[generate-tree] posters/*.html bundled (posterSources:true) — no rasters; export and print RASTERISE in the browser')
} else {
console.log('[generate-tree] posters/ NOT bundled — the wall renders from previews; export and print need posterSources:true, bundlePosters:true, or an absolute assetsBaseUrl')
}
console.log(`[generate-tree] staged ${staged} runtime asset(s) → ${TREE_NAME}/assets (tree is self-contained)`)
}

View file

@ -48,7 +48,13 @@ import { dirname, join, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = join(__dirname, 'out', 'posters');
const REPO = resolve(__dirname, '..', '..', '..');
const DEST = join(REPO, 'public', 'assets', 'posters');
// ⚠ data/posters, NOT public/assets/posters. public/assets/<name> is a GENERATED
// MIRROR: generate-tree.mjs rmSync's it and re-copies data/<name> over it on every
// `bun run tree`, so anything written there is deleted by the next build. This
// script wrote there and its output therefore never survived — which is a large
// part of why the browser-rasterise fallback still had no input months after it
// shipped. data/ is the tracked source the mirror is made from.
const DEST = join(REPO, 'data', 'posters');
const ONLY = process.env.ONLY || '';
if (!existsSync(SRC)) {
@ -88,7 +94,7 @@ for (const file of readdirSync(SRC)) {
bytes += Buffer.byteLength(html);
}
console.log(`[publish-html] ${published} poster HTML -> public/assets/posters/`);
console.log(`[publish-html] ${published} poster HTML -> data/posters/`);
console.log(`[publish-html] ${(bytes / 1048576).toFixed(1)} MB raw `
+ `(gzips to roughly a third over the wire)`);
if (skipped.length) {

View file

@ -145,7 +145,8 @@
"build:bundles": "node internal/run-parallel.mjs --bundles",
"check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs",
"check:tabs-localized": "node internal/check-tabs-localized.mjs # a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
"check:packets-all": "node internal/check-packets-all.mjs # runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated."
"check:packets-all": "node internal/check-packets-all.mjs # runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs # clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",

View file

@ -30,9 +30,15 @@
* (~42MB). See IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF:
* bundling them pushed the tree to 80MB, git-packed to 57MiB, and the
* catalog REJECTED the push — so the all-in version shipped nothing.
* Export and print-preview therefore 404 on a default drop and degrade
* honestly; set bundlePosters:true or point assetsBase at a host that
* serves posters/ to restore them.
* Export and print-preview no longer 404 on a default drop: BOTH now
* fall back to fetching posters/<id>.html and rasterising it in the
* browser (library/packet/poster-raster.js). Print was the odd one out
* until 2026-09-24 — the same poster downloaded fine and PRINTED as
* "Poster not available yet." bundlePosters:true or an assetsBase that
* serves posters/ still short-circuits the fallback with the shipped webp.
* posters/<id>.html — the poster SOURCE, what the fallback rasterises. Also not shipped by
* default (28 zones, 27.9MB raw), so on a drop with neither file the
* preview still refuses to open rather than printing a blank sheet.
* The @error fallback to a labelled placeholder is still live and still the right behaviour —
* it now covers the missing-poster case rather than the nothing-generated-yet case.
*/
@ -267,6 +273,16 @@ async function renderFor(list, spec) {
const EXPORT_SPEC = { widthIn: 8.5, heightIn: 12.75, dpi: 150 } // single-canvas, phone-safe
const exportState = ref(null) // { label, phase, message, pct, kind }
// Blob URLs minted for a rasterised print preview. They CANNOT be revoked when the
// print window opens — the preview <img> is still loading from them, and revoking
// early gives a blank sheet — so they are held until this element goes away.
// One poster is ~1.5MB as a blob; a reader printing a dozen in one session would
// otherwise pin ~18MB for the life of the page.
const printObjectUrls = []
onBeforeUnmount(() => {
for (const u of printObjectUrls.splice(0)) { try { URL.revokeObjectURL(u) } catch { /* already gone */ } }
})
// CARD STOCK for the prayer-card deck. The Driver asked to be able to "choose the
// layout of the print dialog to choose the aspect ratio of these prayer cards", so
// the stock is a user choice, not a constant. Poker (2.5x3.5in) is the default: it
@ -369,7 +385,40 @@ async function printPoster(list) {
exportState.value = { label: list.label, kind: 'Poster', phase: 'fetch',
message: 'preparing print preview', pct: 30 }
try {
const url = posterUrl(list.id)
let url = posterUrl(list.id)
// ── THE SAME FALLBACK LADDER THE DOWNLOAD PATH ALREADY CLIMBS ───────────
// ★ THE ASYMMETRY THIS FIXES. posters/*.webp is NOT shipped by default (42MB;
// bundling it pushed the tree to 80MB, git-packed to 57MiB, and the catalog
// rejected the push). The DOWNLOAD path handles that: PosterWall.vue fetches
// posters/<id>.html and rasterises it in the browser. The PRINT path did not —
// it handed the <img> a url it never checked and, on 404, replaced the whole
// preview with "Poster not available yet."
// So on a default drop the same poster DOWNLOADED fine and PRINTED as an error
// message. The card that recorded this (IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-
// TRADEOFF) calls it out precisely: "a print preview that opens on 'Poster not
// available yet' is a worse first impression than a failed download."
//
// Probing costs one HEAD-like GET that is almost always a cache hit when the
// image IS shipped, and only on a miss do we pay for the HTML.
let res = null
try { res = await fetch(url) } catch { /* offline/blocked — treated as a miss */ }
if (!res || !res.ok) {
exportState.value = { ...exportState.value, phase: 'render',
message: 'generating the poster', pct: 55 }
const htmlRes = await fetch(`${assetsBase.value}/posters/${list.id}.html`)
// Still honest when there is genuinely nothing to render: the preview only
// opens once we HAVE a poster, so the reader never sees a blank print dialog.
if (!htmlRes.ok) throw new Error(`poster not available (${htmlRes.status})`)
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 })
// ⚠ An SVG loaded through <img> is an isolated document: a lost stylesheet makes
// every fill fall back to BLACK, with no error and a normal file size. Printing
// a black square is worse than failing, so verify pixels before opening the
// dialog — the same guard the download path uses.
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank')
url = URL.createObjectURL(blob)
printObjectUrls.push(url)
}
// Preview + print in a dedicated window: a single centered, page-fit image and an
// onload -> print() so the dialog opens on the poster the user is looking at. The
// poster is a 3ft square print (see the template) → @page portrait, image scaled to

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -658,7 +658,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-DTQvsXU3C5sstv8K7pnmiFWMP23nSlwYzIHbwpVoqQzQc+7tGx9IQvPXbMqAPA5p",
"integrity": "sha384-eIh/eQ3SOnMBPQh5z4wAfhFK1MScit5ksl9pVBqHe3wPEuT48xlPwZz1OGwPSICH",
"elements": [
"my-upg-100-list-map"
],
@ -678,7 +678,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-DTQvsXU3C5sstv8K7pnmiFWMP23nSlwYzIHbwpVoqQzQc+7tGx9IQvPXbMqAPA5p\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-eIh/eQ3SOnMBPQh5z4wAfhFK1MScit5ksl9pVBqHe3wPEuT48xlPwZz1OGwPSICH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -692,7 +692,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-DTQvsXU3C5sstv8K7pnmiFWMP23nSlwYzIHbwpVoqQzQc+7tGx9IQvPXbMqAPA5p\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-eIh/eQ3SOnMBPQh5z4wAfhFK1MScit5ksl9pVBqHe3wPEuT48xlPwZz1OGwPSICH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -706,7 +706,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-DTQvsXU3C5sstv8K7pnmiFWMP23nSlwYzIHbwpVoqQzQc+7tGx9IQvPXbMqAPA5p\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-eIh/eQ3SOnMBPQh5z4wAfhFK1MScit5ksl9pVBqHe3wPEuT48xlPwZz1OGwPSICH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]

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 <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-DTQvsXU3C5sstv8K7pnmiFWMP23nSlwYzIHbwpVoqQzQc+7tGx9IQvPXbMqAPA5p" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-eIh/eQ3SOnMBPQh5z4wAfhFK1MScit5ksl9pVBqHe3wPEuT48xlPwZz1OGwPSICH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-DTQvsXU3C5sstv8K7pnmiFWMP23nSlwYzIHbwpVoqQzQc+7tGx9IQvPXbMqAPA5p" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-eIh/eQ3SOnMBPQh5z4wAfhFK1MScit5ksl9pVBqHe3wPEuT48xlPwZz1OGwPSICH" 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 <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-DTQvsXU3C5sstv8K7pnmiFWMP23nSlwYzIHbwpVoqQzQc+7tGx9IQvPXbMqAPA5p" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-eIh/eQ3SOnMBPQh5z4wAfhFK1MScit5ksl9pVBqHe3wPEuT48xlPwZz1OGwPSICH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: