Poster Design: a new plugin with Styles and Sizes tabs

Every poster template the library prints, in one place, so the designs
can be compared side by side and handed to an agent by name.

- poster-design-mfe: the two-tab element (capability 4), the same shape
  as country-posters-mfe: the shared ViewSelect over Styles and Sizes,
  each tab its own element, one ShareButton for the composite.
- poster-styles: the library's PosterWall, one card per configuration
  (src/templates.js: country map sheets at each format, the packet page,
  the packet variants, every card stock, the Bless wall panels, the map
  print). Through PosterWall's host seams the header sticker reads "Show
  full details" and opens a dialog with the configuration name, size,
  file size, settings, sample and renderer, and each thumbnail is drawn
  from the library with sample data (Nepal) when it comes into view.
  Templates the gallery cannot draw yet say which part is missing. The
  dialog fits a phone (border-box), and in Arabic its file size and the
  configuration name stay whole (LRI…PDI isolates).
- poster-sizes: waits for the shared size table rather than keeping a
  second list of sizes.

check:poster-design-catalogue (gates:pre) keeps the catalogue true: its
sheet formats match poster-render FORMATS, names are unique, the card
entries are card-render STOCK, its keys exist, the sample can be drawn.
This commit is contained in:
Doxa Maps 2026-09-30 22:42:05 +00:00
parent 4500133abc
commit 855e89db65
12 changed files with 679 additions and 1 deletions

View file

@ -251,6 +251,7 @@
"check:window-globals-released": "node internal/check-window-globals-released.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"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-fetch-guard": "node internal/check-poster-fetch-guard.mjs",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
"check:posters-exportable": "node internal/check-posters-exportable.mjs",
@ -347,7 +348,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: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: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-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:country-sheet-one-path check:country-sheet-one-path: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:dxlife 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",
"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: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-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:country-sheet-one-path check:country-sheet-one-path: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:dxlife 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-design-catalogue",
"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",
@ -579,6 +580,7 @@
"check:print-frame-reclaim": "a print frame is kept until it has printed: openPrintFrame reclaims it 60 s after its load (not after its creation), at afterprint, or at a 10-minute ceiling, and printImageBlob revokes its object URL on the same clock. Fake timers; two-sided (the pre-fix copy fails). The Wall poster loads 132 MB of photos, and the old 60 s-from-creation clock removed the frame before it could print.",
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
"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.",
"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.",

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · poster-design staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #f3f3f1; color: #3b463d; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { max-width: 1280px; margin: 0 auto; padding: 32px 20px; }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.sub { color: #6d766e; font-size: 13px; margin: 6px 0 24px; }
.slot { border: 1px solid #d1d4cd; border-radius: 12px; overflow: hidden; background: #fff; margin-bottom: 24px; }
.slot .label { font: 12px/1 ui-monospace, monospace; color: #6d766e; padding: 8px 12px;
background: #f3f3f1; border-bottom: 1px solid #d1d4cd; }
.frame { height: 760px; position: relative; }
poster-design { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>poster-design · staging</h1>
<p class="sub">One bundle, three flat profiles: the two-tab element and each of its tabs alone.
All compile into <code>poster-design.js</code>.</p>
<div class="slot">
<div class="label">profile-config.profile = "poster-design-mfe"</div>
<div class="frame"><poster-design profile-config='{"profile":"poster-design-mfe"}'></poster-design></div>
</div>
<div class="slot">
<div class="label">profile-config.profile = "poster-styles"</div>
<div class="frame"><poster-design profile-config='{"profile":"poster-styles"}'></poster-design></div>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,23 @@
/**
* plugin-designs/poster-design/index.js: the Poster Design bundle entry.
*
* One tag, <poster-design>, and three profiles in this folder:
* poster-design-mfe the two-tab element (Styles + Sizes), the one a gallery mounts;
* poster-styles every poster template, one card per configuration;
* poster-sizes every poster size.
*
* <script src="…/poster-design/poster-design.js"></script>
* <poster-design profile-config='{"profile":"poster-design-mfe"}'></poster-design>
*/
import { defineMapElement } from '@map/elements/defineMapElement.js'
// Flat: './*.vue' finds the profiles beside this file (src/ holds private components, not profiles).
const profileModules = import.meta.glob('./*.vue')
const PosterDesignElement = defineMapElement({ profileModules })
if (typeof customElements !== 'undefined' && !customElements.get('poster-design')) {
customElements.define('poster-design', PosterDesignElement)
}
export default PosterDesignElement

View file

@ -0,0 +1,9 @@
{
"type": "nested-elements-mfe",
"label": "Poster Design — Styles + Sizes",
"composes": [
"poster-design/poster-styles",
"poster-design/poster-sizes"
],
"note": "The two-tab Poster Design element, the same shape as country-posters-mfe: the shared ViewSelect over Styles at [0,0] and Sizes at [1,0], each pane its own element. Styles renders the library's PosterWall with two host seams (headerAction for Show full details, previewSrc for thumbnails drawn from the library). Sizes reads the one shared size table."
}

View file

@ -0,0 +1,124 @@
<script setup>
/**
* poster-design-mfe.vue: Poster Design as ONE two-tab element, Styles and Sizes.
*
* @element poster-design
* @description Poster Design: every poster template (Styles) and every poster size (Sizes) side by side,
* drawn from the library, each named by its configuration so it can be handed to an agent.
*
* The same shape as country-posters-mfe and upg-100-mfe, on purpose: the pane-wrapper layout, the
* library's ViewSelect for the tabs, each pane its own element with its own ProfileLoader, and one
* ShareButton sharing THIS composite. Nothing here is poster-specific; the two tabs are the two profiles
* poster-styles and poster-sizes, each also embeddable alone.
*/
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import ViewSelect from '@map/components/ViewSelect.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const t = useOptionalT('')
const cfg = inject('profileConfig', ref(null))
const uiStore = inject('uiStore', null)
const isDark = computed(() => !!uiStore?.isDark)
const TAG = 'poster-design'
const instanceId = computed(() => cfg.value?.instanceId || 'poster-design-mfe')
const spec = computed(() => ({
id: 'poster-design-mfe',
tag: TAG,
config: { instanceId: instanceId.value },
// ORDER LIVES IN at[0]: buildPaneColumn sorts by it while ViewSelect renders this array as written,
// so the two must agree.
panes: [
{ id: 'styles', label: t('posterDesign.tab.styles'), at: [0, 0], config: { profile: 'poster-styles' } },
{ id: 'sizes', label: t('posterDesign.tab.sizes'), at: [1, 0], config: { profile: 'poster-sizes' } },
],
paneHint: false,
}))
const stage = ref(null)
const view = ref(cfg.value?.view === 'sizes' ? 'sizes' : 'styles')
let column = null
let offScroll = null
// Page-level keys a pane inherits from this element's host, by KEY LIST (a pane's own profile and
// instanceId always win, since pane.config is spread last).
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'tokenUrl', 'dataSource', 'locale', 'lang', 'isDark', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)
const host = cfg.value || {}
const inherited = {}
for (const k of INHERITED_PAGE_KEYS) if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
el.setAttribute('profile-config', JSON.stringify({ ...inherited, ...spec.value.config, ...pane.config }))
return el
}
// `inert` on the pane the reader is not looking at, as the other two-tab composites do: the panes are
// stacked in one scroll column, so the hidden one is still full-size geometry.
function markActivePane(activeId) {
if (!column || !activeId) return
for (const pane of column.panes) {
const el = column.mountEl(pane.id)
if (!el) continue
if (pane.id === activeId) el.removeAttribute('inert')
else el.setAttribute('inert', '')
}
}
function go(id) {
view.value = id
column?.scrollTo(id)
markActivePane(id)
}
onMounted(() => {
if (!stage.value) return
column = buildPaneColumn(spec.value, { host: stage.value })
if (!column) return
for (const pane of column.panes) if (pane.config) column.mountEl(pane.id)?.appendChild(paneElement(pane))
markActivePane(column.current() || view.value)
offScroll = column.onScroll(() => {
const cur = column.current()
if (cur && cur !== view.value) view.value = cur
if (cur) markActivePane(cur)
})
if (view.value !== 'styles') go(view.value)
})
onBeforeUnmount(() => { offScroll?.(); column?.destroy(); column = null })
const SHARE_EMBED_PATH = 'poster-design/poster-design-mfe/index.html'
useShadowStyles(PANE_GRID_STYLES + `
:host{ display:block; width:100%; height:100%; min-height:420px; }
.pdmfe{
--ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;
--ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;
--header:56px;
position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;
background:var(--ui-bg); color:var(--ui-text);
font:14px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
}
.pdmfe .hdr{
flex:0 0 var(--header); height:var(--header); position:relative;
display:flex; align-items:center; justify-content:flex-end; padding:0 12px;
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
}
.viewsel{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
.pdmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }
`, 'poster-design-mfe')
</script>
<template>
<div class="pdmfe">
<header class="hdr">
<ViewSelect :views="spec.panes" :current="view" :legend="t('tabs.selectView')" :label="t('tabs.views')" @select="go" />
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
</header>
<div class="stage" ref="stage"></div>
</div>
</template>

View file

@ -0,0 +1,5 @@
{
"type": "single",
"label": "Poster Design — Sizes",
"note": "Every poster size from the shared size table (library/constants/posterSizes.js), each as a print preview; also embeddable alone."
}

View file

@ -0,0 +1,35 @@
<script setup>
/**
* poster-sizes.vue: every poster size, each card a print preview at that size.
*
* @element poster-design
* @description Every poster size the print picker offers (standard paper up to 5 × 8 ft), each drawn as a
* print preview, portrait only, from the one shared size table.
*
* The sizes come from the shared size table in library/constants/posterSizes.js and nowhere else: a
* second list here would drift from the print picker. Until that table carries the size ladder, this
* tab says so rather than inventing one.
*/
import { computed } from 'vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
const t = useOptionalT('')
const heading = computed(() => t('posterDesign.sizes.heading'))
const waiting = computed(() => t('posterDesign.sizes.waiting'))
useShadowStyles(`
:host{display:block;width:100%;height:100%;min-height:420px}
.pd-sizes{position:absolute;inset:0;overflow-y:auto;padding:28px 20px;background:#f3f3f1;color:#3b463d;
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif}
.pd-sizes__h{margin:0 0 8px;font-size:22px;font-weight:700}
.pd-sizes__note{margin:0;max-width:60ch;color:#6d766e}
`, 'poster-design-sizes')
</script>
<template>
<div class="pd-sizes">
<h2 class="pd-sizes__h">{{ heading }}</h2>
<p class="pd-sizes__note">{{ waiting }}</p>
</div>
</template>

View file

@ -0,0 +1,5 @@
{
"type": "single",
"label": "Poster Design — Styles",
"note": "Every poster template, one card per configuration (src/templates.js), drawn from the library with sample data; also embeddable alone."
}

View file

@ -0,0 +1,151 @@
<script setup>
/**
* poster-styles.vue: every poster template the library prints, one card per configuration.
*
* @element poster-design
* @description Every poster template in one grid (map sheets, the prayer packet, prayer cards, the wall
* panels, the map print), each card drawn from the library with sample data and named by its
* configuration, with "Show full details" for what an agent needs to reuse it.
*
* The cards ARE the poster wall's cards: this profile renders the library's PosterWall and changes two
* things through its host seams. headerAction turns the card's "Show on map" sticker into "Show full
* details" (there is no map here), and previewSrc draws each thumbnail from the library at view time
* instead of reading a stored image, so a change to a template shows up here the next time it opens.
* The catalogue itself is src/templates.js.
*/
import { computed, inject, ref } from 'vue'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import countryData from '@map/data/country-posters.json'
import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js'
import FullDetailsDialog from './src/FullDetailsDialog.vue'
const profileConfig = inject('profileConfig', ref(null))
const instanceId = computed(() => profileConfig.value?.instanceId || 'poster-design-styles')
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
const t = useOptionalT('')
const configs = listConfigurations()
const byName = new Map(configs.map((c) => [c.name, c]))
// A card's title: the template's name, then its size. Card stocks name the stock the way the wall's own
// stock picker does (the noun translates, the size is printed as a printer's box prints it).
function stockLabel(s) {
if (!s.noun) return s.label
const noun = s.noun === 'postcard' ? t('wall.stockPostcard') : t('wall.stockCard')
const dims = `${s.w} × ${s.h} in`
return s.side ? `${noun} ${dims} (${t('wall.stockLandscape')})` : `${noun} ${dims}`
}
const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${c.size}`)
// No `count`: these are templates, not lists of people, so the sticker reads "Show full details" alone.
const items = computed(() => configs.map((c) => ({ id: c.name, label: labelOf(c) })))
const groupBy = (item) => t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet)
// ── drawing a configuration from the library ──────────────────────────────────────────────────
const sample = (countryData.countries || []).find((c) => c.id === SAMPLE_COUNTRY_ID)
const recordsFor = createPeopleGroupRecords({
apiBase: () => profileConfig.value?.apiBaseUrl || getApiBaseUrl(),
lang: () => getRuntimeLocale() || detectLocale(),
profileConfig,
})
// The same call the country wall makes for a country's sheet, with the series facts of the sample's tier.
async function countrySheet(format) {
if (!sample) return ''
const [{ generateCountrySheet }, { ISO3_NAME }, records] = await Promise.all([
import('@map/packet/posterSheets.js'), import('@map/data/iso3-name.js'),
recordsFor({ type: 'country', values: [sample.iso3] }),
])
const tier = (countryData.countries || []).filter((x) => x.sheet === sample.sheet)
const series = { totalGroups: tier.reduce((a, x) => a + (x.count || 0), 0), totalLists: tier.length, index: tier.findIndex((x) => x.id === sample.id) + 1 }
return generateCountrySheet({ country: sample, records, assetsBase: assetsBase.value, ISO3_NAME, format, series })
}
const ctx = { countrySheet }
// The printed document's size, measured when it is drawn: what "file size" means in the details.
const fileBytes = ref({})
async function renderDocument(name) {
const c = byName.get(name)
if (!c || !c.render) return ''
const html = await c.render(ctx)
if (html) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
return html || ''
}
// previewSrc: the thumbnail, rasterised from the drawn document at a quarter scale (a 1500px sheet
// becomes ~375px, the card's size). A render that comes out blank is refused, so the card shows
// "preview unavailable" rather than a black rectangle.
async function previewSrc(item) {
const html = await renderDocument(item.id)
if (!html) return null
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(html, { scale: 0.25 })
if (!(await looksRendered(blob))) return null
return URL.createObjectURL(blob)
}
// The card's print button prints THIS configuration: the format the wall asks for is ignored on
// purpose, because the card already names its size.
const sheet = (item) => renderDocument(item.id)
// ── "Show full details" ───────────────────────────────────────────────────────────────────────
const open = ref(null)
const openLabel = computed(() => (open.value ? labelOf(open.value) : ''))
function showDetails(item) { open.value = byName.get(item.id) || null }
const heading = computed(() => t('posterDesign.styles.heading'))
// PosterWall renders the intro as HTML, so plain catalogue text is escaped first.
const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const intro = computed(() => escHtml(t('posterDesign.styles.intro')))
// Child components' <style> blocks are dropped by defineCustomElement, so this profile carries the
// dialog's CSS too. The floor sits on :host, where a shell that sizes its pane can override it.
useShadowStyles(`
:host{display:block;width:100%;height:100%;min-height:420px}
.pd-styles{position:absolute;inset:0;min-height:0}
.pd-dialog{box-sizing:border-box;border:1px solid #d1d4cd;border-radius:12px;padding:20px 22px;max-width:min(560px,calc(100vw - 32px));
width:100%;color:#3b463d;background:#fff;font:14px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
box-shadow:0 12px 40px rgba(8,12,16,.25)}
.pd-dialog::backdrop{background:rgba(8,12,16,.55)}
.pd-dialog__title{margin:0 0 14px;font-size:18px;font-weight:700;line-height:1.3}
.pd-dialog__list{display:grid;grid-template-columns:max-content 1fr;gap:8px 16px;margin:0}
.pd-dialog__list dt{color:#6d766e;font-weight:600}
.pd-dialog__list dd{margin:0;min-width:0;overflow-wrap:anywhere}
.pd-dialog__list code{font:12.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;background:#f3f3f1;border-radius:4px;padding:1px 5px}
.pd-dialog__name{font-weight:700}
.pd-dialog__copy,.pd-dialog__close{font:inherit;border-radius:8px;border:1px solid #d1d4cd;background:#f3f3f1;color:#3b463d;
padding:4px 10px;cursor:pointer;margin-inline-start:8px}
.pd-dialog__note{margin:14px 0 0;color:#6d766e}
.pd-dialog__agent{margin:14px 0 0;padding:10px 12px;border-radius:8px;background:#f3f3f1;border-inline-start:4px solid #92b195}
.pd-dialog__actions{display:flex;justify-content:flex-end;margin:16px 0 0}
.pd-dialog__close{margin:0;background:#92b195;border-color:#92b195;color:#fff;font-weight:600;padding:6px 16px}
@media (max-width:480px){.pd-dialog__list{grid-template-columns:1fr}.pd-dialog__list dd{margin-bottom:6px}}
`, 'poster-design-styles')
</script>
<template>
<div class="pd-styles">
<PosterWall
:items="items"
:group-by="groupBy"
:instance-id="instanceId"
:assets-base="assetsBase"
:heading="heading"
:intro="intro"
unit=""
:collapsible="true"
:header-action="{ labelKey: 'posterDesign.showFullDetails', ariaKey: 'posterDesign.showFullDetailsAria' }"
:preview-src="previewSrc"
:sheet="sheet"
:hide-prayer-list-when-soon="true"
download-prefix="doxa-poster-design"
@header-action="showDetails"
/>
<FullDetailsDialog :config="open" :label="openLabel" :file-bytes="open ? (fileBytes[open.name] ?? null) : null" @close="open = null" />
</div>
</template>

View file

@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* check-catalogue.mjs (check:poster-design-catalogue): Poster Design's configuration catalogue
* (plugin-designs/poster-design/src/templates.js) says only true things about the library.
*
* 1. SHEET_FORMATS has exactly the formats poster-render.js FORMATS defines, at the same pixel size.
* The generator is served as a pinned asset, not bundled, so the catalogue cannot import the table;
* this is what keeps the two from drifting.
* 2. Every configuration name is unique and shaped like a name (lowercase words, dots, dashes, digits):
* a name is what a person hands an agent, so two cards must never share one.
* 3. The card entries are exactly card-render.js STOCK, one per stock.
* 4. Every catalogue key an entry names (labelKey, missing, the family keys) exists in the English
* posterDesign catalogue.
* 5. The sample country exists in the country data and can be drawn (has geometry, a sheet tier).
* A catalogue that lists nothing FAILS (vacuous guard).
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const failures = []
const fail = (m) => failures.push(m)
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 cat, STOCK
try {
cat = await server.ssrLoadModule('/plugin-designs/poster-design/src/templates.js')
;({ STOCK } = await server.ssrLoadModule('/library/packet/card-render.js'))
} finally { await server.close() }
const configs = cat.listConfigurations()
if (!configs.length) fail('vacuous: listConfigurations() returned nothing')
// 1. formats against the generator's own table
const src = readFileSync(join(ROOT, 'library/packet/poster-render.js'), 'utf8')
const block = (src.match(/const FORMATS = \{([\s\S]*?)\n\};?/) || [])[1]
if (!block) fail('poster-render.js: could not find "const FORMATS = {"; update this check')
else {
const found = {}
for (const m of block.matchAll(/^\s*'?([\w]+)'?\s*:\s*\{\s*w:\s*(\d+),\s*h:\s*(\d+)/gm)) found[m[1]] = [Number(m[2]), Number(m[3])]
const names = Object.keys(found)
if (names.length < 3) fail(`poster-render.js FORMATS: parsed only ${names.length} format(s) (${names.join(', ')}); the parser no longer matches`)
for (const k of names) {
const mine = cat.SHEET_FORMATS[k]
if (!mine) fail(`FORMATS has '${k}' and the catalogue does not`)
else if (mine.px[0] !== found[k][0] || mine.px[1] !== found[k][1]) fail(`'${k}': catalogue says ${mine.px.join('×')}, FORMATS says ${found[k].join('×')}`)
}
for (const k of Object.keys(cat.SHEET_FORMATS)) if (!found[k]) fail(`the catalogue has format '${k}', which FORMATS does not`)
}
// 2. names
const seen = new Set()
for (const c of configs) {
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/i.test(c.name)) fail(`'${c.name}' is not a configuration name (words joined by . or -)`)
if (seen.has(c.name)) fail(`two configurations are named '${c.name}'`)
seen.add(c.name)
}
// 3. stocks
const stockNames = configs.filter((c) => c.family === 'cards').map((c) => c.settings.stock).sort()
const want = Object.keys(STOCK).sort()
if (JSON.stringify(stockNames) !== JSON.stringify(want)) fail(`card configurations ${JSON.stringify(stockNames)} are not card-render STOCK ${JSON.stringify(want)}`)
// 4. keys exist in English
const en = JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales/en/posterDesign.json'), 'utf8'))
const has = (key) => {
const parts = String(key).split('.')
if (parts.shift() !== 'posterDesign') return false
let o = en
for (const p of parts) { if (!o || typeof o !== 'object' || !(p in o)) return false; o = o[p] }
return typeof o === 'string'
}
const keys = [...configs.flatMap((c) => [c.labelKey, c.missing].filter(Boolean)), ...Object.values(cat.FAMILY_KEYS)]
for (const k of keys) if (!has(k)) fail(`catalogue key '${k}' is not in en/posterDesign.json`)
for (const c of configs) if (!cat.FAMILY_KEYS[c.family]) fail(`'${c.name}' has family '${c.family}', which has no heading key`)
// 5. the sample
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8'))
const s = (data.countries || []).find((c) => c.id === cat.SAMPLE_COUNTRY_ID)
if (!s) fail(`sample '${cat.SAMPLE_COUNTRY_ID}' is not in library/data/country-posters.json`)
else if (!s.hasGeometry || !s.sheet || s.unsupportedReason) fail(`sample '${s.id}' cannot be drawn (hasGeometry ${s.hasGeometry}, sheet ${s.sheet})`)
if (failures.length) {
console.error(`POSTER-DESIGN-CATALOGUE: FAIL (${failures.length})\n - ${failures.join('\n - ')}`)
process.exitCode = 1
} else {
console.log(`POSTER-DESIGN-CATALOGUE: PASS — ${configs.length} configurations, ${Object.keys(cat.SHEET_FORMATS).length} sheet formats matching poster-render FORMATS, ${want.length} card stocks from STOCK, ${keys.length} catalogue keys present, sample ${cat.SAMPLE_COUNTRY_ID}`)
}

View file

@ -0,0 +1,83 @@
<script setup>
/**
* FullDetailsDialog.vue: "Show full details" for one poster configuration.
*
* Everything a person needs to name the template to an agent, or an agent needs to find it: the
* configuration name (the stable id), what it is, its size, the size of the file it prints, where its
* renderer lives, its settings and its sample data.
*
* A NATIVE <dialog> opened with showModal(), so it sits in the browser's TOP LAYER. A fixed scrim inside
* this element would be captured by a host's containing block (VerticalTabsLayout sets contain:layout on
* its content pane) and would fight the shell's z-index ladder; the top layer is above both, and brings
* Escape-to-close and focus return with it.
*/
import { ref, watch, nextTick, computed } from 'vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const props = defineProps({
// One entry of listConfigurations(), or null when closed.
config: { type: Object, default: null },
// The printed document's size in bytes, once it has been drawn; null until then.
fileBytes: { type: Number, default: null },
label: { type: String, default: '' },
})
const emit = defineEmits(['close'])
const t = useOptionalT('')
const dlg = ref(null)
watch(() => props.config, async (c) => {
await nextTick()
const d = dlg.value
if (!d) return
if (c && !d.open) d.showModal?.()
else if (!c && d.open) d.close()
})
// A number with its unit, or an identifier, inside Arabic text is a left-to-right RUN: without an
// isolate the bidi algorithm reorders it ("KB 888", a configuration name split at its dots).
// LRI … PDI (U+2066 … U+2069) keeps the run whole in any locale and changes nothing in an LTR one.
const ltr = (s) => `\u2066${s}\u2069`
const fileSize = computed(() => {
const b = props.fileBytes
if (b == null) return t('posterDesign.details.fileSizeUnknown')
return ltr(b >= 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1024))} KB`)
})
const settings = computed(() => {
const s = props.config?.settings || {}
return Object.keys(s).length ? Object.entries(s).map(([k, v]) => `${k}: ${v}`).join(' · ') : '—'
})
const agentLine = computed(() => (props.config ? t('posterDesign.details.forAgents', { name: ltr(props.config.name) }) : ''))
async function copyName() {
try { await navigator.clipboard.writeText(props.config.name) } catch (_) { /* the name stays selectable */ }
}
</script>
<template>
<dialog ref="dlg" class="pd-dialog" aria-labelledby="pd-dialog-title" @close="emit('close')" @cancel="emit('close')">
<template v-if="config">
<h2 id="pd-dialog-title" class="pd-dialog__title">{{ label }}</h2>
<dl class="pd-dialog__list">
<dt>{{ t('posterDesign.details.configName') }}</dt>
<dd><code dir="ltr" class="pd-dialog__name">{{ config.name }}</code>
<button type="button" class="pd-dialog__copy" @click="copyName">{{ t('posterDesign.details.copyName') }}</button></dd>
<dt>{{ t('posterDesign.details.size') }}</dt>
<dd dir="ltr">{{ config.size }}<span v-if="config.px"> · {{ config.px[0] }} × {{ config.px[1] }} px</span></dd>
<dt>{{ t('posterDesign.details.fileSize') }}</dt>
<dd>{{ fileSize }}</dd>
<dt>{{ t('posterDesign.details.settings') }}</dt>
<dd dir="ltr">{{ settings }}</dd>
<dt>{{ t('posterDesign.details.sample') }}</dt>
<dd dir="ltr">{{ config.sample || '—' }}</dd>
<dt>{{ t('posterDesign.details.renderer') }}</dt>
<dd dir="ltr"><code>{{ config.renderer }}</code></dd>
</dl>
<p v-if="config.legacy" class="pd-dialog__note">{{ t('posterDesign.details.legacy') }}</p>
<p v-if="config.missing" class="pd-dialog__note">{{ t(config.missing) }}</p>
<p class="pd-dialog__agent">{{ agentLine }}</p>
<form method="dialog" class="pd-dialog__actions">
<button type="submit" class="pd-dialog__close">{{ t('buttons.close') }}</button>
</form>
</template>
</dialog>
</template>

View file

@ -0,0 +1,109 @@
/**
* templates.js: every poster template the library can print, one entry per CONFIGURATION.
*
* A configuration is one template at one setting (a map sheet at 3 × 3, a packet in its lightweight
* variant, a card deck on postcard stock). Its NAME is the stable id a person gives an agent ("use
* map-sheet.country.3x3"), so a name is never renamed once published; a retired configuration keeps
* its name and is marked legacy.
*
* Each entry says where its renderer lives in library/ and, when the gallery can draw it, how:
* render(ctx) returns the template's HTML document drawn from the LIBRARY with the sample data, so a
* library change shows up in the gallery the next time it opens. Entries the gallery cannot draw yet
* say so (render: null) and the card shows the wall's "preview unavailable" placeholder; the details
* dialog states which part is missing, rather than a picture of something else.
*
* Where the library already has the list (card stocks), it is DERIVED here, never retyped.
*/
import { STOCK } from '@map/packet/card-render.js'
// 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.
export const SAMPLE_COUNTRY_ID = 'country-npl'
// The library's poster formats (library/packet/poster-render.js FORMATS). The generator is served as a
// pinned asset, not bundled, so its table cannot be imported here; check:poster-design-catalogue reads
// FORMATS from the source and fails when the two disagree.
export const SHEET_FORMATS = {
'3x3': { page: '3 × 3', px: [1500, 1500] },
'3x4': { page: '3 × 4', px: [1500, 2000], legacy: true },
letter: { page: 'Letter, portrait', px: [1500, 1941] },
packet: { page: 'Letter, portrait (packet page 1)', px: [1500, 1941] },
}
// Families are the sections of the Styles tab, in reading order. The keys are written out, never
// assembled, so the catalogue checks and the i18n area build can see each one.
export const FAMILY_KEYS = {
mapSheet: 'posterDesign.family.mapSheet',
packet: 'posterDesign.family.packet',
cards: 'posterDesign.family.cards',
wall: 'posterDesign.family.wall',
mapPrint: 'posterDesign.family.mapPrint',
}
function sheetEntry(format) {
const f = SHEET_FORMATS[format]
return {
name: format === 'packet' ? 'map-sheet.packet-page' : `map-sheet.country.${format}`,
family: 'mapSheet',
labelKey: format === 'packet' ? 'posterDesign.tpl.mapSheetPacketPage' : 'posterDesign.tpl.mapSheetCountry',
size: f.page,
px: f.px,
legacy: !!f.legacy,
renderer: 'library/packet/posterSheets.js generateCountrySheet → library/packet/poster-render.js',
settings: { format },
sample: SAMPLE_COUNTRY_ID,
render: (ctx) => ctx.countrySheet(format),
}
}
/** @returns {Array<object>} every configuration, in the gallery's order. */
export function listConfigurations() {
return [
...Object.keys(SHEET_FORMATS).map(sheetEntry),
...['full', 'lightweight'].map((variant) => ({
name: `packet.${variant}`,
family: 'packet',
labelKey: variant === 'full' ? 'posterDesign.tpl.packetFull' : 'posterDesign.tpl.packetLightweight',
size: 'Letter, portrait',
renderer: 'library/packet/packet-render.js buildPacket (VARIANTS)',
settings: { variant },
sample: SAMPLE_COUNTRY_ID,
render: null,
missing: 'posterDesign.missing.packet',
})),
...Object.entries(STOCK).map(([id, s]) => ({
name: `cards.${id}`,
family: 'cards',
labelKey: 'posterDesign.tpl.cards',
stock: s,
size: `${s.w} × ${s.h} in`,
renderer: 'library/packet/card-render.js createCardRenderer (STOCK)',
settings: { stock: id },
sample: SAMPLE_COUNTRY_ID,
render: null,
missing: 'posterDesign.missing.cards',
})),
{
name: 'wall.bless-panels',
family: 'wall',
labelKey: 'posterDesign.tpl.blessPanels',
size: '5 × 8 ft, 3 panels',
renderer: 'plugin-designs/wall-of-people-groups/src/blessPoster.js (not in library/ yet)',
settings: {},
sample: null,
render: null,
missing: 'posterDesign.missing.bless',
},
{
name: 'map-print.dialog',
family: 'mapPrint',
labelKey: 'posterDesign.tpl.mapPrint',
size: 'library/constants/posterSizes.js',
renderer: 'library/components/poster/PosterDialog.vue + library/composables/useMapPoster.js',
settings: {},
sample: null,
render: null,
missing: 'posterDesign.missing.mapPrint',
},
]
}