// usePosterPrints.js — THE print actions every poster wall shares. // // ★ WHY THIS EXISTS. The poster wall was generalised into // library/components/poster/PosterWall.vue and two bundles were migrated onto it — // but the ORIGINAL, my-upg-100-list/poster-wall.vue (903 lines), was left behind. // So the one wall carrying the real print actions is the one wall nothing else can // benefit from, and the shared component's packet path is still a "coming soon" // stub. Every print fix made in the 100-list is invisible to the three bundles on // the shared component, and vice versa. That divergence is the bug. // // ── THE SEAM ──────────────────────────────────────────────────────────────── // The PRINT ACTIONS are shareable; the RECORD FETCHING is not. fetchListRecords in // the 100-list knows the people-groups API, the selector evaluator and the pg_slugs // special case; Key Cities and Country Posters have entirely different data shapes. // So this composable takes a `records(item)` PROVIDER and owns everything after it: // the HUD state machine, the in-place print frame, and the three documents. // // That is also what keeps the bundles honest — a wall that cannot supply records // simply does not pass a provider, and the actions that need them stay hidden // rather than failing at click time. // // ⚠ PRINTS IN PLACE, never window.open(). A popup is blockable and drops the reader // out of the map; the shared openPrintFrame mounts a hidden same-page iframe. This // is the mechanism the agent asked for by name: "not supposed to open up a new // browser, but should open up the print dialog directly in the browser". import { ref } from 'vue'; import { openPrintFrame } from './printInPlace.js'; const esc = (s) => String(s ?? '') .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); /** * @param {object} opts * records async (item) => rows[] — REQUIRED for the list and deck prints. * strings () => Promise — the packet/card i18n catalog. * lang () => string — the app locale; the deck and packet follow it. * stock () => string — card stock id (see card-render.js STOCK). * posterUrl (item) => string|null — the pre-rendered image, when one exists. * label (item) => string */ export function usePosterPrints(opts = {}) { // ⚠ ACCEPT the caller's state ref when it has one. A wall that renders its OWN // exportState while these actions write to a private ref shows no progress and // swallows errors — the mechanism works and the feedback does not. Owning a ref // here is only the standalone default. const exportState = opts.state || ref(null); const busy = () => exportState.value !== null; const label = opts.label || ((item) => item?.label || item?.id || ''); const lang = opts.lang || (() => 'en'); const stock = opts.stock || (() => 'poker'); const set = (item, kind, phase, message, pct) => { exportState.value = { label: label(item), kind, phase, message, pct }; }; const done = (item, kind) => { set(item, kind, 'done', 'print dialog opened', 100); setTimeout(() => { exportState.value = null; }, 900); }; const fail = (item, kind, e) => { set(item, kind, 'error', String(e?.message || e).slice(0, 140), 0); }; function dismiss() { exportState.value = null; } async function rows(item) { if (typeof opts.records !== 'function') { throw new Error('this wall supplies no records provider'); } const r = await opts.records(item); if (!r || !r.length) throw new Error('that list resolved to no records'); return r; } // ── 1. THE POSTER ───────────────────────────────────────────────────────── // Prefers the pre-rendered image; falls back to rasterising the poster's own // HTML in the browser, which is what lets the repo stop shipping 41 MB of webp. // ★ THIS IS THE WALL'S posterAction='print' PATH, PROMOTED. It was a private function in // PosterWall.vue, which meant the MAP LEGEND's own "print this list's poster" had no way to // reach it — and the legend had grown a fourth implementation instead: a hand-rolled // `` of the .webp. That was wrong twice over. It silently downloads a file, // which the agent ruled out explicitly (2026-09-23: "no silent file download as the default // action"), and it has NO fallback, so on the 27 lists whose .webp does not ship it simply // saved a 404. // // Two guards below were each learned the hard way and MUST survive any future move: // 1. a THROWN fetch is a MISS, not an error (blocked/offline/refused reject rather than // returning !ok). Letting it escape skips the rasterise fallback entirely — measured // during the wall migration, when check-poster-print-fallback went PASS -> FAIL with 0 // .html requests. // 2. a blank raster must fail LOUDLY. An SVG inside an is an isolated document, so a // missing stylesheet paints every fill black with no error at all. // // `assetsBase` is the seam: the wall and the map profile resolve their own base, and this // stays ignorant of where a bundle serves its posters from. async function printPoster(item) { if (busy()) return; const kind = 'Poster'; const base = opts.assetsBase ? opts.assetsBase() : null; const id = item?.id ?? item; set(item, kind, 'fetch', 'fetching the poster', 25); try { // An explicit posterUrl still wins — it is how a caller points at a poster that is not // /posters/.webp at all. const direct = opts.posterUrl ? opts.posterUrl(item) : null; let res = null; const webp = direct || (base ? `${base}/posters/${id}.webp` : null); if (webp) { try { res = await fetch(webp); } catch { /* a miss, see guard 1 */ } } if (res && res.ok) { await deliverPoster(await res.blob(), item, kind); return; } if (!base) throw new Error('no poster available for this item'); // NO PRE-RENDERED IMAGE? GENERATE IT. The .webp posters are ~41MB across a tree and are // not shipped by default; the HTML is the SOURCE and is smaller than the image it makes. set(item, kind, 'render', 'generating the poster', 45); const htmlRes = await fetch(`${base}/posters/${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 }); if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank'); await deliverPoster(blob, item, kind); } catch (e) { fail(item, kind, e); } } // A 3ft SQUARE on a PORTRAIT sheet must fit by its SHORTER axis: scaling to the full page // WIDTH makes it taller than the printable height and it overflows onto a second page. // Constrain both axes and let object-fit letterbox it. const posterObjectUrls = []; async function deliverPoster(blob, item, kind) { set(item, kind, 'save', 'opening the print dialog', 80); const url = URL.createObjectURL(blob); posterObjectUrls.push(url); const title = `${opts.downloadPrefix || 'doxa'} — ${label(item)}`; openPrintFrame( '' + esc(title) + '' + '' + `${esc(label(item))}`, { autoprint: true }, ); done(item, kind); } // ── 2. THE LIST ─────────────────────────────────────────────────────────── // The agent's FIRST option — "that's what the whole app was about": the list // itself as a print-paginated table with a prayer checkbox per row. async function printList(item, { columns, title } = {}) { if (busy()) return; const kind = 'List'; set(item, kind, 'fetch', 'fetching records', 30); try { const data = await rows(item); const cols = columns || [ { key: 'name', head: 'Name', cls: 'c-name' }, { key: 'country_code_label', head: 'Country' }, { key: 'population', head: 'People', cls: 'c-num', num: true }, { key: 'religion_label', head: 'Religion' }, ]; const head = cols.map((c) => `${esc(c.head)}`).join('') + '✓'; const body = data.map((r) => '' + cols.map((c) => { const raw = r[c.key] ?? r[`${c.key}_label`] ?? '—'; const v = c.num && Number.isFinite(Number(raw)) ? Number(raw).toLocaleString(lang()) : raw; return `${esc(v)}`; }).join('') + '□').join(''); set(item, kind, 'render', `laying out ${data.length} rows`, 70); const docTitle = title || `${label(item)} — ${data.length}`; openPrintFrame( `${esc(docTitle)}` + '' + `

${esc(docTitle)}

` + `

${data.length} entries · pray for them by name

` + `${head}${body}
` + '', { autoprint: true }, ); done(item, kind); } catch (e) { fail(item, kind, e); } } // ── 3. THE CARD DECK ────────────────────────────────────────────────────── // One card per PAGE at the chosen stock, so the print dialog shows CARDS rather // than letter paper. The geometry lives in the shared card renderer. async function printCardDeck(item) { if (busy()) return; const kind = 'Card Deck'; set(item, kind, 'fetch', 'fetching records', 30); try { const data = await rows(item); const [{ createCardRenderer }, strings] = await Promise.all([ import('../packet/card-render.js'), opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m), ]); set(item, kind, 'render', `laying out ${data.length} cards`, 70); const R = createCardRenderer({ strings, lang: lang(), stock: stock() }); openPrintFrame(R.buildDeck(item, data), { autoprint: true }); done(item, kind); } catch (e) { fail(item, kind, e); } } // ── 4. THE PRAYER PACKET ────────────────────────────────────────────────── // The full booklet: map one-pager, people groups 20-per-page with photos, the 12 // prayer points, the prayer wheel. Shares the renderer with the build-time // generator (library/packet/packet-render.js), so a printed packet and a // generated one cannot diverge. // // The MAP PAGE is optional by design: `mapSvg` is supplied by the caller because // only the bundle knows where its posters are served from. If it cannot be // fetched the packet still prints — a cover without the map beats no packet. // `funnel` is the page-2 narrowing data (see packet-render.funnelPage). It is passed THROUGH // untouched: this composable does not know how a host defines its lists, and deriving the counts // here would make a second source of truth for numbers the Vision Card already publishes. async function printPacket(item, { mapSvg = '', funnel = null } = {}) { if (busy()) return; const kind = 'Prayer Packet'; set(item, kind, 'fetch', 'fetching people groups', 30); try { const data = await rows(item); // `christian` is the flock-wide evangelical share behind the packet's %-evangelical page. The // Node generator passes it (packet.mjs reads data/people-groups/christian-share.json); this // path did not, so buildPacket's christianPage returned '' and a browser print had one sheet // fewer with nothing to say so. Same seam as strings: a host may supply its own figure. const [{ createPacketRenderer }, strings, christian] = await Promise.all([ import('../packet/packet-render.js'), opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m), opts.christian ? opts.christian() : import('../../data/people-groups/christian-share.json').then((m) => m.default || m), ]); const R = createPacketRenderer({ strings, lang: lang() }); // Photos stay as URLs. The Node generator base64-inlines them so its file is // self-contained offline; in a browser the print frame fetches them, and // inlining ~95 images client-side would stall the tab for no gain. const photos = new Map( data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]), ); set(item, kind, 'render', `printing ${data.length} people groups`, 70); openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel, christian), { autoprint: true }); done(item, kind); } catch (e) { fail(item, kind, e); } } return { exportState, dismiss, printPoster, printList, printCardDeck, printPacket }; }