PosterWall seams: gate the second-base retry, observe the wall's scroller

check:poster-wall-host-seams gains arm H: a default wall's broken stored
webp retries the tree's own copy before the placeholder. Arm I: a generated
preview that fails shows the placeholder at once. The gate serves embedBase.js
as if from a built tree, because the second base is derived from that URL and
is empty from source, which let a mutant that skipped the retry pass.

previewSrc's observer now uses the wall's own scroller as its root (the
viewport when the wall is embedded and the page scrolls it). A pane wall
scrolls inside .wall, which clips its cards, so with the viewport as root the
200px margin never looked ahead. Arm E asserts the root.

PosterWall states the two limits of previewSrc: a timeout ends the wait, not
the render, and the page cache never revokes the URLs it holds.
This commit is contained in:
Doxa Maps 2026-09-30 21:27:06 +00:00
parent 7cc28b7aee
commit d9b237ac19
3 changed files with 68 additions and 6 deletions

View file

@ -28,12 +28,18 @@
* 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
* E (previewSrc) 3 cards, all pending, 0 renders before any is in view (lazy), observed with the wall's
* own scroller as the root (it clips its cards). 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.
* H (no previewSrc, the stored webp fails) the first error retries the tree's own copy
* (<tree>/assets/previews/<id>.webp) and only the second shows the placeholder.
* I (previewSrc, the generated image fails) one error shows the placeholder: there is no second base.
* embedBase.js is served to the gate as if from a BUILT tree (<tree>/<bundle>/<bundle>.js), because
* PosterWall's second base is derived from that URL; from source it is empty and H could not fail.
* A vacuous run (no .tape-h found, or no items rendered) FAILS.
*/
import { readFileSync } from 'node:fs'
@ -69,7 +75,7 @@ const observers = []
requestAnimationFrame: (f) => setTimeout(f, 0), cancelAnimationFrame: clearTimeout,
ResizeObserver: 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) }
IntersectionObserver: class { constructor(cb, opts) { this.cb = cb; this.opts = opts || {}; 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 {},
@ -80,6 +86,17 @@ const observers = []
// ── PosterWall as a CLIENT component, loaded through Vite for the alias ────────────────────────
const CLIENT_ID = '\0poster-wall-client.js'
// PosterWall's fallback base comes from the bundle's own script URL (embedBase.js bundleTreeRoot), which
// from source is not a built bundle URL, so the fallback would be empty and arm H vacuous.
const GATE_TREE = 'https://gate.invalid/tree'
const builtScriptUrl = {
name: 'gate-built-script-url',
transform(code, id) {
if (!id.endsWith('/library/utils/embedBase.js')) return null
if (!/const SCRIPT_URL = import\.meta\.url/.test(code)) throw new Error('embedBase.js no longer reads SCRIPT_URL from import.meta.url; update arm H')
return code.replace(/const SCRIPT_URL = import\.meta\.url/, `const SCRIPT_URL = ${JSON.stringify(GATE_TREE + '/poster-design/poster-design.js')}`)
},
}
const clientCompile = {
name: 'gate-poster-wall-client',
resolveId: (id) => (id === 'virtual:poster-wall-client' ? CLIENT_ID : null),
@ -98,7 +115,7 @@ const { createServer } = await import('vite')
const server = await createServer({
root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, ws: false, watch: null },
plugins: [clientCompile],
plugins: [clientCompile, builtScriptUrl],
resolve: { alias: { '@map': join(ROOT, 'library') } },
define: { __DOXA_TREE_NAME__: JSON.stringify(TREE_NAME) },
})
@ -244,6 +261,9 @@ try {
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`)
// A pane wall scrolls inside .wall, so the observer must watch THAT scroller or its margin never looks ahead.
const io = observers[observers.length - 1]
if (!io || !io.opts.root || io.opts.root.tag !== 'div' || !hasClass(io.opts.root, 'wall')) fail(`E: the observer's root should be the wall's own scroller (.wall), got ${io?.opts.root ? io.opts.root.tag + '.' + io.opts.root.props.class : String(io?.opts.root)}`)
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()
@ -283,6 +303,40 @@ try {
w.app.unmount()
} finally { globalThis.setTimeout = realSetTimeout }
}
// H: a default wall's broken stored webp tries the tree's copy before giving up
{
const w = await mountWall({})
const first = imgs(w.root)[0]
const firstSrc = first?.props.src
if (!first || typeof first.props.onError !== 'function') fail('H: the default card has no img error handler')
else {
first.props.onError(); await settle()
const second = imgs(w.root)[0]
const want = `${GATE_TREE}/assets/previews/gate-item.webp`
if (!second || second.props.src !== want) fail(`H: the first error should retry ${want}, got ${JSON.stringify(second?.props.src)} (was ${JSON.stringify(firstSrc)})`)
else {
second.props.onError(); await settle()
const ph = phFor(w.root, 'Gate Item')
if (imgs(w.root).length || !ph || !/unavailable/i.test(textOf(ph))) fail('H: the second error should show the "preview unavailable" placeholder')
}
}
w.app.unmount()
}
// I: a generated preview that fails to load has no second base
{
const w = await mountWall({ previewSrc: async () => 'blob:gate-broken', instanceId: 'gate-i' }, [{ id: 'i1', label: 'Ione', count: 1 }])
await settle(); inView(phFor(w.root, 'Ione')); await settle()
const im = imgs(w.root)[0]
if (!im || im.props.src !== 'blob:gate-broken') fail(`I: the generated URL should be on the img, got ${JSON.stringify(im?.props.src)}`)
else {
im.props.onError(); await settle()
const ph = phFor(w.root, 'Ione')
if (imgs(w.root).length || !ph || !/unavailable/i.test(textOf(ph))) fail('I: one error on a generated preview should show the placeholder, not retry another base')
}
w.app.unmount()
}
} catch (e) {
fail(`threw: ${e?.stack || e}`)
} finally {
@ -294,5 +348,5 @@ if (failures.length) {
console.error(`POSTER-WALL-HOST-SEAMS: FAIL (${failures.length})\n - ${failures.join('\n - ')}`)
process.exitCode = 1
} else {
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)`)
console.log(`POSTER-WALL-HOST-SEAMS: PASS (9 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

@ -198,6 +198,7 @@ const props = defineProps({
// 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.
// The page keeps each returned URL and never revokes it (see GENERATED PREVIEWS below).
previewSrc: { type: Function, default: null },
})
const emit = defineEmits(['header-action'])
@ -441,6 +442,10 @@ onBeforeUnmount(() => { if (rowRO) rowRO.disconnect(); rowRO = null })
// 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.
// Two limits, both deliberate: (1) the timeout stops the WAIT, not the render, so after one times out
// the next may start while the slow one still runs ("one at a time" is best-effort past a timeout);
// (2) the cache keeps whatever URL previewSrc returns for the life of the page and never revokes it,
// which suits a gallery that shows the same few previews again; a caller that makes many should say so.
const PREVIEW_TIMEOUT_MS = 20000
const previewCache = (globalThis.__posterWallPreviews_v1 ||= new Map())
const generated = ref({}) // id -> the URL previewSrc answered
@ -497,9 +502,12 @@ 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 }
// The ROOT is the wall's own scroller: a pane wall scrolls inside .wall, which clips its cards, so
// a viewport root would never see a card ahead of view and the margin would buy nothing. An embedded
// wall is in normal flow and the page scrolls it, so there the viewport is the right root.
previewIO = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) queuePreview(previewIds.get(e.target))
}, { rootMargin: '200px' })
}, { root: props.embedded ? null : wallEl.value, rootMargin: '200px' })
for (const el of previewEls.values()) previewIO.observe(el)
})
onBeforeUnmount(() => { if (previewIO) previewIO.disconnect(); previewIO = null; previewQueue.length = 0 })

View file

@ -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-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: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 of the wall's own scroller, one render at a time, cached per page, and null / a throw / a timeout ends on the preview-unavailable placeholder. Image errors: a default wall retries the tree's own copy before the placeholder; a generated preview goes straight to it. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 15 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.",