PosterWall: a previewSrc seam so a host can supply each card's thumbnail

A card's thumbnail was always the stored previews/<id>.webp. A gallery of
poster templates drawn from the library has no stored file, and must show a
library change the moment it lands, so the thumbnail can now come from the
host: previewSrc: async (item) => an image URL | null.

Default null keeps the stored webp and creates no observer, so every current
wall is unchanged. When set, a card renders only once it is in view, one
render at a time, cached on the page by instanceId + item id. Until it answers
the card shows its label; null, a throw or no answer within 20 s shows the
existing "preview unavailable" placeholder.

The header-action gate becomes check:poster-wall-host-seams and covers both
seams: arms D to G for previewSrc (default webp and no observer; lazy, one at
a time and the null fallback; the timeout; the cache). Six mutants of the new
code each fail it.
This commit is contained in:
Doxa Maps 2026-09-30 21:21:20 +00:00
parent b3d6db3c59
commit 7cc28b7aee
3 changed files with 179 additions and 9 deletions

View file

@ -1,14 +1,21 @@
#!/usr/bin/env node
/**
* check-poster-wall-header-action.mjs: PosterWall's header sticker does what its headerAction prop says.
* check-poster-wall-host-seams.mjs: PosterWall's two host seams, headerAction and previewSrc, do what they
* say, and with neither set every wall behaves as before.
*
* WHY. PosterWall's card header was hard-wired to "Show on map". A wall with no map beside it (the
* WHY (headerAction). PosterWall's card header was hard-wired to "Show on map". A wall with no map beside it (the
* Poster Design gallery) needs the same card with a different action, so the header became a seam:
* headerAction null (the default) keeps the "Show on map" TOGGLE; { labelKey, ariaKey } makes it a
* plain button that emits 'header-action'. Every existing wall passes nothing, so the default path is
* the one that must not move, and the new path must not leak the old one's side effects (a window
* event a map would act on, aria-pressed, the is-last highlight).
*
* WHY (previewSrc). The card's thumbnail was always the stored previews/<id>.webp. A gallery of templates
* drawn from the library has no stored file and must show a library change, so the thumbnail can come
* from the host. A gallery renders many cards, so the seam is LAZY (only cards in view), ONE render at a
* time, cached per page, and bounded: no answer, a throw or a timeout falls back to the wall's
* "preview unavailable" placeholder, never a blank or an endless wait.
*
* HOW. No browser (fleet policy). PosterWall has no <style> block, so it is compiled here as a CLIENT
* component (compiler-sfc, inline template) and loaded through Vite with the repo's @map alias. It is
* mounted on a recording renderer: every element keeps its tag, props and children, so the gate reads
@ -19,6 +26,14 @@
* B (headerAction) one .tape-h, NO aria-pressed, label = "7 UPG ·" + t(labelKey), aria-label = t(ariaKey);
* a click emits 'header-action' with the item, dispatches NO window event, sets no is-last.
* C (headerAction, an item with no count) the sticker reads t(labelKey) alone: no "UPG", no "·".
* D (no previewSrc) the card is an <img class="img"> on previews/<id>.webp; no pending placeholder, and the
* wall creates no IntersectionObserver.
* E (previewSrc) 3 cards, all pending, 0 renders before any is in view (lazy). Two come into view: exactly
* one render runs; when it answers a URL that card becomes an <img> with that src and the
* second render starts; an answer of null gives the "preview unavailable" placeholder. The
* card never in view is never rendered.
* F (previewSrc that never answers) after the timeout the card shows the placeholder.
* G (cache) a remount with the same instanceId shows the cached URL with no second render.
* A vacuous run (no .tape-h found, or no items rendered) FAILS.
*/
import { readFileSync } from 'node:fs'
@ -33,6 +48,7 @@ const fail = (m) => failures.push(m)
// ── the thinnest page PosterWall's setup touches ───────────────────────────────────────────────
const dispatched = []
const observers = []
{
const noop = () => {}
const el = () => ({ style: {}, dataset: {}, setAttribute: noop, getAttribute: () => null, removeAttribute: noop,
@ -52,7 +68,9 @@ const dispatched = []
dispatchEvent: (e) => { dispatched.push(e); return true },
requestAnimationFrame: (f) => setTimeout(f, 0), cancelAnimationFrame: clearTimeout,
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
IntersectionObserver: class { observe() {} unobserve() {} disconnect() {} },
// A RECORDING observer, so the gate can bring a card into view on demand.
IntersectionObserver: class { constructor(cb) { this.cb = cb; this.els = new Set(); observers.push(this) }
observe(e) { this.els.add(e) } unobserve(e) { this.els.delete(e) } disconnect() { this.els.clear() } },
MutationObserver: class { observe() {} disconnect() {} },
HTMLElement: class {}, ShadowRoot: globalThis.ShadowRoot || class ShadowRoot {},
CustomEvent: class { constructor(type, o) { this.type = type; this.detail = o?.detail } },
@ -196,6 +214,75 @@ try {
else if (/UPG|·|undefined/.test(label) || !/full\s*details/i.test(label)) fail(`C: a countless item should read the label alone, got ${JSON.stringify(label)}`)
w.app.unmount()
}
// ── the previewSrc seam ───────────────────────────────────────────────────────────────────────
const settle = async () => { for (let i = 0; i < 6; i++) { await new Promise((r) => setTimeout(r, 0)); await rc.nextTick() } }
const imgs = (root) => { const out = []; walk(root, (n) => { if (n.tag === 'img' && hasClass(n, 'img')) out.push(n) }); return out }
const pending = (root) => { const out = []; walk(root, (n) => { if (n.tag === 'div' && hasClass(n, 'ph') && hasClass(n, 'is-pending')) out.push(n) }); return out }
const phFor = (root, label) => { let hit = null; walk(root, (n) => { if (!hit && n.tag === 'div' && hasClass(n, 'ph') && textOf(n).includes(label)) hit = n }); return hit }
const inView = (el) => { for (const io of observers) if (io.els.has(el)) io.cb([{ target: el, isIntersecting: true }]) }
// D: no previewSrc, the stored file exactly as before
{
const before = observers.length
const w = await mountWall({})
const im = imgs(w.root)
if (im.length !== 1 || !String(im[0].props.src).endsWith('/previews/gate-item.webp')) fail(`D: expected one img on previews/gate-item.webp, got ${JSON.stringify(im.map((x) => x.props.src))}`)
if (pending(w.root).length) fail('D: a wall without previewSrc must show no pending placeholder')
if (observers.length !== before) fail('D: a wall without previewSrc must create no IntersectionObserver')
w.app.unmount()
}
// E + G: lazy, one at a time, fallback on null, cached
const three = [{ id: 'p1', label: 'Pone', count: 3 }, { id: 'p2', label: 'Ptwo', count: 2 }, { id: 'p3', label: 'Pthree', count: 1 }]
const calls = []; let inFlight = 0; let maxInFlight = 0; const answer = {}
const previewSrc = (it) => { calls.push(it.id); inFlight++; maxInFlight = Math.max(maxInFlight, inFlight)
return new Promise((res) => { answer[it.id] = (v) => { inFlight--; res(v) } }) }
{
const w = await mountWall({ previewSrc, instanceId: 'gate-e' }, three)
await settle()
if (pending(w.root).length !== 3) fail(`E: expected 3 pending cards before any is in view, got ${pending(w.root).length}`)
if (imgs(w.root).length) fail('E: no card may show an img before its preview arrives')
if (calls.length) fail(`E: LAZY broken, ${calls.length} render(s) before any card was in view`)
inView(phFor(w.root, 'Pone')); inView(phFor(w.root, 'Ptwo')); await settle()
if (calls.length !== 1) fail(`E: ONE AT A TIME broken, ${calls.length} renders running with two cards in view`)
answer.p1?.('blob:gate-one'); await settle()
const im = imgs(w.root)
if (im.length !== 1 || im[0].props.src !== 'blob:gate-one') fail(`E: the answered card should be an img on blob:gate-one, got ${JSON.stringify(im.map((x) => x.props.src))}`)
if (calls.length !== 2 || calls[1] !== 'p2') fail(`E: the second render should start after the first answered, calls ${JSON.stringify(calls)}`)
answer.p2?.(null); await settle()
const ph2 = phFor(w.root, 'Ptwo')
if (!ph2 || hasClass(ph2, 'is-pending') || !/unavailable/i.test(textOf(ph2))) fail(`E: a null answer should show the "preview unavailable" placeholder, got ${JSON.stringify(ph2 && textOf(ph2))}`)
if (calls.includes('p3')) fail('E: a card never in view was rendered')
if (maxInFlight !== 1) fail(`E: ${maxInFlight} renders overlapped`)
w.app.unmount()
}
{
const n = calls.length
const w = await mountWall({ previewSrc, instanceId: 'gate-e' }, three)
await settle()
const p1 = phFor(w.root, 'Pone'); if (p1) inView(p1)
await settle()
if (calls.length !== n) fail(`G: CACHE broken, a remount rendered again (${calls.slice(n).join(',')})`)
const im = imgs(w.root)
if (!im.some((x) => x.props.src === 'blob:gate-one')) fail('G: the remount should show the cached blob:gate-one')
w.app.unmount()
}
// F: a render that never answers is bounded by the timeout
{
const realSetTimeout = globalThis.setTimeout
globalThis.setTimeout = (f, ms, ...a) => realSetTimeout(f, ms >= 10000 ? 1 : ms, ...a)
try {
const w = await mountWall({ previewSrc: () => new Promise(() => {}), instanceId: 'gate-f' }, [{ id: 'f1', label: 'Fone', count: 1 }])
await settle()
inView(phFor(w.root, 'Fone'))
await settle(); await new Promise((r) => realSetTimeout(r, 20)); await settle()
const ph = phFor(w.root, 'Fone')
if (!ph || hasClass(ph, 'is-pending') || !/unavailable/i.test(textOf(ph))) fail(`F: a render that never answers should end on the placeholder, got ${JSON.stringify(ph && textOf(ph))}`)
w.app.unmount()
} finally { globalThis.setTimeout = realSetTimeout }
}
} catch (e) {
fail(`threw: ${e?.stack || e}`)
} finally {
@ -204,8 +291,8 @@ try {
if (!buttonsSeen) fail('vacuous: no header sticker rendered in either arm')
if (failures.length) {
console.error(`POSTER-WALL-HEADER-ACTION: FAIL (${failures.length})\n - ${failures.join('\n - ')}`)
console.error(`POSTER-WALL-HOST-SEAMS: FAIL (${failures.length})\n - ${failures.join('\n - ')}`)
process.exitCode = 1
} else {
console.log(`POSTER-WALL-HEADER-ACTION: PASS (3 arms, ${buttonsSeen} stickers rendered; default toggle + window event unchanged, headerAction emits and dispatches nothing)`)
console.log(`POSTER-WALL-HOST-SEAMS: PASS (7 arms, ${buttonsSeen} stickers rendered; defaults unchanged: the Show-on-map toggle and the stored webp; headerAction emits, previewSrc is lazy, one at a time, cached and bounded)`)
}

View file

@ -192,6 +192,13 @@ const props = defineProps({
// with the item, dispatching no window event. ariaKey gets { label: item.label }. An item with no
// count (a template, not a list of people) shows the label alone, without the "N UPG ·" prefix.
headerAction: { type: Object, default: null },
// ★ WHERE A CARD'S PREVIEW IMAGE COMES FROM. null (the default) is the stored
// previews/<id>.webp every wall uses today, untouched. A wall whose cards have no stored
// preview (a gallery of templates drawn from the library, so a library change shows up in it)
// passes async (item) => an image URL | null. It runs only for cards that come into view, one at
// a time, cached per page by instanceId + item id; until it answers the card shows its label, and
// null, a throw or no answer within PREVIEW_TIMEOUT_MS shows the "preview unavailable" placeholder.
previewSrc: { type: Function, default: null },
})
const emit = defineEmits(['header-action'])
@ -322,6 +329,8 @@ const fallbackBase = computed(() => {
const attempt = ref({})
const previewUrl = (id) => `${attempt.value[id] ? fallbackBase.value : base.value}/previews/${id}.webp`
function onPreviewError(id) {
// A generated preview has no second base to try.
if (props.previewSrc) { missing.value = { ...missing.value, [id]: true }; return }
const fb = fallbackBase.value
if (!attempt.value[id] && fb && fb !== base.value) { attempt.value = { ...attempt.value, [id]: 1 }; return }
missing.value = { ...missing.value, [id]: true }
@ -427,6 +436,74 @@ onMounted(() => {
onUpdated(observeRows)
onBeforeUnmount(() => { if (rowRO) rowRO.disconnect(); rowRO = null })
// ─── GENERATED PREVIEWS (the previewSrc prop) ────────────────────────────────
// Nothing here runs unless the caller passes previewSrc. A pending card registers its placeholder
// through a function ref; the observer queues it once it is in view; the queue renders ONE preview
// at a time, so a gallery never starts a burst of renders on load. The answer is cached on the page
// (a module cache would be per bundle), so a remount or a second tab shows it without a re-render.
const PREVIEW_TIMEOUT_MS = 20000
const previewCache = (globalThis.__posterWallPreviews_v1 ||= new Map())
const generated = ref({}) // id -> the URL previewSrc answered
const previewEls = new Map() // id -> its pending placeholder
const previewIds = new WeakMap() // placeholder -> id
const previewQueue = []
let previewBusy = false
let previewIO = null
function setPreviewEl(id, el) {
const old = previewEls.get(id)
if (old === el) return
if (old && previewIO) previewIO.unobserve(old)
if (!el) { previewEls.delete(id); return }
previewEls.set(id, el)
previewIds.set(el, id)
if (previewIO) previewIO.observe(el)
}
function queuePreview(id) {
if (id == null || generated.value[id] || missing.value[id] || previewQueue.includes(id)) return
previewQueue.push(id)
pumpPreviews()
}
function withTimeout(promise, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('preview timed out')), ms)
promise.then((v) => { clearTimeout(timer); resolve(v) }, (e) => { clearTimeout(timer); reject(e) })
})
}
async function pumpPreviews() {
if (previewBusy || !previewQueue.length) return
const id = previewQueue.shift()
const item = (props.items || []).find((x) => x && x.id === id)
if (!item || !props.previewSrc) { pumpPreviews(); return }
previewBusy = true
const key = `${props.instanceId}\u0000${id}`
try {
if (!previewCache.has(key)) {
previewCache.set(key, withTimeout(Promise.resolve().then(() => props.previewSrc(item)), PREVIEW_TIMEOUT_MS))
}
let url = null
try { url = await previewCache.get(key) } catch (_) { url = null }
if (url) generated.value = { ...generated.value, [id]: url }
else {
previewCache.delete(key) // a later mount may try again
missing.value = { ...missing.value, [id]: true }
}
} finally {
previewBusy = false
pumpPreviews()
}
}
onMounted(() => {
if (!props.previewSrc) return
// No observer (an old engine): still one at a time, just not deferred to view.
if (typeof IntersectionObserver === 'undefined') { for (const id of previewEls.keys()) queuePreview(id); return }
previewIO = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) queuePreview(previewIds.get(e.target))
}, { rootMargin: '200px' })
for (const el of previewEls.values()) previewIO.observe(el)
})
onBeforeUnmount(() => { if (previewIO) previewIO.disconnect(); previewIO = null; previewQueue.length = 0 })
function showOnMap(item) {
// TOGGLE, not a one-way set: a control the user can turn on but not off reads as
// broken even when the selection is correct.
@ -1288,8 +1365,14 @@ useShadowStyles(
</div>
<!-- previewAction='print': the poster preview IS the control. Keyboard-
reachable (role/tabindex/Enter/Space), not click-only. -->
<!-- previewSrc set and the preview not arrived yet: the card's label, never a spinner. -->
<div v-else-if="previewSrc && !generated[item.id] && !missing[item.id]" class="ph is-pending"
:ref="(el) => setPreviewEl(item.id, el)">
<div class="ph-label">{{ item.label }}</div>
<div v-if="item.count" class="ph-count">{{ countOf('wall.peopleGroupCount', item.count) }}</div>
</div>
<img v-else-if="!missing[item.id]" class="img"
:class="{ 'is-clickable': previewIsControl }" :src="previewUrl(item.id)"
:class="{ 'is-clickable': previewIsControl }" :src="previewSrc ? generated[item.id] : previewUrl(item.id)"
:alt="previewIsControl
? hud('wall.posterAltAction', '{label} — prayer poster (click to preview and print)', { label: item.label })
: hud('wall.posterAlt', '{label} — prayer poster', { label: item.label })"

View file

@ -252,7 +252,7 @@
"check:posters-exportable": "node internal/check-posters-exportable.mjs",
"check:poster-prints": "node internal/check-poster-prints.mjs",
"check:poster-raster": "node internal/check-poster-raster.mjs",
"check:poster-wall-header-action": "node internal/check-poster-wall-header-action.mjs",
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs",
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
"check:preset-config-merge": "node internal/check-preset-config-merge.mjs",
@ -343,7 +343,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:poster-wall-header-action check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:poster-wall-host-seams check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -570,7 +570,7 @@
"check:poster-fetch-guard": "a host's 200 fallback page is never taken for a poster. On a drop-in dev host a missing posters/<id>.webp answered 200 text/html, and export handed that page to the print dialog as the image. Asserts library/packet/poster-fetch.js accepts a raster only if it is an image (by type or by its first bytes) and a source only if it is a poster document (class=\"stage\"), and that neither usePosterPrints nor PosterWall fetches posters/ directly. Node only, no server.",
"check:poster-layout-optin": "the composed poster sheet (header, map, legend beside it, footer) is OPT-IN: spec.layout === 'poster' asks for it, and every caller that does not gets the full-page map exactly as before, asserted by object identity, not just by value. Drives the real layout decisions with real spec objects. Headless, no browser.",
"check:poster-print-fallback": "clicks the REAL poster print control with the served poster generator and 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; NOT MEASURED otherwise.",
"check:poster-wall-header-action": "PosterWall's card header sticker does what headerAction says. Default (null) is the Show-on-map TOGGLE every existing wall uses: aria-pressed, one window event per click (listId, then null), the is-last highlight, no emit. { labelKey, ariaKey } makes it a plain button: label and aria-label from those keys, one header-action emit with the item, NO window event, no aria-pressed, no is-last. Headless: PosterWall compiled as a client component and mounted on a recording renderer; the real onClick is called. A third arm: an item with no count reads the label alone (no \"N UPG ·\"). Mutation-tested: no seam, a dispatching new branch, an emitting default, aria-pressed on the new branch, an unguarded count prefix each FAIL.",
"check:poster-wall-host-seams": "PosterWall's two host seams, and that with neither set every wall behaves as before. headerAction: default null is the Show-on-map TOGGLE (aria-pressed, one window event per click, is-last, no emit); { labelKey, ariaKey } is a plain button that emits header-action with the item, no window event, no aria-pressed, and reads the label alone for an item with no count. previewSrc: default null is the stored previews/<id>.webp and no IntersectionObserver; set, a card renders only once in view, one render at a time, cached per page, and null / a throw / a timeout ends on the preview-unavailable placeholder. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 11 mutants each FAIL.",
"check:posters-exportable": "every poster the tree offers can be printed. With posterSources on, the tree once shipped only the 28 zone sources, so 350 of 378 posters said \"poster not found (404)\". Each id, from data/posters and the wall's previews, must ship a real WebP (RIFF..WEBP) or a real poster source (its class=\"stage\" element), so a placeholder or a host's fallback page cannot pass. SKIPs when posters are off by config, which check:poster-degrades covers. Reads the built tree; no server.",
"check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT=<dir> so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.",
"check:preset-config-merge": "a preset page (<bundle>/<profile>/<instance>/index.html) keeps every default in its profile's .meta.json config, with the instance's props merged over them (go-int's upg-neighbor presets lost pinHint:false when props replaced the config). Reads the built tree; SKIP without one.",