From 47d81afd0898384c65541b4e967fa05f7848a387 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 06:07:21 +0000 Subject: [PATCH] Poster Design previews: generated by its own live path, stamped, and gated for freshness No freshness gate existed for any stored preview, so Poster Design no longer borrows other plugins' thumbnails as its own. It generates its own: gen:poster-design-previews draws every configuration it renders live through the same library path the product prints with (Styles with ?livePreviews=1, in the shared browser) and stores each thumbnail under assets/previews/poster-design/, with a fingerprint of its inputs in src/previews.stamp.json (scripts/preview-inputs.mjs: the sha256 of every source file that render reads, plus the template entry). Styles shows a stamped thumbnail at once; Show full details and print stay live. check:poster-design-previews-fresh (gates:pre) proves every live configuration has a stored preview whose fingerprint equals today's code: a code change makes it stale until the generator runs again. Its self-test proves a one-character renderer edit, a missing stamp and a changed entry each fail. The city, India-state and UPG100-card kinds have no live renderer here yet, so they stay Experimental, shown from their plugin's stored poster. PREP: the stamp is empty, so the gate is red until gen:poster-design-previews runs on a built tree at this row's turn. --- package.json | 8 ++- .../poster-design/poster-styles.vue | 4 +- .../scripts/check-previews-fresh.mjs | 67 +++++++++++++++++++ .../poster-design/scripts/gen-previews.mjs | 60 +++++++++++++++++ .../poster-design/scripts/preview-inputs.mjs | 57 ++++++++++++++++ .../poster-design/src/previews.stamp.json | 5 ++ plugin-designs/poster-design/src/templates.js | 24 ++++--- 7 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 plugin-designs/poster-design/scripts/check-previews-fresh.mjs create mode 100644 plugin-designs/poster-design/scripts/gen-previews.mjs create mode 100644 plugin-designs/poster-design/scripts/preview-inputs.mjs create mode 100644 plugin-designs/poster-design/src/previews.stamp.json diff --git a/package.json b/package.json index beac0276a..014fed224 100644 --- a/package.json +++ b/package.json @@ -262,6 +262,9 @@ "check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs", "check:poster-design-catalogue": "node plugin-designs/poster-design/scripts/check-catalogue.mjs", "check:poster-design-catalogue:self-test": "node plugin-designs/poster-design/scripts/check-catalogue.mjs --self-test", + "check:poster-design-previews-fresh": "node plugin-designs/poster-design/scripts/check-previews-fresh.mjs", + "check:poster-design-previews-fresh:self-test": "node plugin-designs/poster-design/scripts/check-previews-fresh.mjs --self-test", + "gen:poster-design-previews": "node plugin-designs/poster-design/scripts/gen-previews.mjs", "check:upg100-zone-copy": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs", "check:upg100-zone-copy:self-test": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs --self-test", "check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs", @@ -359,7 +362,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:script-flags 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: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 check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test check:pinned-fetch-fresh:self-test", - "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: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:pins-in-state check:fork-leaders check:circular-layout 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:india-states check:batch-selection-export 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-internal-role-nouns 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:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf 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:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:upg100-zone-copy check:upg100-zone-copy:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh", + "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: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:pins-in-state check:fork-leaders check:circular-layout 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:india-states check:batch-selection-export 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-internal-role-nouns 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:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf 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:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:poster-design-previews-fresh check:poster-design-previews-fresh:self-test check:upg100-zone-copy check:upg100-zone-copy:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh", "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", @@ -622,6 +625,9 @@ "check:poster-degrades": "with NO stored poster, NO source and NO generator (forced in the browser: every posters/*.webp and *.html answers 404, lib/poster-render.js is aborted), nothing must print and the reader must be told why. Measures any served tree; NOT MEASURED without a wall url.", "check:poster-design-catalogue": "Poster Design's configuration catalogue (plugin-designs/poster-design/src/templates.js) says only true things about the library: its sheet formats are exactly poster-render.js FORMATS at the same pixel sizes (the generator is a pinned asset, so the table cannot be imported); every configuration name is unique and well formed; the card entries are exactly card-render STOCK; every catalogue key it names exists in en/posterDesign.json; the sample country can be drawn. Six mutants (px drift, a missing format, a duplicate name, a bad key, an undrawable sample, a dropped stock) each FAIL. It also validates every plugin's posterDesign block (plugin-designs//.meta.json: family, name, size, mobileFriendly, optional labelKey and instance), keeps names unique across templates.js and all blocks, and fails when a name in the names ledger (src/catalogue-names.json) was renamed or removed. Every template's stored preview (templates.js `preview`) must be a file some plugin ships under assets/previews (read from the git index).", "check:poster-design-catalogue:self-test": "an invalid posterDesign block, a name that clashes with templates.js, a renamed ledger name a block naming a preset that does not exist, a sample that exists but cannot be drawn (India with withholding off), and a preview no plugin ships must each make check:poster-design-catalogue fail, and the documented example block must pass.", + "check:poster-design-previews-fresh": "Poster Design's stored previews are honest thumbnails of its live render: every configuration it renders live has one (src/previews.stamp.json, the file in the tree), and each stamped fingerprint (scripts/preview-inputs.mjs: the sha256 of every source file its render reads, plus the template entry) equals today's. A code change makes a preview STALE until gen:poster-design-previews draws it again.", + "check:poster-design-previews-fresh:self-test": "a one-character change to a renderer file, a missing stamp and a changed template entry must each make check:poster-design-previews-fresh fail; the real tree must pass.", + "gen:poster-design-previews": "draws every Poster Design configuration that renders live through its live library path (Styles with ?livePreviews=1, in the shared browser) and stores each thumbnail under plugin-designs/poster-design/assets/previews/poster-design/ with its input fingerprint in src/previews.stamp.json. Needs --url and --connect .", "check:upg100-zone-copy": "library/packet/upg100Zones/ is UPG100's zone-poster code copied into the library (so Poster Design draws UPG100 zone posters). COPY mode while UPG100's originals exist: each file equals its original beyond its import specifiers, clusters.json byte for byte. ONE-PATH mode once UPG100 switches and deletes its originals: no plugin file defines the zone code again. Half switched fails.", "check:upg100-zone-copy:self-test": "a one-character edit in a function body, a changed clusters.json, a missing copy, a half switch, and (one-path) a plugin re-defining makeZoneSheet must each fail; an import-specifier-only change and a clean one-path tree must pass.", "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/.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.", diff --git a/plugin-designs/poster-design/poster-styles.vue b/plugin-designs/poster-design/poster-styles.vue index c535beeed..b19168d1c 100644 --- a/plugin-designs/poster-design/poster-styles.vue +++ b/plugin-designs/poster-design/poster-styles.vue @@ -183,8 +183,10 @@ async function renderDocument(name) { // A configuration with a STORED thumbnail of itself (templates.js `preview`) shows it at once: one small image // instead of a live draw (the zone posters took ~9 s each, measured). The live render is still one click away. const storedUrl = (rel) => `${assetsBase.value}/${rel}` +// ?livePreviews=1 draws every card live (gen:poster-design-previews uses it to make the stored ones). +const livePreviews = typeof location !== 'undefined' && new URLSearchParams(location.search).get('livePreviews') === '1' async function previewSrc(item) { - const stored = byName.get(item.id)?.preview + const stored = !livePreviews && byName.get(item.id)?.preview if (stored) return storedUrl(stored) const out = await renderDocument(item.id) if (!out) return null diff --git a/plugin-designs/poster-design/scripts/check-previews-fresh.mjs b/plugin-designs/poster-design/scripts/check-previews-fresh.mjs new file mode 100644 index 000000000..32970553b --- /dev/null +++ b/plugin-designs/poster-design/scripts/check-previews-fresh.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * check:poster-design-previews-fresh — Poster Design's stored previews are honest thumbnails of the live render. + * + * 1. Every configuration Poster Design renders live has a stored preview in previews.stamp.json (so every card + * shows at once), and that file is in the tree (plugin-designs/poster-design/assets/, git index). + * 2. Each preview's stamped FINGERPRINT (scripts/preview-inputs.mjs: the sha256 of every source file its render + * reads, plus the template entry) equals today's. A code change makes it STALE: run + * `bun run gen:poster-design-previews` (it draws through the same library path the product prints with). + * 3. No stamp names a configuration that no longer renders live. + * --self-test: a one-character change to a renderer file, a missing stamp and a changed template entry each FAIL. + */ +import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { fingerprint, inputsFor } from './preview-inputs.mjs' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const STAMP_FILE = join(ROOT, 'plugin-designs/poster-design/src/previews.stamp.json') + +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 }, resolve: { alias: { '@map': join(ROOT, 'library') } } }) +let configs +try { configs = (await server.ssrLoadModule('/plugin-designs/poster-design/src/templates.js')).listConfigurations() } finally { await server.close() } + +const stamp = JSON.parse(readFileSync(STAMP_FILE, 'utf8')) +const tracked = new Set(execFileSync('git', ['-C', ROOT, 'ls-files', 'plugin-designs/poster-design/assets'], { encoding: 'utf8' }).split('\n').filter(Boolean)) + +export function arms({ configs, stamp, tracked, fp }) { + const fails = [] + const live = configs.filter((c) => inputsFor(c)) + const S = stamp.previews || {} + for (const c of live) { + const s = S[c.name] + if (!s) { fails.push(`1: '${c.name}' renders live but has no stored preview (run gen:poster-design-previews)`); continue } + if (!tracked.has(`plugin-designs/poster-design/assets/${s.file}`)) fails.push(`1: '${c.name}': its preview assets/${s.file} is not in the tree`) + const now = fp(c) + const changed = Object.keys({ ...now, ...s.inputs }).filter((k) => now[k] !== (s.inputs || {})[k]) + if (changed.length) fails.push(`2: '${c.name}' is STALE: ${changed.slice(0, 4).join(', ')}${changed.length > 4 ? ` +${changed.length - 4}` : ''} changed since its preview was made (run gen:poster-design-previews)`) + } + for (const n of Object.keys(S)) if (!live.some((c) => c.name === n)) fails.push(`3: the stamp names '${n}', which no longer renders live`) + return fails +} + +const real = { configs, stamp, tracked, fp: (c) => fingerprint(ROOT, c) } +if (process.argv.includes('--self-test')) { + const victim = configs.find((c) => inputsFor(c) && (stamp.previews || {})[c.name]) + let ok = true + if (!victim) { console.log(' ✗ no stamped preview to mutate: run gen:poster-design-previews first'); process.exit(1) } + const file = inputsFor(victim)[0] + const cases = [ + ['a one-character change to a renderer file', { ...real, fp: (c) => fingerprint(ROOT, c, (f) => { const b = readFileSync(join(ROOT, f)); return f === file ? Buffer.concat([b, Buffer.from(' ')]) : b }) }], + ['a missing stamp', { ...real, stamp: { previews: Object.fromEntries(Object.entries(stamp.previews).filter(([k]) => k !== victim.name)) } }], + ['a changed template entry', { ...real, configs: configs.map((c) => (c === victim ? { ...c, settings: { ...c.settings, gate: 'changed' } } : c)) }], + ] + for (const [name, input] of cases) { + const f = arms(input); const hit = f.find((x) => x.includes(victim.name)) + console.log(hit ? ` ✓ caught: ${name} -> ${hit}` : ` ✗ MISSED: ${name}`); if (!hit) ok = false + } + const clean = arms(real) + console.log(clean.length ? ` ✗ the real tree fails: ${clean[0]}` : ' ✓ the real tree passes'); if (clean.length) ok = false + console.log(ok ? 'POSTER-DESIGN-PREVIEWS-FRESH SELF-TEST: PASS' : 'POSTER-DESIGN-PREVIEWS-FRESH SELF-TEST: FAIL'); process.exit(ok ? 0 : 1) +} +const fails = arms(real) +if (fails.length) { console.log(`POSTER-DESIGN-PREVIEWS-FRESH: FAIL (${fails.length})`); fails.forEach((f) => console.log(` - ${f}`)); process.exit(1) } +console.log(`POSTER-DESIGN-PREVIEWS-FRESH: PASS — ${Object.keys(stamp.previews || {}).length} stored previews, each matching the code it was drawn from (generated ${stamp.generatedAt || '?'}).`) diff --git a/plugin-designs/poster-design/scripts/gen-previews.mjs b/plugin-designs/poster-design/scripts/gen-previews.mjs new file mode 100644 index 000000000..f203fd824 --- /dev/null +++ b/plugin-designs/poster-design/scripts/gen-previews.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * gen-previews.mjs (bun run gen:poster-design-previews -- --url http://127.0.0.1:) — draws every Poster + * Design configuration that renders live, THROUGH ITS LIVE PATH (Styles with ?livePreviews=1, i.e. the same + * library code the product prints with), and stores each thumbnail as + * plugin-designs/poster-design/assets/previews/poster-design/.webp, plus its input fingerprint in + * src/previews.stamp.json (scripts/preview-inputs.mjs). check:poster-design-previews-fresh then proves each + * stored preview still matches the code. Needs a served, built tree and the shared browser. + */ +import { writeFileSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { fingerprint, inputsFor } from './preview-inputs.mjs' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const OUT = join(ROOT, 'plugin-designs/poster-design/assets/previews/poster-design') +const STAMP_FILE = join(ROOT, 'plugin-designs/poster-design/src/previews.stamp.json') +// The browser connector exports withSharedPage(fn, { viewport }); it is the operator's, so it is passed in, never assumed. +const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : '' } +const BROWSER = arg('--connect') || process.env.SHARED_BROWSER_CONNECT || '' +const url = (arg('--url') || '').replace(/\/+$/, '') +if (!/^https?:\/\//.test(url) || !BROWSER) { console.error('usage: gen-previews.mjs --url http://127.0.0.1: --connect (a served, built tree; or SHARED_BROWSER_CONNECT)'); process.exit(2) } + +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 }, resolve: { alias: { '@map': join(ROOT, 'library') } } }) +let configs +try { configs = (await server.ssrLoadModule('/plugin-designs/poster-design/src/templates.js')).listConfigurations() } finally { await server.close() } +const live = configs.filter((c) => inputsFor(c)) + +const { withSharedPage } = await import(BROWSER) +mkdirSync(OUT, { recursive: true }) +const previews = {} +let missing = 0 +await withSharedPage(async (page) => { + await page.goto(`${url}/dx-maps/poster-design/poster-styles/index.html?livePreviews=1&lang=en`, { waitUntil: 'load', timeout: 60000 }) + await page.locator('.card').first().waitFor({ timeout: 30000 }) + // open every collapsed group, so experimental configurations are drawn too + for (const h of await page.locator('.sec-head[aria-expanded="false"]').all()) await h.click() + for (const c of live) { + const card = page.locator(`.card[data-list-id="${c.name}"]`) + if (!(await card.count())) { console.log(` ✗ ${c.name}: no card on the page`); missing++; continue } + await card.scrollIntoViewIfNeeded() + for (let k = 0; k < 240 && await card.locator('.ph.is-pending').count(); k++) await page.waitForTimeout(250) + const b64 = await card.evaluate(async (el) => { + const img = el.querySelector('img.img'); if (!img || !img.complete || !img.naturalWidth) return null + const w = Math.min(375, img.naturalWidth), h = Math.round(img.naturalHeight * (w / img.naturalWidth)) + const cv = document.createElement('canvas'); cv.width = w; cv.height = h + cv.getContext('2d').drawImage(img, 0, 0, w, h) + return cv.toDataURL('image/webp', 0.82).split(',')[1] + }) + if (!b64) { console.log(` ✗ ${c.name}: not drawn`); missing++; continue } + const file = `previews/poster-design/${c.name}.webp` + writeFileSync(join(ROOT, 'plugin-designs/poster-design/assets', file), Buffer.from(b64, 'base64')) + previews[c.name] = { file, inputs: fingerprint(ROOT, c) } + console.log(` ✓ ${c.name} (${Math.round(Buffer.from(b64, 'base64').length / 1024)} KB)`) + } +}, { viewport: { width: 1440, height: 900 } }) +writeFileSync(STAMP_FILE, JSON.stringify({ '//': 'GENERATED by gen:poster-design-previews; check:poster-design-previews-fresh proves each entry. Do not edit.', generatedAt: new Date().toISOString(), previews }, null, 2) + '\n') +console.log(`${Object.keys(previews).length} previews stored, ${missing} missing`) +process.exit(missing ? 1 : 0) diff --git a/plugin-designs/poster-design/scripts/preview-inputs.mjs b/plugin-designs/poster-design/scripts/preview-inputs.mjs new file mode 100644 index 000000000..4c1c5f1f6 --- /dev/null +++ b/plugin-designs/poster-design/scripts/preview-inputs.mjs @@ -0,0 +1,57 @@ +/** + * preview-inputs.mjs — what each Poster Design preview is MADE FROM, so a stored thumbnail can be proven fresh. + * + * A stored preview is a thumbnail of the live render (gen-previews.mjs draws it through the same library path the + * product prints with). Its FINGERPRINT is the sha256 of every source file that render reads, plus the template's + * own entry. check-previews-fresh.mjs recomputes it: when template CODE changes, the preview is stale until it is + * generated again. Live DATA (people-group counts) moving is not a code change and does not stale a preview. + * + * One function for both the generator and the gate, so they cannot disagree about what a preview depends on. + */ +import { createHash } from 'node:crypto' +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +// The render path of each kind, as repo-relative files. Shared pieces are listed once and composed. +const SHEET = [ + 'library/packet/countrySheet.js', 'library/packet/posterSheets.js', 'library/packet/poster-render.js', + 'library/scripts/posters/series.json', 'library/data/country-posters.json', 'library/packet/poster-raster.js', +] +const ZONE = [ + 'library/packet/upg100Zones/zone-sheet-print.js', 'library/packet/upg100Zones/zone-sheet.js', + 'library/packet/upg100Zones/legend-tree.js', 'library/packet/upg100Zones/selector-evaluator.js', + 'library/packet/upg100Zones/zone-geometry.js', 'library/packet/upg100Zones/zones-for-list.js', + 'library/packet/upg100Zones/clusters.json', 'library/packet/posterSheets.js', 'library/packet/poster-render.js', + 'library/scripts/posters/series.json', 'library/packet/poster-raster.js', +] +const DOCUMENT = ['plugin-designs/poster-design/src/pageThumb.js', 'library/packet/packetStrings.js', 'library/scripts/posters/packet-strings.json'] +const PACKET = ['library/packet/packet-render.js', ...DOCUMENT, ...SHEET] +const CARDS = ['library/packet/card-render.js', ...DOCUMENT] +const PANELS = ['library/components/poster/panelSheets.js', 'plugin-designs/poster-design/src/panelsSvg.js', 'library/constants/posterSizes.js'] + +/** The source files a configuration's live render reads; null when Poster Design does not render it live. */ +export function inputsFor(config) { + if (!config || !config.render) return null + const pick = { + mapSheet: SHEET, + upgZone: ZONE, + packet: PACKET, + cards: CARDS, + wall: PANELS, + }[config.family] + return pick ? [...new Set(pick)].sort() : null +} + +/** The template entry itself, as stable text (its size, settings, sample, renderer): a change here stales it too. */ +export const entryText = (c) => JSON.stringify({ name: c.name, family: c.family, size: c.size, px: c.px, settings: c.settings, sample: c.sample || null, renderer: c.renderer }) + +const sha = (buf) => createHash('sha256').update(buf).digest('hex').slice(0, 16) + +/** { file: sha256-prefix } for every input, plus '#entry'. A missing input is recorded as 'MISSING'. */ +export function fingerprint(root, config, read = (f) => (existsSync(join(root, f)) ? readFileSync(join(root, f)) : null)) { + const files = inputsFor(config) + if (!files) return null + const out = { '#entry': sha(entryText(config)) } + for (const f of files) { const b = read(f); out[f] = b == null ? 'MISSING' : sha(b) } + return out +} diff --git a/plugin-designs/poster-design/src/previews.stamp.json b/plugin-designs/poster-design/src/previews.stamp.json new file mode 100644 index 000000000..c688a2554 --- /dev/null +++ b/plugin-designs/poster-design/src/previews.stamp.json @@ -0,0 +1,5 @@ +{ + "//": "GENERATED by gen:poster-design-previews; check:poster-design-previews-fresh proves each entry. Do not edit.", + "generatedAt": null, + "previews": {} +} diff --git a/plugin-designs/poster-design/src/templates.js b/plugin-designs/poster-design/src/templates.js index a1dd1d223..8498552b5 100644 --- a/plugin-designs/poster-design/src/templates.js +++ b/plugin-designs/poster-design/src/templates.js @@ -18,6 +18,10 @@ * Where the library already has the list (card stocks), it is DERIVED here, never retyped. */ import { STOCK } from '@map/packet/card-render.js' +// The STORED thumbnails Poster Design generated of its own live renders (gen:poster-design-previews), each proven +// fresh against the code it was drawn from (check:poster-design-previews-fresh). A configuration with an entry +// shows it at once; without one it renders live. +import STAMP from './previews.stamp.json' // The sample every map sheet is drawn with: one real country from the data the country wall ships, // mid-sized (34 groups), on the standard sheet tier, with full outlines, so each sheet shows every part. @@ -33,10 +37,7 @@ export const SAMPLE_COUNTRY_ID = 'country-npl' // show the template, and their details say so (missing: noCountryAtSize). Names never change (src/catalogue-names.json). export const SHEET_FORMATS = { // `size` is the printed size as data (src/sizeText.js names it in the reader's language): inches, side lanes, paper. - // `preview`: a STORED thumbnail of this exact configuration (same format, same sample), generated by the real - // generator (library/scripts/posters), path under the tree's assets/. Styles shows it instantly; the live render - // stays one click away (Show full details, print). check:poster-design-catalogue proves the file exists. - '3x3': { size: { dims: [36, 36] }, px: [1500, 1500], preview: 'previews/country-npl.webp' }, + '3x3': { size: { dims: [36, 36] }, px: [1500, 1500] }, // experimental: still being designed, or no drawable preview; Styles lists these in a collapsed Experimental group. '60x96': { size: { dims: [60, 96], lanes: true }, px: [2500, 4000], sample: 'country-ind', experimental: true }, '66x66': { size: { dims: [66, 66], lanes: true }, px: [2750, 2750], sample: 'country-chn', missing: 'posterDesign.missing.noCountryAtSize', experimental: true }, @@ -68,7 +69,6 @@ function sheetEntry(format) { px: f.px, legacy: !!f.legacy, ...(f.experimental ? { experimental: true } : {}), - ...(f.preview && !f.sample ? { preview: f.preview } : {}), ...(f.missing ? { missing: f.missing } : {}), renderer: 'library/packet/posterSheets.js generateCountrySheet → library/packet/poster-render.js', settings: { format }, @@ -95,16 +95,13 @@ function zoneEntry(format) { renderer: ZONE_RENDERER, settings: { format }, sample: SAMPLE_ZONE_ID, - ...(ZONE_PREVIEWS[format] ? { preview: ZONE_PREVIEWS[format] } : {}), render: (ctx) => ctx.zoneSheet(format, SAMPLE_ZONE_ID), } } -// The stored thumbnails of middle-east-core that are exactly these configurations (the 3x3 zone poster and the -// packet's page 1); the letter and 3x4 zone posters have none yet, so they render live. -const ZONE_PREVIEWS = { '3x3': 'previews/middle-east-core.webp', packet: 'previews/examples/middle-east-core/packet-p01.webp' } // ONE OF EACH KIND that exists only as STORED posters today (their generator is the batch one, library/scripts/ -// posters): the card shows the stored poster; the live render is the source plugin's own page. Each carries its +// posters): Poster Design cannot draw them live yet, so their preview is the SOURCE PLUGIN's stored poster, with no +// freshness proof here. They stay EXPERIMENTAL until their renderer is reachable from the library. Each carries its // source plugin (`plugin`), so that plugin's own posterDesign block may share the name. const STORED_KINDS = [ { name: 'city-poster.3x3', family: 'cityPoster', labelKey: 'posterDesign.tpl.cityPoster', plugin: 'key-cities', @@ -119,10 +116,15 @@ const STORED_KINDS = [ size: { dims: [2.5, 3.5] }, px: [480, 672], sampleLabel: 'middle-east-core, card 1', preview: 'previews/examples/middle-east-core/card-01.webp', renderer: 'library/packet/card-render.js createCardRenderer (STOCK poker), via library/scripts/posters/packet.mjs' }, -].map((k) => ({ ...k, settings: {}, sample: null, render: null })) +].map((k) => ({ ...k, settings: {}, sample: null, render: null, experimental: true })) + +const stored = (c) => (STAMP.previews && STAMP.previews[c.name] && !c.preview ? { ...c, preview: STAMP.previews[c.name].file } : c) /** @returns {Array} every configuration, in the gallery's order. */ export function listConfigurations() { + return listAll().map(stored) +} +function listAll() { return [ ...Object.keys(SHEET_FORMATS).map(sheetEntry), ...['letter', '3x3', '3x4', 'packet'].map(zoneEntry),