1448 lines
97 KiB
Vue
1448 lines
97 KiB
Vue
<script setup>
|
||
/**
|
||
* PosterWall.vue — THE SHARED POSTER WALL.
|
||
*
|
||
* A grid of poster cards styled as posters taped to a wall, grouped into
|
||
* collapsible sections. Extracted from my-upg-100-list/poster-wall.vue so that
|
||
* every poster app renders from ONE component.
|
||
*
|
||
* The design: application PROFILES are duplicated per app, while the shared poster wall
|
||
* is ONE parameterized library component. An app picks its template and supplies its
|
||
* data (the posters on its wall); the reusable work lives in the library, which exists
|
||
* for parameterized components like this one.
|
||
*
|
||
* So: PROFILES duplicate, COMPONENTS get promoted here and parameterized.
|
||
*
|
||
* WHAT IS PARAMETERIZED
|
||
* items — the posters. Each { id, label, count, bbox? }.
|
||
* groupBy — (item) => section name. The caller owns its own taxonomy.
|
||
* collapsible — per-section expand/collapse.
|
||
* collapsedBy — (section) => bool, the INITIAL state. Lets a caller open the
|
||
* dense sections and fold the long tail without hard-coding.
|
||
* assetsBase — where previews/posters are served from.
|
||
* eventName — the window CustomEvent this wall dispatches for "show on map".
|
||
* heading / tag / intro / unit / itemNoun — the copy.
|
||
*
|
||
* WHAT IS NOT
|
||
* Sorting. Sections AND items are ALWAYS ordered by descending count. Both apps
|
||
* want the densest prayer need to lead, and on the country wall it is load-
|
||
* bearing: 92 of 175 countries have exactly ONE people group, so without this
|
||
* the wall opens on a wall of single-entry cards.
|
||
*
|
||
* COLLAPSIBILITY IS NOT COSMETIC. A country wall has far too many countries to scroll
|
||
* through one by one, so sections collapse. Measured:
|
||
* Europe alone is 27 countries averaging 2 groups each.
|
||
*
|
||
* CROSS-PANE COMMS — this wall NEVER imports a map. It dispatches a window
|
||
* CustomEvent scoped by instanceId, so several embeds on one page never cross-talk.
|
||
*/
|
||
import { computed, ref, watch, onBeforeUnmount, onMounted, onUpdated, provide, toRefs } from 'vue'
|
||
import PosterSection from './PosterSection.vue'
|
||
import { POSTER_WALL_CTX } from './posterWallContext.js'
|
||
import { tStatic } from '../../i18n/index.js'
|
||
|
||
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
|
||
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in.
|
||
const T = (key, en) => tStatic(key) || en
|
||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||
import { fetchPosterRaster, fetchPosterSource } from '@map/packet/poster-fetch.js'
|
||
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
|
||
import { keyedError, errorText } from '@map/i18n/keyedError.js'
|
||
import { useOptionalI18n, useOptionalT } from '@map/composables/useOptionalT.js'
|
||
// Export-modal geometry. A fixed;inset:0 scrim covers only the CONTENT PANE in the cap-5 shell
|
||
// (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable
|
||
// behind the "modal". See library/utils/viewportScrim.js for the measurements and rejected fixes.
|
||
import { useViewportScrim } from '@map/utils/viewportScrim.js'
|
||
import { bundleTreeRoot } from '@map/utils/embedBase.js'
|
||
|
||
const props = defineProps({
|
||
items: { type: Array, required: true },
|
||
groupBy: { type: Function, default: () => 'All' },
|
||
instanceId: { type: String, default: 'poster-wall' },
|
||
isDark: { type: Boolean, default: false },
|
||
assetsBase: { type: String, default: './assets' },
|
||
eventName: { type: String, default: 'doxa-poster-wall:show-on-map' },
|
||
heading: { type: String, default: 'Prayer Lists & Posters' },
|
||
tag: { type: String, default: '' },
|
||
intro: { type: String, default: '' },
|
||
// "92 UPG" on a card header — the unit travels with the number so the header
|
||
// states what it counts.
|
||
unit: { type: String, default: 'UPG' },
|
||
itemNoun: { type: String, default: 'list' },
|
||
// English plurals are not "+s". A naive pluralizer shipped "28 countrys" to the
|
||
// section headers, so the plural is a PARAMETER — the caller knows its own noun.
|
||
// ⚠ No "+s" default any more: a noun pair is English-only by construction.
|
||
itemNounPlural: { type: String, default: '' },
|
||
// ★ THE LOCALIZED WAY TO COUNT ITEMS: a catalogue PLURAL key ("{n} country | {n} countries" in en,
|
||
// four forms in ru, six in ar, one in zh), rendered with the count so each locale picks its own CLDR
|
||
// form. Wins over itemNoun/itemNounPlural, which remain for callers that have not moved yet.
|
||
itemCountKey: { type: String, default: '' },
|
||
collapsible: { type: Boolean, default: false },
|
||
collapsedBy: { type: Function, default: () => false },
|
||
// A section's people-group total, shown in its header and used to order the sections (largest first).
|
||
// Default: the sum of its cards' counts. A caller whose cards overlap overrides it, so a group is counted
|
||
// once: the Country Posters India section holds India's own card AND one card per state, the same 855 groups.
|
||
sectionTotal: { type: Function, default: null },
|
||
// A card may declare itself unsupported (India: 855 groups do not fit a sheet).
|
||
// The caller supplies the reason; the wall renders it instead of a poster.
|
||
unsupported: { type: Function, default: () => null },
|
||
downloadPrefix: { type: String, default: 'doxa' },
|
||
// What "Prayer List" says while it is unbuilt. PARAMETERIZED because the unit
|
||
// differs per app: the 100-list generates a list per PEOPLE GROUP, the country
|
||
// app one per COUNTRY. A shared hardcoded string told country users the wrong
|
||
// thing. The message must say that a prayer list is generated for each item the
|
||
// app lists (each country, on the country app).
|
||
prayerListSoonMessage: { type: String,
|
||
default: 'Coming soon — the prayer list for each people group is in build.' },
|
||
// ★ RECORDS PROVIDER — the seam that makes the print actions shareable.
|
||
// The PRINT ACTIONS are common to every wall; the RECORD FETCHING is not. The
|
||
// 100-list resolves its rows through the people-groups API, the selector
|
||
// evaluator and a pg_slugs special case; Key Cities and Country Posters have
|
||
// entirely different data shapes. So a bundle that CAN supply rows passes this
|
||
// and gets the list + card-deck prints; one that cannot simply omits it and
|
||
// those buttons stay hidden, rather than failing at click time.
|
||
records: { type: Function, default: null },
|
||
// () => the ISO3 -> English name table the host's map sheets were generated from (a loader, so a
|
||
// bundle can keep it lazy). With it, a packet's page-1 map names its countries in the packet's
|
||
// language; without it the sheet prints as drawn. usePosterPrints' iso3Names.
|
||
iso3Names: { type: Function, default: null },
|
||
// async (item) => an image URL of this item's map that the host GENERATES (a live map raster), used
|
||
// for the packet's page 1 before any stored sheet. usePosterPrints' mapImage.
|
||
mapImage: { type: Function, default: null },
|
||
// async (item, format) => a map SHEET generated now (packet/posterSheets.js); tried first for the poster
|
||
// print and the packet's page 1. usePosterPrints' sheet.
|
||
sheet: { type: Function, default: null },
|
||
// Card stock for the deck print, when this wall offers one. See card-render.js
|
||
// STOCK — both orientations are available. This is the DEFAULT selection.
|
||
cardStock: { type: String, default: 'poker' },
|
||
// LET THE READER CHOOSE THE STOCK. `cardStock` alone fixes one size for the whole
|
||
// wall, which is right for country-posters and key-cities; my-upg-100-list offers a
|
||
// picker, and that was the one capability keeping it on its own 888-line copy of
|
||
// this component rather than consuming it.
|
||
// Pass true for every stock card-render.js supports, or an array of stock ids to
|
||
// offer a subset. Default false = today's behaviour exactly: no picker, cardStock
|
||
// is used as-is, so the two migrated consumers are untouched.
|
||
// ⚠ The LABELS come from card-render.js STOCK, never hand-written here. The
|
||
// 100-list carried its own DECK_STOCKS list duplicating all five labels, with a
|
||
// comment saying they "come from the renderer's own STOCK table so this list cannot
|
||
// drift" — which was the intent, not the implementation. Deriving them makes it true.
|
||
cardStocks: { type: [Boolean, Array], default: false },
|
||
// ★ DOWNLOAD OR PRINT? A PRODUCT RULING, NOT A PREFERENCE — and the reason the
|
||
// 100-list could not simply adopt this component.
|
||
// Default 'download' is what country-posters and key-cities do today and keeps
|
||
// them byte-for-byte unchanged. 'print' opens the OS print dialog on the poster
|
||
// instead, which is what my-upg-100-list/poster-wall.vue:280 records as the
|
||
// brief: three print buttons, each opening an in-browser print dialog, and no
|
||
// silent file download as the default action (a raw 'Download' button and a
|
||
// 'Prayer List: coming soon' placeholder were the two rejected). Printing to PDF
|
||
// from that dialog covers the get-a-file need without a fourth competing button.
|
||
// Migrating the 100-list onto this component WITHOUT this prop would have
|
||
// reintroduced both rejected buttons — the capability gap was never the whole
|
||
// blocker; the shared component still encoded the pre-brief design.
|
||
// ★ DEFAULT 'print': an export-poster button opens the PRINT DIALOG with a preview, never a direct
|
||
// download. country-posters and key-cities set nothing and downloaded.
|
||
// 'download' is an explicit opt-in, and a consumer that opts in must say why in a comment carrying
|
||
// `posterAction-download:` (check:poster-action-print). Multi-page PDF packets and CSV exports are not
|
||
// poster buttons and stay downloads.
|
||
posterAction: { type: String, default: 'print' }, // 'print' | 'download'
|
||
// ★ IS THE PREVIEW ITSELF A CONTROL? Default 'none' keeps the image inert, which is
|
||
// what country-posters and key-cities render today. 'print' makes clicking the
|
||
// poster preview open it for printing (the poster shown in the browser to help
|
||
// print it), and the LAST capability the my-upg-100-list
|
||
// migration had not carried across.
|
||
// ⚠ WHEN IT IS A CONTROL IT MUST BE A KEYBOARD CONTROL. The original carried
|
||
// role="button", tabindex="0" and Enter/Space handlers alongside the click; an
|
||
// <img> with only @click is unreachable without a mouse, and re-adding the click
|
||
// alone would be a half-restoration that LOOKS complete. The alt and title change
|
||
// with the mode too, because "prayer poster" and "click to preview and print" are
|
||
// different promises to a screen reader.
|
||
previewAction: { type: String, default: 'none' }, // 'none' | 'print'
|
||
// Hide the Prayer List button entirely, rather than showing it as a
|
||
// "coming soon" placeholder. The placeholder is honest where the feature is
|
||
// genuinely unbuilt, and it is the OTHER rejected button on a wall
|
||
// where the list DOES print — so a wall with a records provider can suppress it.
|
||
hidePrayerListWhenSoon: { type: Boolean, default: false },
|
||
// EMBEDDED mode (added 2026-09-24 for the regions-tab poster dropdown). The wall's root is
|
||
// `position:absolute;inset:0` because in country-posters and my-upg-100-list it IS a pane and
|
||
// fills it. Dropped inside another component — a region card, say — that absolute root covers
|
||
// the whole element: MEASURED at 1440x1100 painted over the region's country list, which no
|
||
// assertion caught and the screenshot did. Embedded returns it to normal flow and drops the
|
||
// pane background so it inherits its host's. Default false, so both existing panes are
|
||
// byte-identical.
|
||
embedded: { type: Boolean, default: false },
|
||
// Section heads carry the group name and its totals. A caller that has ALREADY named the
|
||
// group in its own chrome (the region card's header and summary) would otherwise repeat it,
|
||
// and with a single unnamed group the head renders the literal "Other". Default true.
|
||
sectionHeads:{ type: Boolean, default: true },
|
||
// ★ HOW MANY POSTERS SIT IN A ROW. The regions posters want two per row, with a setting to raise
|
||
// the number.
|
||
// 0 keeps the wall's existing auto-fill (repeat(auto-fill, minmax(210px, 1fr))), so the two
|
||
// panes that already ship — country-posters and my-upg-100-list — are byte-identical unless
|
||
// their owner opts in. A positive number fixes the track count instead.
|
||
columns: { type: Number, default: 0 },
|
||
// And the control that changes it, off by default for the same reason. Only the caller that
|
||
// asked for a fixed count wants a knob for it.
|
||
columnsControl: { type: Boolean, default: false },
|
||
// The narrowest card this wall will lay out before it drops a column. 0 (the default) means
|
||
// CARD_MIN_PX, 210 -- so every wall that does not set it lays out exactly as before. A wall whose
|
||
// cards are thumbnails can set it lower so a phone keeps two per row; see the clamp below for why
|
||
// that is safe now.
|
||
minCardPx: { type: Number, default: 0 },
|
||
// ★ WHAT THE CARD'S HEADER STICKER DOES. null (the default) is today's "Show on map" toggle,
|
||
// unchanged for every wall that does not set it. A wall with no map beside it (a gallery of
|
||
// templates) passes { labelKey, ariaKey } instead: the sticker then reads t(labelKey), is a plain
|
||
// button rather than a toggle (no aria-pressed, no is-last highlight), and emits 'header-action'
|
||
// with the item, dispatching no window event. ariaKey gets { label: item.label }. An item with no
|
||
// count (a template, not a list of people) shows the label alone, without the "N UPG ·" prefix.
|
||
headerAction: { type: Object, default: null },
|
||
// ★ WHERE A CARD'S PREVIEW IMAGE COMES FROM. null (the default) is the stored
|
||
// previews/<id>.webp every wall uses today, untouched. A wall whose cards have no stored
|
||
// preview (a gallery of templates drawn from the library, so a library change shows up in it)
|
||
// passes async (item) => an image URL | null. It runs only for cards that come into view, one at
|
||
// a time, cached per page by instanceId + item id; until it answers the card shows its label, and
|
||
// null, a throw or no answer within PREVIEW_TIMEOUT_MS shows the "preview unavailable" placeholder.
|
||
// The page keeps each returned URL and never revokes it (see GENERATED PREVIEWS below).
|
||
previewSrc: { type: Function, default: null },
|
||
// ★ ONE SECTION COUNTED IN ITS OWN WORDS. null (the default) counts every section's cards with item-count-key, as
|
||
// always. A wall whose section holds a different kind of card passes (sec) => an i18n plural key, or { key, n }
|
||
// to count n rather than the section's card count, or null for that section to keep the default. The Country
|
||
// Posters India section holds India's own card and one per state and union territory, which are not countries.
|
||
sectionItemCountKey: { type: Function, default: null },
|
||
// SECTION-ONLY: the wall's own head (heading, tag, intro) is not drawn, only its section(s): a standalone poster
|
||
// section (<dxm-poster-section>). Off by default, so every existing wall renders exactly as before.
|
||
sectionOnly: { type: Boolean, default: false },
|
||
})
|
||
const emit = defineEmits(['header-action'])
|
||
|
||
// The live count, seeded from the prop. A ref rather than the prop directly, because the control
|
||
// changes it and a prop is not ours to write.
|
||
// A stored preference outranks the prop default, because the prop is only what the page suggests
|
||
// before the reader has said otherwise; once they choose, their choice is the default.
|
||
const perRow = ref(readStoredPerRow() ?? (props.columns || PER_ROW_DEFAULT))
|
||
watch(() => props.columns, (n) => { if (readStoredPerRow() == null) perRow.value = n || PER_ROW_DEFAULT })
|
||
// ⚠ PER_ROW_AUTO (0) IS STORED TOO, so "Auto" survives a reload like any other choice. Without this
|
||
// clause the range check below rejects 0, nothing is written, and the next visit silently falls back
|
||
// to the default — a preference that forgets only ONE of its values is worse than one that forgets
|
||
// them all, because the reader cannot tell it is broken.
|
||
watch(perRow, (n) => {
|
||
if (n === PER_ROW_AUTO || (Number.isFinite(n) && n >= PER_ROW_MIN && n <= PER_ROW_MAX)) storePerRow(n)
|
||
})
|
||
// The choices the control offers: a minimum of 1, a maximum of 5, a default of 2; the ceiling is the
|
||
// wall's own auto-fill behaviour, offered as "fit" so the knob can always get back to where it started.
|
||
// ★ AUTO IS 0, AND 0 IS NOT A COUNT — it is the absence of an override. gridStyle already returns
|
||
// {} for a non-positive value, leaving the stylesheet's auto-fill rule in charge, so Auto needed no
|
||
// new branch in the layout: it needed to become REACHABLE from the control and from storage.
|
||
// This extends the earlier ruling (min 2, max 5, default 2) rather than contradicting it: those
|
||
// bound the COUNTS, and Auto is the option that declines to pick one.
|
||
const PER_ROW_AUTO = 0
|
||
// 1 joined the counts 2026-09-30: on a phone the choices are Auto / 1 / 2, and 1 is the one a reader
|
||
// asks for when two posters are too small to read (see perRowChoices below).
|
||
const PER_ROW_MIN = 1, PER_ROW_MAX = 5, PER_ROW_DEFAULT = 2
|
||
// The choice persists in localStorage, so a number the reader sets survives a reload. One key for the
|
||
// whole wall, not per instance: the reader is setting a PREFERENCE, and a preference that only survives on the
|
||
// page you set it on is not one. Every access is wrapped because localStorage THROWS outright in a
|
||
// private window or with site data blocked -- a preference must never be able to break the wall.
|
||
const PER_ROW_KEY = 'doxa-maps:poster-wall:per-row'
|
||
function readStoredPerRow() {
|
||
try {
|
||
const raw = window.localStorage.getItem(PER_ROW_KEY)
|
||
// ⚠ A MISSING KEY MUST NOT READ AS AUTO. getItem returns null when nothing is stored, and
|
||
// Number(null) is 0 — which is exactly PER_ROW_AUTO now that 0 means something. Without this
|
||
// guard every first-time reader would silently get Auto instead of the default, i.e. adding an
|
||
// option would have CHANGED the layout for everyone who never touched the control. Caught by
|
||
// round-tripping the storage values rather than by reading the code.
|
||
if (raw == null || raw === '') return null
|
||
const n = Number(raw)
|
||
if (n === PER_ROW_AUTO) return PER_ROW_AUTO
|
||
return Number.isFinite(n) && n >= PER_ROW_MIN && n <= PER_ROW_MAX ? n : null
|
||
} catch (_) { return null }
|
||
}
|
||
function storePerRow(n) {
|
||
try { window.localStorage.setItem(PER_ROW_KEY, String(n)) } catch (_) { /* unavailable: keep the session value */ }
|
||
}
|
||
// grid-template-columns, or nothing at all when the count is 0 — an empty string leaves the
|
||
// stylesheet's auto-fill rule in charge rather than overriding it with something equivalent.
|
||
// ⚠ THE PREFERENCE IS CLAMPED BY WHAT THE VIEWPORT CAN ACTUALLY HOLD.
|
||
// An inline style beats the stylesheet, so `repeat(2, minmax(0,1fr))` overrode the CSS rule's
|
||
// `minmax(210px,1fr)` at EVERY width — including 390px, where two columns give a ~162px card.
|
||
// Reported from a 390x844 phone shot: two posters per row, where the CSS alone would have
|
||
// given one. The stored preference is the reader's and must survive; it is a preference for
|
||
// DESKTOP density, not an instruction to overflow a phone.
|
||
// 210px is the same minimum the stylesheet's auto-fill uses, so the clamp agrees with the rule
|
||
// it is overriding rather than inventing a second breakpoint.
|
||
const CARD_MIN_PX = 210
|
||
const viewportW = ref(typeof window !== 'undefined' ? window.innerWidth : 1280)
|
||
if (typeof window !== 'undefined') {
|
||
const onResize = () => { viewportW.value = window.innerWidth }
|
||
window.addEventListener('resize', onResize)
|
||
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
|
||
}
|
||
// ★ OFFER ONLY WHAT FITS (2026-09-30, from a phone: "3" and "4" still showed two). The grid below
|
||
// clamps a count to what the viewport can hold, so a choice above that clamp was a promise the wall
|
||
// could not keep. The choices are Auto plus every count up to what fits, capped at 5: Auto / 1 / 2 on
|
||
// a 390px phone (minCardPx 150), up to 5 on a desktop. A stored preference above the fit (4, set on a
|
||
// desktop) is SHOWN as what is rendered and stays stored, so the desktop keeps its 4.
|
||
const fitsPerRow = computed(() => {
|
||
const usable = Math.max(0, viewportW.value - 36)
|
||
const minCard = props.minCardPx > 0 ? props.minCardPx : CARD_MIN_PX
|
||
return Math.max(1, Math.floor(usable / minCard))
|
||
})
|
||
const perRowChoices = computed(() => [PER_ROW_AUTO, ...Array.from({ length: Math.min(PER_ROW_MAX, fitsPerRow.value) }, (_, i) => i + 1)])
|
||
const perRowShown = computed({
|
||
get: () => (perRow.value > 0 ? Math.min(perRow.value, fitsPerRow.value) : perRow.value),
|
||
set: (n) => { perRow.value = n },
|
||
})
|
||
const gridStyle = computed(() => {
|
||
if (!(perRow.value > 0)) return {}
|
||
// The wall's own padding either side; err toward FEWER columns rather than a clipped card.
|
||
const usable = Math.max(0, viewportW.value - 36)
|
||
// ★ A PHONE CAN NOW HOLD TWO, IF THE WALL ASKS. This clamp forced ONE poster per row at 390
|
||
// (floor(354 / 210) = 1), silently overriding a per-row setting of 2 -- reported from a phone
|
||
// as "2 posters per row does not work". It was added while the card's action labels were
|
||
// being CLIPPED at ~162px ('Pra...', Romanian 'Pachet de cartonase'); those labels now WRAP
|
||
// (the .pfs > .pbtn rule below), so a two-up card stays legible and only grows taller.
|
||
// It stays OPT-IN: the default is still CARD_MIN_PX, so no wall changes unless it sets minCardPx.
|
||
const minCard = props.minCardPx > 0 ? props.minCardPx : CARD_MIN_PX
|
||
const fits = Math.max(1, Math.floor(usable / minCard))
|
||
return { gridTemplateColumns: `repeat(${Math.min(perRow.value, fits)}, minmax(0, 1fr))` }
|
||
})
|
||
|
||
const plural = computed(() => props.itemNounPlural || props.itemNoun)
|
||
// Counts are PLURAL catalogue keys, rendered with the number so each locale takes its own CLDR form.
|
||
// `t` here is useOptionalI18n's, whose bare-host fallback is humanizeKey — right for button labels,
|
||
// wrong for a count — so counts use a catalogue translator of their own (tStatic when no i18n).
|
||
const tCount = useOptionalT('')
|
||
const countOf = (key, n) => tCount(key, { n: Number(n || 0).toLocaleString() }, Number(n || 0))
|
||
// The HUD's kinds and progress messages, and the poster image's alt text (what a screen reader announces
|
||
// for a card), in the page's language; English only where a key is absent. The same translator goes to
|
||
// usePosterPrints (opts.t) so the messages IT sets are localized too.
|
||
// ★ A PRINT'S REAL STEPS: a bar that moves as the work moves, never frozen, with no invented percentages.
|
||
// The sheet reports each step by name (countrySheet / posterSheets / renderPoster's onStep), the bar is the share of
|
||
// the KNOWN steps done, and the elapsed seconds tick on their own whenever the page can paint.
|
||
const PRINT_STEPS = ['records', 'boundaries', 'generator', 'map-frame', 'neighbours', 'seas', 'pins', 'side-lanes', 'cities', 'names', 'header', 'print']
|
||
const STEP_EN = {
|
||
records: 'loading the people groups', boundaries: 'loading the map outlines', generator: 'loading the poster engine',
|
||
'map-frame': 'fitting the map', neighbours: 'drawing the neighbouring provinces', seas: 'naming the seas',
|
||
pins: 'placing the pins', 'side-lanes': 'laying out the side lanes', cities: 'adding the cities',
|
||
names: 'placing the names', header: 'writing the header', print: 'preparing the print',
|
||
}
|
||
const stepKey = (name) => name.replace(/-([a-z])/g, (m, c) => c.toUpperCase())
|
||
const nowMs = ref(Date.now())
|
||
let elapsedTick = null
|
||
const elapsedS = computed(() => (exportState.value && exportState.value.startedAt ? Math.max(0, Math.floor((nowMs.value - exportState.value.startedAt) / 1000)) : 0))
|
||
function printStepper() {
|
||
let done = 0
|
||
return (name) => {
|
||
const i = PRINT_STEPS.indexOf(name)
|
||
if (i < 0 || !exportState.value) return
|
||
done = Math.max(done, i + 1)
|
||
exportState.value = { ...exportState.value, phase: 'render', message: hud(`wall.hud.step.${stepKey(name)}`, STEP_EN[name]),
|
||
stepN: done, stepM: PRINT_STEPS.length, pct: Math.round((100 * done) / PRINT_STEPS.length) }
|
||
}
|
||
}
|
||
|
||
const hud = (key, en, params = null, n = undefined) => {
|
||
const v = tCount(key, params || {}, n)
|
||
return v && v !== key ? v : String(en).replace(/\{(\w+)\}/g, (m, k) => (params && params[k] != null ? String(params[k]) : m))
|
||
}
|
||
const itemCount = (n) => (props.itemCountKey ? countOf(props.itemCountKey, n) : `${n} ${n === 1 ? props.itemNoun : plural.value}`)
|
||
// A section head's count line. The people-group total and its " · " leave together when the total is 0 (a gallery
|
||
// of poster templates counts no people), the rule the cards already follow with v-if="item.count". One string in
|
||
// one interpolation, so a head with a total renders exactly the text it always did, whitespace included.
|
||
const sectionItems = (sec) => {
|
||
const own = props.sectionItemCountKey ? props.sectionItemCountKey(sec) : null
|
||
if (!own) return itemCount(sec.items.length)
|
||
return typeof own === 'string' ? countOf(own, sec.items.length) : countOf(own.key, own.n ?? sec.items.length)
|
||
}
|
||
const sectionCount = (sec) => (sec.total > 0
|
||
? `${countOf('wall.peopleGroupCount', sec.total)} · ${sectionItems(sec)}`
|
||
: sectionItems(sec))
|
||
const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, ''))
|
||
|
||
// ★ A SECOND PLACE TO LOOK, because "poster not generated yet" was being shown for posters that
|
||
// ARE generated. It was reported on Ethiopia, whose country-eth.webp exists (174 of the 175
|
||
// records have their .webp; India is the one that legitimately does not and carries its own
|
||
// reason). MEASURED here — static build AND dev server, every request 200, zero cards in that
|
||
// branch — so the file is not missing; a HOST LAYOUT is. resolveAssetsBase points at
|
||
// <treeRoot>/../assets, the shared pool, which is correct for the layout generate-tree documents
|
||
// and wrong on a host that publishes only the tree folder. generate-tree ALSO stages a
|
||
// self-contained copy INSIDE the tree ("staged 421 runtime asset(s) → doxa-maps/assets"), and
|
||
// that copy is reachable on both layouts. So: try the pool, and if the bytes do not arrive, try
|
||
// the copy before telling the user anything.
|
||
const fallbackBase = computed(() => {
|
||
const root = String(bundleTreeRoot() || '').replace(/\/+$/, '')
|
||
return root ? `${root}/assets` : ''
|
||
})
|
||
// 0/absent = first attempt, 1 = the fallback is being tried.
|
||
const attempt = ref({})
|
||
const previewUrl = (id) => `${attempt.value[id] ? fallbackBase.value : base.value}/previews/${id}.webp`
|
||
function onPreviewError(id) {
|
||
// A generated preview has no second base to try.
|
||
if (props.previewSrc) { missing.value = { ...missing.value, [id]: true }; return }
|
||
const fb = fallbackBase.value
|
||
if (!attempt.value[id] && fb && fb !== base.value) { attempt.value = { ...attempt.value, [id]: 1 }; return }
|
||
missing.value = { ...missing.value, [id]: true }
|
||
}
|
||
|
||
// Sections ordered by TOTAL count (largest first), items within a section by their
|
||
// own count — the densest prayer need leads at BOTH levels.
|
||
const sections = computed(() => {
|
||
const byGroup = new Map()
|
||
for (const it of props.items || []) {
|
||
if (!it || !it.id) continue
|
||
const g = props.groupBy(it) || 'Other'
|
||
if (!byGroup.has(g)) byGroup.set(g, [])
|
||
byGroup.get(g).push(it)
|
||
}
|
||
return [...byGroup.entries()]
|
||
.map(([name, items]) => ({
|
||
name,
|
||
items: items.slice().sort((a, b) => (b.count || 0) - (a.count || 0)),
|
||
total: props.sectionTotal ? props.sectionTotal(name, items) : items.reduce((n, x) => n + (x.count || 0), 0),
|
||
}))
|
||
.sort((a, b) => b.total - a.total)
|
||
})
|
||
|
||
// The INITIAL collapsed state, derived from the caller's rule. Kept separate from
|
||
// the user's overrides so a click is never fighting a re-seed.
|
||
//
|
||
// This used to seed lazily INSIDE isCollapsed(), i.e. it wrote to a ref while Vue
|
||
// was rendering. That is a side effect during render: the write happened after the
|
||
// dependency was already read, so toggling a section changed the ref and the DOM
|
||
// never updated — clicking a region header did nothing. Deriving the seed and
|
||
// layering explicit overrides on top keeps the render pure.
|
||
const seeded = computed(() => {
|
||
const out = {}
|
||
for (const s of sections.value) out[s.name] = !!props.collapsedBy(s)
|
||
return out
|
||
})
|
||
const overrides = ref({})
|
||
const isCollapsed = (name) => {
|
||
if (!props.collapsible) return false
|
||
return name in overrides.value ? !!overrides.value[name] : !!seeded.value[name]
|
||
}
|
||
const toggleSection = (name) => {
|
||
overrides.value = { ...overrides.value, [name]: !isCollapsed(name) }
|
||
}
|
||
|
||
const lastShown = ref(null)
|
||
const missing = ref({})
|
||
// FORGET the shown card when the wall's view goes inactive (row 79). A composite marks its
|
||
// inactive pane `inert` (the PinHint contract); after "Show zone" -> Map -> back, the same card must SHOW
|
||
// again, not toggle off a selection the reader can no longer see. Watches the ancestor chain, through
|
||
// shadow roots, for inert/hidden/aria-hidden; clears lastShown only (the map keeps its selection).
|
||
const wallEl = ref(null)
|
||
let paneMOs = []
|
||
onMounted(() => {
|
||
const chain = []
|
||
for (let n = wallEl.value; n; n = n.parentNode || n.host || null) if (n.nodeType === 1) chain.push(n)
|
||
const inactive = () => chain.some((a) => a.hasAttribute('inert') || a.hasAttribute('hidden') || a.getAttribute('aria-hidden') === 'true')
|
||
if (typeof MutationObserver === 'undefined') return
|
||
for (const a of chain) {
|
||
const mo = new MutationObserver(() => { if (inactive()) lastShown.value = null })
|
||
mo.observe(a, { attributes: true, attributeFilter: ['inert', 'hidden', 'aria-hidden'] })
|
||
paneMOs.push(mo)
|
||
}
|
||
})
|
||
onBeforeUnmount(() => { for (const mo of paneMOs) mo.disconnect(); paneMOs = [] })
|
||
|
||
// ★ THE CARD CONTAINS ITS PRINT ROW (2026-09-30, from a phone at two posters per row). The row is a sticker
|
||
// pinned --tape-overlap over the banner's bottom edge that grows DOWNWARD as its labels wrap. One line
|
||
// fits inside the card's 26px bottom padding; on a ~162px two-up card it wraps to three lines and hung
|
||
// about 45px below the card's white box. No fixed padding fits every locale x width (the reason the row
|
||
// is pinned by its overlap and not its centre), so each card is fitted to its own row: a ResizeObserver
|
||
// on every .pfs writes --pfs-room on the card: for a row that hangs past the white box (the 26px padding
|
||
// and the 1px border), the part below the banner plus ROW_DAYLIGHT_PX that the 26px does not cover. A row
|
||
// that fits writes 0 (a one-line row ends ~5px inside the box), so every card that fit is unchanged.
|
||
// The revealed packet/size rows stay overlays on purpose (.has-tape-open lifts the card above its
|
||
// neighbours); only the print row itself is contained.
|
||
const CARD_PAD_BOTTOM_PX = 26
|
||
const CARD_BORDER_PX = 1
|
||
const ROW_DAYLIGHT_PX = 8
|
||
let rowRO = null
|
||
function fitCardToRow(pfs) {
|
||
const card = pfs.closest('.card')
|
||
const banner = pfs.parentElement
|
||
if (!card || !banner) return
|
||
// Rect DIFFERENCES inside one card: the hover lift (a translateY on the card) moves both edges alike.
|
||
const below = pfs.getBoundingClientRect().bottom - banner.getBoundingClientRect().bottom
|
||
const hangs = below > CARD_PAD_BOTTOM_PX + CARD_BORDER_PX
|
||
const room = `${hangs ? Math.ceil(below + ROW_DAYLIGHT_PX - CARD_PAD_BOTTOM_PX) : 0}px`
|
||
if (card.style.getPropertyValue('--pfs-room') !== room) card.style.setProperty('--pfs-room', room)
|
||
}
|
||
// observe() is idempotent, so re-scanning after every render only adds the rows that are new (items,
|
||
// sections opened, the per-row choice); a hidden wall reports 0 and writes 0 until it is shown.
|
||
function observeRows() {
|
||
if (!rowRO || !wallEl.value) return
|
||
for (const pfs of wallEl.value.querySelectorAll('.pfs')) rowRO.observe(pfs)
|
||
}
|
||
onMounted(() => {
|
||
if (typeof ResizeObserver === 'undefined') return
|
||
rowRO = new ResizeObserver((entries) => { for (const e of entries) fitCardToRow(e.target) })
|
||
observeRows()
|
||
})
|
||
onUpdated(observeRows)
|
||
onBeforeUnmount(() => { if (rowRO) rowRO.disconnect(); rowRO = null })
|
||
|
||
// ─── GENERATED PREVIEWS (the previewSrc prop) ────────────────────────────────
|
||
// Nothing here runs unless the caller passes previewSrc. A pending card registers its placeholder
|
||
// through a function ref; the observer queues it once it is in view; the queue renders ONE preview
|
||
// at a time, so a gallery never starts a burst of renders on load. The answer is cached on the page
|
||
// (a module cache would be per bundle), so a remount or a second tab shows it without a re-render.
|
||
// 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
|
||
const previewEls = new Map() // id -> its pending placeholder
|
||
const previewIds = new WeakMap() // placeholder -> id
|
||
const previewQueue = []
|
||
let previewBusy = false
|
||
let previewIO = null
|
||
|
||
function setPreviewEl(id, el) {
|
||
const old = previewEls.get(id)
|
||
if (old === el) return
|
||
if (old && previewIO) previewIO.unobserve(old)
|
||
if (!el) { previewEls.delete(id); return }
|
||
previewEls.set(id, el)
|
||
previewIds.set(el, id)
|
||
if (previewIO) previewIO.observe(el)
|
||
}
|
||
function queuePreview(id) {
|
||
if (id == null || generated.value[id] || missing.value[id] || previewQueue.includes(id)) return
|
||
previewQueue.push(id)
|
||
pumpPreviews()
|
||
}
|
||
function withTimeout(promise, ms) {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error('preview timed out')), ms)
|
||
promise.then((v) => { clearTimeout(timer); resolve(v) }, (e) => { clearTimeout(timer); reject(e) })
|
||
})
|
||
}
|
||
async function pumpPreviews() {
|
||
if (previewBusy || !previewQueue.length) return
|
||
const id = previewQueue.shift()
|
||
const item = (props.items || []).find((x) => x && x.id === id)
|
||
if (!item || !props.previewSrc) { pumpPreviews(); return }
|
||
previewBusy = true
|
||
const key = `${props.instanceId}\u0000${id}`
|
||
try {
|
||
if (!previewCache.has(key)) {
|
||
previewCache.set(key, withTimeout(Promise.resolve().then(() => props.previewSrc(item)), PREVIEW_TIMEOUT_MS))
|
||
}
|
||
let url = null
|
||
try { url = await previewCache.get(key) } catch (_) { url = null }
|
||
if (url) generated.value = { ...generated.value, [id]: url }
|
||
else {
|
||
previewCache.delete(key) // a later mount may try again
|
||
missing.value = { ...missing.value, [id]: true }
|
||
}
|
||
} finally {
|
||
previewBusy = false
|
||
pumpPreviews()
|
||
}
|
||
}
|
||
onMounted(() => {
|
||
if (!props.previewSrc) return
|
||
// No observer (an old engine): still one at a time, just not deferred to view.
|
||
if (typeof IntersectionObserver === 'undefined') { for (const id of previewEls.keys()) queuePreview(id); return }
|
||
// 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))
|
||
}, { 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 })
|
||
|
||
function showOnMap(item) {
|
||
// TOGGLE, not a one-way set: a control the user can turn on but not off reads as
|
||
// broken even when the selection is correct.
|
||
const isOn = lastShown.value === item.id
|
||
lastShown.value = isOn ? null : item.id
|
||
window.dispatchEvent(new CustomEvent(props.eventName, {
|
||
detail: {
|
||
instanceId: props.instanceId,
|
||
listId: isOn ? null : item.id, // null = deselect
|
||
label: isOn ? null : item.label,
|
||
},
|
||
}))
|
||
}
|
||
|
||
// ─── EXPORT — serve the PRE-RENDERED poster ──────────────────────────────────
|
||
// Deliberately a fetch, not a re-render. Re-rendering produced a DIFFERENT
|
||
// ARTEFACT from the preview on the same card — a bare basemap capture with no
|
||
// title, no scripture bands, no facts table, no numbered pins. Serving the
|
||
// pre-rendered image makes preview and download identical BY CONSTRUCTION rather
|
||
// than by keeping two renderers in sync, and it works on Android where the Node
|
||
// generator cannot run at all.
|
||
const exportState = ref(null)
|
||
// the elapsed seconds tick for the life of a print's HUD (declared here: the watch reads exportState at setup)
|
||
watch(() => exportState.value && exportState.value.startedAt, (st) => {
|
||
clearInterval(elapsedTick); elapsedTick = null
|
||
if (st) { nowMs.value = Date.now(); elapsedTick = setInterval(() => { nowMs.value = Date.now() }, 250) }
|
||
})
|
||
onBeforeUnmount(() => { clearInterval(elapsedTick) })
|
||
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
|
||
|
||
async function exportPoster(item) {
|
||
if (exportState.value) return // one at a time
|
||
const reason = props.unsupported(item)
|
||
if (reason) {
|
||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'soon', message: reason, pct: 0 }
|
||
return
|
||
}
|
||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'fetch',
|
||
message: hud('wall.hud.fetchingPoster', 'fetching the poster'), pct: 0, startedAt: Date.now() }
|
||
const step = printStepper()
|
||
try {
|
||
// ★ GENERATED FIRST (the print standard): a host that can draw the sheet now (the `sheet` prop,
|
||
// packet/posterSheets.js) prints or downloads THAT — no stored file involved, so a country, which has
|
||
// no stored sheet on the published tree, still gets its poster. 'letter' for a print (the format made
|
||
// for a home printer), the item's own poster size for a download. A generation that fails falls through
|
||
// to the stored image and HTML below rather than to an error.
|
||
if (props.sheet) {
|
||
exportState.value = { ...exportState.value, phase: 'render', message: hud('wall.hud.generatingPoster', 'generating the poster') }
|
||
const format = props.posterAction === 'print' ? 'letter' : (item.sheet === '3x3' ? '3x3' : '3x4')
|
||
const html = await Promise.resolve().then(() => props.sheet(item, format, { onStep: step })).catch(() => '')
|
||
if (html) {
|
||
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
|
||
// ★ A PRINTED SHEET IS ITS OWN DOCUMENT, never an image of itself. The sheet is vector HTML/SVG with its own
|
||
// @page; printed as is, the print is the same drawing (crisper), carries any photo from its own host (a raster
|
||
// cannot: poster-raster.js says why), and skips the raster entirely: on a phone that decode + draw + encode was
|
||
// the last multi-second freeze before the print dialog. A DOWNLOAD still rasterises (it needs an image file).
|
||
if (props.posterAction === 'print') { step('print'); await printSheetDocument(html); return }
|
||
step('print')
|
||
// let the step paint before the raster starts: the SVG decode below holds the page while it draws
|
||
await new Promise((r) => (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(() => setTimeout(r, 0)) : setTimeout(r, 0)))
|
||
const blob = await rasterPoster(html, { scale: 2 })
|
||
if (await looksRendered(blob)) { await deliverPoster(blob, item); return }
|
||
}
|
||
}
|
||
// ⚠ A THROWN FETCH IS A MISS, NOT AN ERROR. `fetch` rejects — it does not return
|
||
// !ok — when the request is blocked, offline, or refused by the network layer.
|
||
// Letting that reject escape to the outer catch skips the rasterise fallback
|
||
// entirely and reports "Failed to fetch" to the reader, on a poster the browser
|
||
// could have generated from posters/<id>.html. Measured 2026-09-24 while
|
||
// migrating my-upg-100-list onto this component: check:poster-print-fallback,
|
||
// which aborts every .webp at the network layer to force the default-drop
|
||
// condition, went from PASS on the old wall to FAIL here with 0 .html requests.
|
||
// The wall this component was extracted from already guarded it; the extraction
|
||
// did not carry the guard across. The guard now lives in poster-fetch.js, which also
|
||
// takes a host's 200 fallback page (text/html for a missing .webp) as a miss.
|
||
const raster = await fetchPosterRaster(`${base.value}/posters/${item.id}.webp`)
|
||
if (!raster) {
|
||
// ★ NO PRE-RENDERED IMAGE? GENERATE IT. The 378 .webp posters were 41 MB in
|
||
// every clone, shipped to every reader whether or not they opened one — and a
|
||
// publish was already rejected once at a 74 MB asset tree. The poster HTML is
|
||
// SMALLER than the image it produces (~115 KB gzipped vs ~213 KB) and it is
|
||
// the source, so the reader picks the resolution instead of being handed one.
|
||
// This is the same move the prayer packet already makes: generate on click,
|
||
// ship nothing. A 404 used to end here as "poster not found".
|
||
exportState.value = { ...exportState.value, phase: 'render',
|
||
message: hud('wall.hud.generatingPoster', 'generating the poster'), pct: 45 }
|
||
const source = await fetchPosterSource(`${base.value}/posters/${item.id}.html`)
|
||
if (!source.html) throw keyedError('wall.hud.posterNotFound', 'poster not found ({status})', { status: source.status })
|
||
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
|
||
const blob = await rasterPoster(source.html, { scale: 2 })
|
||
// Fail LOUDLY rather than hand over a black rectangle: an SVG in an <img> is
|
||
// an isolated document, so a missing stylesheet makes every fill fall back to
|
||
// black with no error at all.
|
||
if (!(await looksRendered(blob))) throw keyedError('wall.hud.posterBlank', 'the generated poster came out blank')
|
||
await deliverPoster(blob, item)
|
||
return
|
||
}
|
||
await deliverPoster(raster, item)
|
||
} catch (e) {
|
||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'error',
|
||
message: errorText(e, (k, p) => hud(k, k, p)).slice(0, 140), pct: 0 }
|
||
}
|
||
}
|
||
|
||
// ── SHARED PRINT ACTIONS ──────────────────────────────────────────────────
|
||
// The list and card-deck prints live in the shared composable so every wall gets
|
||
// the same documents. They need ROWS, which only the bundle can fetch — hence the
|
||
// `records` prop. Without it the buttons are hidden and this stays honest rather
|
||
// than offering an action that cannot work.
|
||
// The wall follows the app's locale: the card deck and its QR codes are localised,
|
||
// and packet-strings.json falls back per key.
|
||
// `t` for the wall's own labels, `locale` for the card deck's language. Both come
|
||
// from the SAME instance so a label and a printed deck can never disagree about
|
||
// which language the reader chose.
|
||
// ★ i18n IS OPTIONAL IN A LIBRARY COMPONENT, NOT REQUIRED. useI18n() THROWS ("Need to install
|
||
// with `app.use` function") on any host that never installed vue-i18n, and seven bundles here do
|
||
// not — big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check, people-groups-grid,
|
||
// plugin-studio, stack-demo. An unguarded call in a component this widely consumed is a BLANK
|
||
// PAGE waiting for the first bare host that renders it: exactly how library FilterDropdown
|
||
// blanked people-groups-grid, a production plugin, fixed by studio in 6603dc17. Same shape of
|
||
// fix as PinHint.vue.
|
||
//
|
||
// ⚠ THE FALLBACK IS DERIVED, NOT A TABLE. Hardcoding English for these eleven keys would put a
|
||
// second copy of the `en` catalog inside this file, and two copies of one fact drift — the exact
|
||
// trap behind the embedBase asset-path bug this week. Humanizing the last path segment gives
|
||
// "buttons.prayerList" -> "Prayer List", "buttons.cardDeck" -> "Card Deck",
|
||
// "buttons.posterMap" -> "Poster Map": readable English that CANNOT go stale, rather than a raw
|
||
// key shown to a reader.
|
||
const humanizeKey = (k) => String(k).split('.').pop()
|
||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
|
||
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback and the
|
||
// bare-host locale stays 'en', so nothing this wall prints changes.
|
||
const { t, locale: wallLocale } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
|
||
// ⚠ ONE exportState, not two. The composable owns a ref of its own; if the HUD
|
||
// keeps rendering the wall's while the print actions write to the composable's, a
|
||
// card-deck print shows NO progress and its errors vanish silently — the mechanism
|
||
// works and the feedback does not. Hand the composable this wall's ref so both
|
||
// write to the surface the reader is actually looking at.
|
||
// ── CARD STOCK, optionally reader-chosen ────────────────────────────────────
|
||
// pickedStock is what the deck actually prints at: the prop when there is no
|
||
// picker, the reader's choice when there is.
|
||
const pickedStock = ref(props.cardStock)
|
||
const stockOptions = ref([])
|
||
// ⚠ LAZY, deliberately. usePosterPrints imports card-render.js dynamically
|
||
// (usePosterPrints.js:157) to keep a 10KB renderer out of every wall's initial
|
||
// bundle; importing it statically here just for five labels would undo that for
|
||
// every consumer, including the ones that show no picker at all. So the options
|
||
// fill in when the module arrives, and until then the <select> simply is not
|
||
// rendered — no flash of unlabelled ids.
|
||
// A stock's name in the page's language: the NOUN translates (wall.stockCard / stockPostcard), the size
|
||
// stays as a printer's box prints it, and a stock without a noun (A6) keeps its international name.
|
||
const stockLabel = (s) => {
|
||
if (!s.noun) return s.label
|
||
const key = `wall.stock${s.noun[0].toUpperCase()}${s.noun.slice(1)}`
|
||
const noun = tCount(key)
|
||
if (!noun || noun === key) return s.label
|
||
const dims = `${s.w} × ${s.h} in`
|
||
return s.side ? `${noun} ${dims} (${tCount('wall.stockLandscape')})` : `${noun} ${dims}`
|
||
}
|
||
if (props.cardStocks) {
|
||
import('@map/packet/card-render.js').then(({ STOCK }) => {
|
||
const want = Array.isArray(props.cardStocks) ? props.cardStocks : Object.keys(STOCK)
|
||
stockOptions.value = want
|
||
.filter((id) => STOCK[id]) // an unknown id is dropped, not rendered blank
|
||
// The STOCK is kept, not a label string: the label is read at render (stockLabel below), so a
|
||
// locale change re-labels the buttons like every other string on the wall.
|
||
.map((id) => ({ id, stock: STOCK[id] }))
|
||
// Keep the default honest: if the configured cardStock is not on offer, fall
|
||
// back to the first one that is, rather than printing at a size not listed.
|
||
if (!stockOptions.value.some((s) => s.id === pickedStock.value)) {
|
||
pickedStock.value = stockOptions.value[0]?.id || props.cardStock
|
||
}
|
||
}).catch(() => { /* no picker rather than a broken one */ })
|
||
}
|
||
|
||
const prints = usePosterPrints({
|
||
t: (key, params, n) => tCount(key, params, n),
|
||
state: exportState,
|
||
// This wall's HUD renders phase 'ready' as a Print button (a phone whose tap expired while preparing).
|
||
tapToPrint: true,
|
||
records: props.records ? (item) => props.records(item) : null,
|
||
// useI18n()'s locale can be a ref or a plain string depending on how the host
|
||
// installed it, so normalise rather than assume.
|
||
lang: () => {
|
||
const l = wallLocale && (wallLocale.value ?? wallLocale)
|
||
return (typeof l === 'string' && l) || 'en'
|
||
},
|
||
stock: () => pickedStock.value,
|
||
label: (item) => item?.label || item?.id || '',
|
||
// The near-neighbours table for the FULL packet's bounded/centered back page — fetched from the
|
||
// SAME served asset pool the posters come from, so an embedded copy of this bundle prints the
|
||
// same page as the dev tree. null on any failure: the packet prints without the page rather
|
||
// than not printing.
|
||
// assetsBase feeds the packet's default map lift (posters/<id>.html). Without it the lift's
|
||
// guard skips silently and the wall would STILL print a mapless packet — the exact
|
||
// seam-without-supplier failure this change exists to close, one layer down.
|
||
assetsBase: () => props.assetsBase,
|
||
iso3Names: props.iso3Names ? () => props.iso3Names() : null,
|
||
mapImage: props.mapImage ? (item) => props.mapImage(item) : null,
|
||
sheet: props.sheet ? (item, format) => props.sheet(item, format) : null,
|
||
sets: () => fetch(`${props.assetsBase}/tables/near-neighbors.json`)
|
||
.then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
||
})
|
||
const canPrintRows = computed(() => typeof props.records === 'function')
|
||
const previewIsControl = computed(() => props.previewAction === 'print')
|
||
|
||
// ── CARD-DECK DISCLOSURE ────────────────────────────────────────────────────
|
||
// Which card currently has its size row revealed, by item id. Holding an ID
|
||
// rather than a boolean-per-card means opening one CLOSES the others for free:
|
||
// two size rows open at once would overlap in the grid gap, and there is no
|
||
// reading of the request where two are wanted.
|
||
// ★ NOW A COMPOUND KEY, `${id}:${which}` — the Prayer List gets the SAME caret, with two entries
|
||
// (Full detail / Lightweight), and explicitly not a merged menu and not a second dropdown component. There is no dropdown COMPONENT here to reuse: this is an inline
|
||
// disclosure, one ref plus a sibling tape of plain buttons. So the PATTERN is generalized instead of
|
||
// copied — copy/paste would give two independent open-states, and then opening the Prayer List would
|
||
// NOT close the Cards, so two tapes could sit open at once and overlap in the grid gap. That is a
|
||
// visible regression on the Cards row, which is to stay exactly as it is.
|
||
// Holding ONE key keeps "opening one closes the others" free, which is why it was an id to begin with.
|
||
const deckOpenFor = ref(null)
|
||
const deckKey = (id, which) => `${id}:${which}`
|
||
function toggleDeck(id, ev, which = 'deck') {
|
||
const key = deckKey(id, which)
|
||
deckOpenFor.value = deckOpenFor.value === key ? null : key
|
||
// Tell the size row how far down to start: the print row's REAL height, which is
|
||
// one line in en and two in the long-label locales. Walking up from the clicked
|
||
// button to its own .pfs keeps this correct per card rather than per wall.
|
||
const pfs = ev?.currentTarget?.closest?.('.pfs')
|
||
if (pfs) pfs.parentElement?.style.setProperty('--pstocks-gap', `${pfs.offsetHeight}px`)
|
||
}
|
||
|
||
// Choosing a size IS the print command — the reader already said "print the
|
||
// cards" by opening this row, so a second confirm step would be a click with
|
||
// nothing behind it. prints.stock reads pickedStock through a getter, so setting
|
||
// it here is what the deck prints at.
|
||
function printDeckAt(item, stockId) {
|
||
pickedStock.value = stockId
|
||
deckOpenFor.value = null
|
||
prints.printCardDeck(item)
|
||
}
|
||
|
||
function downloadPacket(item) {
|
||
// Still the honest "not yet" when this wall has no records provider: NOT an
|
||
// error — unbuilt, not broken. phase:'error' reads to a user as "something went
|
||
// wrong" rather than "not yet".
|
||
if (!canPrintRows.value) {
|
||
exportState.value = { label: item.label, kind: hud('wall.hud.kindPrayerList', 'Prayer List'), phase: 'soon',
|
||
message: props.prayerListSoonMessage, pct: 0 }
|
||
return
|
||
}
|
||
prints.printList(item)
|
||
}
|
||
|
||
// ★ THE TWO PACKETS the Prayer List caret reveals. Declared here rather than inline in the
|
||
// template so the row is DATA — a third packet is one entry, not a markup change — and so the
|
||
// gate can assert the entries without parsing HTML.
|
||
// ⚠ The ids MUST match packet-render's VARIANTS keys; buildPacket falls back to 'full' on an
|
||
// unknown one, which would silently print the wrong packet rather than failing. Asserted in
|
||
// internal/check-packet-variants.mjs, which reads BOTH sides and compares.
|
||
const packetVariants = [
|
||
{ id: 'full', labelKey: 'buttons.packetFull' },
|
||
{ id: 'lightweight', labelKey: 'buttons.packetLightweight' },
|
||
]
|
||
|
||
// Choosing a variant IS the print command, exactly as choosing a card size is — the reader
|
||
// already said "prayer list" by opening the row. Routed through downloadPacket's guard rather
|
||
// than around it, so a wall with no records provider answers "not yet" identically whichever
|
||
// entry is picked. Opening a variant closes the row, like printDeckAt.
|
||
// ★ THIS CALLS printPacket, NOT printList — and that difference is a DEFECT FIX, not a refactor.
|
||
// MEASURED: downloadPacket() above calls prints.printList(item), which renders a COLUMNAR TABLE
|
||
// (name / country / population / religion — usePosterPrints.js:150). The PRAYER PACKET as specified
|
||
// — map, life calendar, %-evangelical grid, triangle, profiles, wheel, points — is
|
||
// prints.printPacket, and this wall never called it. Every OTHER surface does
|
||
// (upg-100-list.vue:1430 and :1671, research-map.vue:1597, countries-map.vue:1360); the poster wall
|
||
// was the sole exception. So the button labelled "Prayer List" here has been printing a list, which
|
||
// is why the packet "does not follow the design that was asked for" ON THIS SURFACE — it was never
|
||
// the packet renderer's output.
|
||
function printPacketAs(item, variantId) {
|
||
if (!canPrintRows.value) { downloadPacket(item); return }
|
||
deckOpenFor.value = null
|
||
// mapSvg/funnel are optional: printPacket defaults them, and mapPage/funnelPage each render ''
|
||
// without data rather than breaking, so the wall prints a correct packet with the pages it can
|
||
// truthfully draw. A host that has them can pass them through the same seam.
|
||
prints.printPacket(item, { variant: variantId })
|
||
}
|
||
|
||
// ONE delivery point for the finished poster, so 'download' and 'print' cannot
|
||
// drift apart or be applied to only one of the two paths (shipped .webp vs
|
||
// browser-rasterised). Both callers in exportPoster() route through here.
|
||
// Print a generated sheet as the document it is (photos load, the page is its own size). Same HUD steps as
|
||
// deliverPoster's print, including the phone's Print button when the tap has expired.
|
||
// ★ A PRINTED SHEET KEEPS THE PRINTER'S MARGIN AND ITS SOFT LINES. The sheet's own print CSS runs it edge to edge
|
||
// (@page margin 0), which a home printer clips, and its scale can overflow the page by a sliver (a blank second
|
||
// page on a phone). So the print copy gets, AFTER the sheet's own rules (later rules win; the sheet itself is
|
||
// unchanged): a 12 mm @page margin (as the image print had), the stage scaled to fit inside it on letter AND A4,
|
||
// and the leader lines softened to how the image print drew them (thin vector lines print darker than the same
|
||
// lines anti-aliased into an image).
|
||
const PRINT_MARGIN_MM = 12
|
||
function fitForPrint(html) {
|
||
const sc = /\.stage\{margin:0;transform:scale\(([\d.]+)\)/.exec(html)
|
||
const box = /html,body\{width:(\d+)px;height:(\d+)px;overflow:hidden\}/.exec(html)
|
||
if (!sc || !box || !html.includes('</head>')) return html
|
||
const inches = /@page\{size:([\d.]+)in ([\d.]+)in/.exec(html)
|
||
// letter fits A4's narrower width too, so one fit prints on either paper
|
||
const [pageW, pageH] = inches ? [+inches[1] * 96, +inches[2] * 96] : /@page\{size:letter/.test(html) ? [8.27 * 96, 11 * 96] : [0, 0]
|
||
if (!pageW) return html
|
||
const M = (PRINT_MARGIN_MM / 25.4) * 96
|
||
const W = +box[1], H = +box[2]
|
||
const f = Math.min((pageW - 2 * M) / W, (pageH - 2 * M) / H) * 0.995
|
||
const css = `<style>@media print{@page{margin:${PRINT_MARGIN_MM}mm}.stage{transform:scale(${(+sc[1] * f).toFixed(6)})}`
|
||
+ `html,body{width:${Math.floor(W * f)}px;height:${Math.floor(H * f)}px}.lead{stroke-opacity:.55}.slead{opacity:.35}}</style>`
|
||
return html.replace('</head>', css + '</head>')
|
||
}
|
||
|
||
async function printSheetDocument(html) {
|
||
html = fitForPrint(html)
|
||
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }
|
||
const { printDocument } = await import('@map/composables/printInPlace.js')
|
||
const how = await printDocument(html, { onNeedsTap: (printNow, cancel) => {
|
||
exportState.value = { ...exportState.value, phase: 'ready', message: hud('wall.hud.readyToPrint', 'ready to print'), pct: 100,
|
||
action: () => { exportState.value = null; printNow() }, cancel }
|
||
} })
|
||
if (how === 'needs-tap') return
|
||
exportState.value = { ...exportState.value, phase: 'done', message: hud('wall.hud.printOpened', 'print dialog opened'), pct: 100 }
|
||
setTimeout(() => { exportState.value = null }, 900)
|
||
}
|
||
|
||
async function deliverPoster(blob, item) {
|
||
if (props.posterAction === 'print') {
|
||
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }
|
||
const { printDocument } = await import('@map/composables/printInPlace.js')
|
||
const url = URL.createObjectURL(blob)
|
||
posterObjectUrls.push(url)
|
||
// A 3ft SQUARE on a PORTRAIT sheet must fit by its SHORTER axis: scaling to the
|
||
// full page WIDTH makes it taller than the printable height and it overflows onto
|
||
// a second page. Constrain both axes and let object-fit letterbox it.
|
||
// printDocument: the in-place frame as before, or on a phone the PAGE itself (printInPlace.js says why).
|
||
const how = await printDocument(
|
||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>'
|
||
+ esc(`${props.downloadPrefix} — ${item.label || item.id}`) + '</title>'
|
||
+ '<style>@page{size:letter portrait;margin:12mm}'
|
||
+ 'html,body{margin:0;height:100%;background:#fff}'
|
||
+ 'body{display:flex;align-items:center;justify-content:center}'
|
||
+ 'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}'
|
||
+ '@media print{body{height:auto}'
|
||
+ 'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>'
|
||
+ `<body><img src="${url}" alt="${esc(item.label || item.id)}"></body></html>`,
|
||
{ onNeedsTap: (printNow, cancel) => {
|
||
exportState.value = { ...exportState.value, phase: 'ready', message: hud('wall.hud.readyToPrint', 'ready to print'), pct: 100,
|
||
action: () => { exportState.value = null; printNow() }, cancel }
|
||
} },
|
||
)
|
||
// The reader now owns the next step: the Print button in the HUD.
|
||
if (how === 'needs-tap') return
|
||
} else {
|
||
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.saving', 'saving'), pct: 80 }
|
||
download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`)
|
||
}
|
||
exportState.value = { ...exportState.value, phase: 'done',
|
||
message: props.posterAction === 'print' ? hud('wall.hud.printOpened', 'print dialog opened') : hud('wall.hud.saved', 'saved'), pct: 100 }
|
||
setTimeout(() => { exportState.value = null }, 900)
|
||
}
|
||
|
||
// Blob urls for printed posters. NOT revoked when the frame opens — the preview
|
||
// <img> is still loading from them and revoking early prints a blank sheet — so
|
||
// they are held until this component goes away.
|
||
const posterObjectUrls = []
|
||
onBeforeUnmount(() => {
|
||
for (const u of posterObjectUrls.splice(0)) { try { URL.revokeObjectURL(u) } catch { /* already gone */ } }
|
||
})
|
||
|
||
// Escape the characters that break out of an HTML text node or a "..." attribute.
|
||
// The print document is built by string concatenation, so every interpolated value
|
||
// — a list label straight from data — goes through this first.
|
||
const esc = (v) => String(v == null ? '' : v)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||
|
||
function download(blob, name) {
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = name
|
||
document.body.appendChild(a); a.click(); a.remove()
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
|
||
}
|
||
|
||
// A 'ready' print that the reader walks away from takes its document with it, so the browser's own Print
|
||
// menu prints the page again rather than a poster nobody asked for.
|
||
function dismissExport() {
|
||
const s = exportState.value
|
||
exportState.value = null
|
||
try { if (s && typeof s.cancel === 'function') s.cancel() } catch (_) { /* already gone */ }
|
||
}
|
||
|
||
useShadowStyles(
|
||
// --wall-pad-top is read back by .sec-head::before to cover the scroll-padding strip
|
||
// above a pinned header (see there). Declared as a var so the two can never drift:
|
||
// typing 22px in both places is how that gap would silently re-open.
|
||
'.wall{--wall-pad-top:22px;position:absolute;inset:0;overflow-y:auto;' +
|
||
'padding:var(--wall-pad-top) 18px 44px;' +
|
||
"background:#f6f7f9;color:#0f1419;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" +
|
||
'.wall.is-dark{background:#0a1410;color:#e5e7eb}' +
|
||
// Embedded: normal flow, no pane background, no pane padding. inset:auto matters as much
|
||
// as position:static — leaving inset set keeps the box stretched even once static.
|
||
// ⚠ --wall-pad-top MUST go to 0 here too, not just the padding. Embedded, the wall is not
|
||
// the scroll container — the HOST page is — so there is no scroll-padding strip to cover,
|
||
// and a 22px cover bar would paint the wall's background over the host's own content
|
||
// above each header. The var and the padding are one decision, set in one place.
|
||
'.wall--embedded{--wall-pad-top:0px;position:static;inset:auto;overflow-y:visible;' +
|
||
'padding:0;background:transparent}' +
|
||
'.wall--embedded.is-dark{background:transparent}' +
|
||
'.wall-h{margin:0 0 6px;font-size:19px;font-weight:700;text-align:center}' +
|
||
'.wall-tag{margin:0 auto 12px;width:fit-content;padding:3px 11px;border-radius:999px;' +
|
||
'background:rgba(16,185,129,.13);color:#0b7a5b;border:1px solid rgba(16,185,129,.28);' +
|
||
'font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}' +
|
||
'.is-dark .wall-tag{background:rgba(16,185,129,.16);color:#6ee7b7;border-color:rgba(110,231,183,.32)}' +
|
||
'.wall-sub{margin:0 auto 22px;max-width:620px;font-size:12.5px;line-height:1.55;' +
|
||
'text-align:center;opacity:.75}' +
|
||
'.wall-sub strong{font-weight:700;opacity:1;color:inherit}' +
|
||
// GAP must clear the OVERHANGING TAPES, not just the card boxes: the header tape
|
||
// sits at top:-11px and the footer fieldset at bottom:-14px, so a 26px row gap
|
||
// left ~1px of real air and the wall read as one dense block.
|
||
// ⚠ THE ROW GAP MUST CLEAR THE PRINT ROW, and 88px was tuned for a row that NO LONGER HANGS
|
||
// THAT FAR. It was set when .pfs sat at top:100% with margin-top:-14px on the CARD — i.e. the
|
||
// whole row (up to 78px wrapped) hung below the card and the gap had to clear all of it.
|
||
// The row now straddles the BANNER's bottom edge and overhangs by --tape-overlap plus whatever
|
||
// grows downward, and the card's own 26px bottom padding absorbs part of that. Reported from a
|
||
// 390x844 phone shot: a ~180px empty band between one row's print stickers and the next row's
|
||
// header stickers — the gap is now clearing a distance nothing occupies.
|
||
// 56px still clears a two-line row (the ro/ru case) with room; the assertion that keeps this
|
||
// honest is `neighbour 0` in check-poster-card-fits, which fails if a row reaches another card.
|
||
'.grid{display:grid;gap:56px 30px;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));' +
|
||
'max-width:1180px;margin:0 auto}' +
|
||
// The per-row control. Sits above its own grid, right-aligned so it reads as a setting for the
|
||
// block below rather than as content, and inherits the wall's type so it does not announce
|
||
// itself. max-width matches the grid's, so the two share an edge.
|
||
'.perrow{display:flex;align-items:center;justify-content:flex-end;gap:8px;' +
|
||
'max-width:1180px;margin:0 auto 12px;font:inherit;font-size:13px}' +
|
||
'.perrow__label{color:#6d766e}' +
|
||
'.perrow__select{font:inherit;font-size:13px;padding:2px 6px;border:1px solid #cbd2d9;' +
|
||
'border-radius:6px;background:#fff;color:inherit;cursor:pointer}' +
|
||
'.is-dark .perrow__label{color:#9fb3a6}' +
|
||
'.is-dark .perrow__select{background:#132019;border-color:rgba(110,231,183,.20)}' +
|
||
// --card-pad-top IS the banner's top edge in card coordinates, and .tape-h reads it to
|
||
// centre itself there. Declared as a var so the two cannot drift apart the way a second
|
||
// hand-typed 24px would.
|
||
'.card{--card-pad-top:24px;--tape-overlap:16px;position:relative;background:#fff;border:1px solid #e3e6ea;' +
|
||
'border-radius:3px;' +
|
||
// --pfs-room: the extra bottom padding a card needs to CONTAIN its print row once that row wraps
|
||
// (fitCardToRow in the script). 0 wherever the row fits the 26px, so those cards are unchanged.
|
||
'padding:var(--card-pad-top) 12px calc(26px + var(--pfs-room,0px));' +
|
||
'box-shadow:0 1px 3px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.07);' +
|
||
'transition:transform .16s ease,box-shadow .16s ease}' +
|
||
// ★ THE BLUE OUTLINE IS A HOVER STATE, NOT A MISSING STATIC BORDER. Several reports have asked
|
||
// for "the blue outline around the poster" to be restored, and MEASURED 2026-09-24 there has
|
||
// never been one: all 136 elements painting a frame on this wall compute `outline:none`, and
|
||
// every border is grey (.card #e3e6ea, .main #dfe3e7). Nothing regressed — the affordance was
|
||
// never built. Asked for as Pinterest/Instagram do it: the card answers the pointer.
|
||
//
|
||
// ⚠ `outline`, NOT `border`, and this is the load-bearing choice. An outline is painted OUTSIDE
|
||
// the box and takes no space, so it cannot move anything. Growing the border on hover would
|
||
// re-lay-out the card by 2px and push the print row — whose geometry is asserted at five widths
|
||
// by check:poster-card-fits (0 overflow / 0 over-preview / 0 neighbour intrusions) — meaning a
|
||
// cosmetic hover would break a gate about print controls. outline-offset keeps it clear of the
|
||
// rounded corner instead of cutting across it.
|
||
//
|
||
// ⚠ :focus-visible CARRIES IT TO THE KEYBOARD. A hover-only affordance tells a mouse user which
|
||
// card they are about to act on and tells a keyboard user nothing, on a card whose buttons this
|
||
// same wall already had to be fixed to name (791/791 accessible names).
|
||
'.card:hover,.card:focus-within{transform:translateY(-3px);' +
|
||
'box-shadow:0 3px 8px rgba(0,0,0,.14),0 12px 28px rgba(0,0,0,.10);' +
|
||
'outline:2px solid #2f6fdb;outline-offset:2px}' +
|
||
'.is-dark .card:hover,.is-dark .card:focus-within{outline-color:#5aa0ff}' +
|
||
// ⚠ A TOUCH READER NEVER HOVERS. On a phone the lift and the outline would either never appear
|
||
// or, worse, stick after a tap and leave a card looking selected. The print row on this wall is
|
||
// always visible, so touch loses no affordance by dropping the hover state entirely.
|
||
'@media (hover:none){.card:hover,.card:focus-within{transform:none;outline:none}}' +
|
||
'.is-dark .card{background:#132019;border-color:rgba(110,231,183,.20)}' +
|
||
// box-sizing is NOT inherited into a shadow root from the host page's reset.
|
||
'.tape{box-sizing:border-box;position:absolute;left:50%;transform:translateX(-50%);z-index:2;' +
|
||
'font-size:10.5px;font-weight:600;letter-spacing:.02em;cursor:pointer;' +
|
||
'border:1px solid rgba(0,0,0,.08);background:#eef2f6;color:#23303c}' +
|
||
'.is-dark .tape{background:#1d2b23;color:#cfe9d9;border-color:rgba(255,255,255,.10)}' +
|
||
// ★ THE HEADER STICKER STRADDLES THE BANNER'S TOP EDGE, for the same reason the footer one
|
||
// straddles the bottom. MEASURED before: 35px above the banner's top edge, -1px below — it
|
||
// floated over the card, touching the banner nowhere. The rounded top sticker is the one
|
||
// that reads as well spaced, so this keeps its size and shape exactly and only corrects
|
||
// WHERE it sits: centred on the edge it is taping.
|
||
// --banner-pad-top reserves the banner surface it lands on, so it never reaches the map.
|
||
// .tape-h is positioned against .card (it is a direct child), and the banner's top is the
|
||
// card's padding-top — hence top:24px, read from the card rather than restated.
|
||
// Same rule as .pfs below: the OVERLAP onto the banner is the constant, not the centre. This
|
||
// sticker is a single line at every width so centring happened to work, but pinning both
|
||
// stickers the same way means one --tape-overlap governs both and neither can drift.
|
||
// Its height grows UPWARD here (bottom-anchored to the band) so it never reaches the map.
|
||
'.tape-h{top:calc(var(--card-pad-top,24px) - var(--tape-overlap,16px));' +
|
||
'transform:translateX(-50%);' +
|
||
'width:82%;padding:4px 8px;border-radius:999px;' +
|
||
'display:flex;align-items:center;justify-content:center;gap:5px}' +
|
||
'.tape-h:hover{background:#e2e8ee}' +
|
||
'.hcount{font-weight:800;font-variant-numeric:tabular-nums}' +
|
||
'.hsep{opacity:.45}' +
|
||
'.hlabel{font-weight:600}' +
|
||
'.card.is-last{box-shadow:0 0 0 2px #10b981,0 6px 18px rgba(0,0,0,.10)}' +
|
||
'.card.is-last .tape-h{background:#10b981;color:#04241a;border-color:#10b981}' +
|
||
// ⚠ min-width:0 ON THE CHILDREN — and NOT flex-wrap. width:82% bounds the fieldset, but flex
|
||
// items default to min-width:auto and REFUSE to shrink below their intrinsic content width, so
|
||
// four controls (Prayer List · Card Deck · stock select · Poster Map) burst the box regardless.
|
||
// MEASURED 2026-09-24 at 1440px: all 27 cards overflowing, worst 110px past a 212px card.
|
||
//
|
||
// ★ flex-wrap:wrap FIXED THAT AND BROKE THE OTHER AXIS — reported the same day. The row is
|
||
// absolutely positioned at bottom:-14px, so wrapping it to three lines grew it 35px -> 78px and
|
||
// pushed its TOP up from 216px to 173px into a 238px card: straight over the preview image and
|
||
// the card's outline. Horizontal overflow traded for vertical. min-width:0 alone lets the
|
||
// controls compress on ONE line, which is what the row was always meant to be.
|
||
// The gate that passed this only measured horizontal edges; it now measures the row's top too.
|
||
// ⚠ ANCHORED AT THE TOP, NOT THE BOTTOM. This was bottom:-14px, so any growth pushed the row
|
||
// UPWARD over the preview — which is exactly how wrapping it turned a sideways overflow into
|
||
// a vertical one. Anchoring at top:100% pins it just under the card, so the
|
||
// second line grows DOWN into empty space. MEASURED: 92px between the preview's bottom and
|
||
// the next card, of which the single-line row used 35 — 51px spare.
|
||
// ★ THE FOOTER STICKER STRADDLES THE BANNER'S BOTTOM EDGE. The header (Show on map) sits on the
|
||
// top border of the poster box; the footer print options did not sit on the bottom one. The
|
||
// footer now imitates the header, on the edge like a piece of tape.
|
||
//
|
||
// top:100% + translate(-50%,-50%) centres it on that edge at ANY height, which matters because
|
||
// the row's height is NOT fixed: it holds two to four controls and grows with translated
|
||
// labels (MEASURED 68px in en/ro, 54px in zh). A fixed margin-top left it hanging below the
|
||
// edge and moved again whenever the height changed. Centring is self-correcting, so no
|
||
// constant here has to track the locale.
|
||
//
|
||
// ⚠ top:100% IS THE BANNER'S BOTTOM EDGE ONLY BECAUSE .pfs LIVES INSIDE .main. As a SIBLING
|
||
// of the banner it resolved against the CARD instead — MEASURED card bottom 453 vs banner
|
||
// bottom 426, so the row centred on a line 27px past the banner, touching it nowhere. The
|
||
// re-parenting and the centring are one fix; neither works alone.
|
||
//
|
||
// Half the sticker lands on the banner, so the banner must reserve white space for it or the
|
||
// sticker sits on the map: that is --banner-pad-bottom on .main, and the gate asserts the
|
||
// result (map-covered 0) rather than trusting the arithmetic.
|
||
//
|
||
// ⚠ DO NOT ADD margin-top HERE. `margin:0` in the shorthand below RESETS any margin-top
|
||
// declared above it — a shorthand always beats an earlier longhand in the same block. A
|
||
// margin-top:-1px sat here computing to 0px while I believed it was doing the positioning;
|
||
// a mutation test (set it to 26px, expect the row to move) proved it inert.
|
||
// ⚠ NOT CENTRED — and this is a MEASURED correction, not a preference. translate(-50%,-50%)
|
||
// puts HALF the row's height onto the banner, and the row's height is CONTENT-DEPENDENT: it
|
||
// wraps to two lines in ro/ru and to three at 390px once the labels wrap. Measured ro/390px:
|
||
// 27 of 27 stickers overlapping the map by 4px, while en and zh were clean at the same
|
||
// padding. No fixed --banner-pad-bottom can be right for every locale x width, because half
|
||
// of a variable is a variable.
|
||
//
|
||
// So the OVERLAP is the constant instead of the centre: the sticker's top is pinned a fixed
|
||
// --tape-overlap above the banner's bottom edge, and any extra height grows DOWNWARD into
|
||
// the grid's row gap, where there is room. The amount sitting on the banner is now the same
|
||
// in all 11 locales at every width, which is what --banner-pad-bottom has to clear.
|
||
'.pfs{position:absolute;top:calc(100% - var(--tape-overlap,16px));' +
|
||
'left:50%;transform:translateX(-50%);' +
|
||
// ⚠ min-width:0 ON THE FIELDSET ITSELF. A <fieldset> gets `min-width:min-content` from the
|
||
// UA stylesheet, which OVERRIDES width and max-width — measured: computed width 431px
|
||
// inside a 212px card while the rule said 82%. That is the real reason `width:82%` read as
|
||
// correct in source for so long and the row still burst the card.
|
||
'width:82%;max-width:82%;min-width:0;box-sizing:border-box;margin:0;padding:0 4px 3px;' +
|
||
'border:1px solid rgba(0,0,0,.14);' +
|
||
'border-radius:2px;background:#fff;display:flex;flex-wrap:wrap;gap:2px;justify-content:center;' +
|
||
'box-shadow:0 1px 4px rgba(0,0,0,.10)}' +
|
||
// Let every control shrink inside the row rather than push it open. overflow:hidden +
|
||
// text-overflow keeps a squeezed label readable instead of spilling.
|
||
// ⚠ NO ELLIPSIS CRUSH. min-width:0 alone let three 76-79px labels share 172px — 40px each,
|
||
// about five characters, so 'Prayer List' and 'Poster Map' both read 'Pra…'. MEASURED: 81
|
||
// of 108 controls clipped. Fitting a row by hiding what its buttons say is not fitting it.
|
||
// flex-basis:auto + wrap lets each control take the width its LABEL needs and flow to a
|
||
// second line, which there is room for now that the row grows downward.
|
||
// ⚠ nowrap HERE IS A CLIPPING BUG IN THE NARROW x LONG-LABEL CORNER, and it only appeared once
|
||
// the per-row default became 2: at 390px that halves the card to ~170px, and Romanian's
|
||
// "Pachet de cartonașe" (133px) plus two more controls cannot share it on one line.
|
||
// MEASURED after the merge: 54 of 81 controls clipped at ro/390px, en and zh clean — long
|
||
// labels and a narrow card, exactly the pair each change was individually fine with.
|
||
// The standing ruling on this wall is that a label too long for its box WRAPS; nowrap forced
|
||
// the browser to clip instead. Wrapping costs a second line in the one corner that needs it.
|
||
'.pfs > .pbtn{flex:0 1 auto;min-width:0;white-space:normal;overflow-wrap:break-word;' +
|
||
'text-align:center;line-height:1.2}' +
|
||
'.is-dark .pfs{background:#132019;border-color:rgba(110,231,183,.24)}' +
|
||
// ── THE REVEALED SIZE ROW ────────────────────────────────────────────────────
|
||
// Sits directly under the print row, still hanging into the grid's 88px row-gap.
|
||
// ⚠ THE OFFSET IS MEASURED, NOT GUESSED. Both rows share .main as containing block, so
|
||
// "under .pfs" means top:100% + .pfs's own height — and that height is NOT a constant:
|
||
// the row wraps to a second line in long-label locales (ro/ru measured at 2 lines).
|
||
// A hard-coded 20px would tuck the size row under the print row in exactly those
|
||
// locales. toggleDeck() reads the live offsetHeight and writes it to --pstocks-gap.
|
||
// ⚠ THE OFFSET IS THE FULL ROW HEIGHT MINUS A FIXED STICKER OVERHANG, NOT HALF THE ROW.
|
||
// It used to be `calc(var(--pstocks-gap) / 2)`, reasoning that the footer sticker half-overhangs
|
||
// the banner edge so half the row's height is the right clearance. That holds ONLY while the row
|
||
// is ONE LINE. On mobile it wraps to two, and half of a doubled height is no longer half a row —
|
||
// it lands squarely on the labels, so the size options covered the words "Card deck" themselves.
|
||
// Reported from mobile 2026-09-28.
|
||
// A FRACTION OF A VARIABLE HEIGHT IS THE BUG: the sticker's overhang is a CONSTANT (~11px), so
|
||
// subtracting a constant is correct at any number of lines, while halving is correct at exactly
|
||
// one. Same shape as the box-sizing error on the packet's table rows — a value that happens to be
|
||
// right at one size and is silently wrong at every other.
|
||
'.pstocks{top:100%;margin-top:calc(var(--pstocks-gap,20px) - 11px);width:92%;max-width:92%;' +
|
||
'box-sizing:border-box;padding:3px;border-radius:2px;background:#fff;' +
|
||
'display:flex;flex-wrap:wrap;gap:3px;justify-content:center;' +
|
||
'box-shadow:0 1px 4px rgba(0,0,0,.10);cursor:default}' +
|
||
'.is-dark .pstocks{background:#132019;border-color:rgba(110,231,183,.24)}' +
|
||
// ★ The open card outranks its siblings. 5 sits above any card (all of which are z-index:auto)
|
||
// and below the sticky section header at 6, so a revealed tape never covers the header the
|
||
// reader uses to scroll — a menu that hides its own navigation is a worse bug than the one
|
||
// being fixed here.
|
||
'.card.has-tape-open{z-index:5}' +
|
||
// The tape is OPAQUE and casts a shadow so it reads as sitting ON the card below rather than
|
||
// being tangled with it. Without a solid background the next card's text shows through, which is
|
||
// the other half of what "underlapping" looks like on a phone.
|
||
'.pstocks{box-shadow:0 2px 10px rgba(0,0,0,.18)}' +
|
||
'.pfs > legend{margin:0 auto;padding:0 5px;font-size:8px;text-transform:uppercase;' +
|
||
'letter-spacing:.6px;font-weight:700;color:#8a93a0}' +
|
||
'.is-dark .pfs > legend{color:#7f9c8c}' +
|
||
'.pbtn{position:relative;flex:1;border:0;background:none;color:#5b6672;font:inherit;' +
|
||
'font-size:10.5px;font-weight:600;padding:2px 6px 3px;cursor:pointer;white-space:nowrap;' +
|
||
'transition:color .15s}' +
|
||
'.pbtn::after{content:"";position:absolute;left:6px;right:6px;bottom:0;height:2px;' +
|
||
'border-radius:2px;background:transparent;transition:background .15s}' +
|
||
'.pbtn:hover{color:#10b981}' +
|
||
'.pbtn:hover::after{background:#10b981}' +
|
||
// The deck button is a DISCLOSURE, so it says so while open — a caret that flips,
|
||
// plus the green the row already uses for "active", so the reader can see which
|
||
// card's sizes are showing without reading aria-expanded.
|
||
'.pbtn-deck[aria-expanded="true"]{color:#10b981}' +
|
||
'.pbtn-deck::before{content:"\\25B8";display:inline-block;margin-right:3px;font-size:8px;' +
|
||
'transition:transform .15s}' +
|
||
'.pbtn-deck[aria-expanded="true"]::before{transform:rotate(90deg)}' +
|
||
// Size choices. These are BUTTONS, not <option>s — a native dropdown here would
|
||
// collide with the other dropdowns on the host page.
|
||
// Each one both picks the size and prints, so it is styled as an action.
|
||
// ⚠ NO nowrap HERE, and this is a MEASURED correction rather than a preference. With
|
||
// white-space:nowrap the two landscape labels rendered 161px inside a 169px row and CLIPPED
|
||
// ("Card 3.5 x 2.5 in (landscape)", "Postcard 6 x 4 in (landscape)"). The standing ruling on
|
||
// this wall is that a label too long for its box WRAPS — never an ellipsis, never a crop. So
|
||
// each size takes a full row of its own when it needs one; flex-wrap on .pstocks gives it the
|
||
// line, and min-width:0 lets the text actually break instead of forcing the box open.
|
||
'.pstock{flex:0 1 auto;min-width:0;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' +
|
||
'background:#fff;color:#5b6672;font:inherit;font-size:9.5px;font-weight:600;' +
|
||
'padding:2px 5px;cursor:pointer;line-height:1.25;white-space:normal;overflow-wrap:break-word}' +
|
||
'.pstock:hover{border-color:#10b981;color:#10b981}' +
|
||
'.pstock.is-on{border-color:#10b981;color:#0b7a5b;background:rgba(16,185,129,.10)}' +
|
||
// ★ WCAG 2.5.8 Target Size (Minimum), AA = 24x24 CSS px. MEASURED at 390x844 on the UPG-100
|
||
// wall: .pbtn 76x20, .pstock 188x20, .tape-h 289x22 -- 135 controls under the minimum on one
|
||
// phone screen, and the same components fail on three other maps because they live in library/.
|
||
// SCOPED TO (pointer:coarse) ON PURPOSE: 2.5.8 is a TOUCH ergonomics rule, and these three
|
||
// controls sit in a row whose height was tuned by eye -- the tape must
|
||
// straddle the border without reaching the poster's blue outline, with ~9px of artwork clearance
|
||
// left. Growing them for the mouse would spend 2px of that clearance to solve a problem a mouse
|
||
// does not have, on a wall that is another owner's map. A finger gets the bigger target; the
|
||
// desktop keeps the geometry that was signed off. Safe to grow because both stickers are now
|
||
// pinned by --tape-overlap rather than by their centres: the overlap onto the banner is the
|
||
// constant, so extra height grows into empty space instead of walking over the artwork.
|
||
// ⚠ STATE THE REQUIREMENT, DO NOT COMPUTE IT. The first attempt here grew the padding instead
|
||
// -- 2px a side on .pbtn, 2px on .pstock -- and MEASURED 23.7px and 23.0px: under the minimum by
|
||
// a third of a pixel and by one, because the height is padding PLUS a line box derived from a
|
||
// 10.5px font, and that line box is the renderer's number, not mine. min-height IS the 24 the
|
||
// spec asks for, it survives a font swap or a locale with taller glyphs, and a reader can check
|
||
// it against WCAG without doing arithmetic. Both controls centre their own label, so no padding
|
||
// change is needed to go with it.
|
||
'@media (pointer:coarse){' +
|
||
'.pbtn{min-height:24px}' +
|
||
'.pstock{min-height:24px}' +
|
||
'.tape-h{min-height:24px}' +
|
||
'}' +
|
||
// previewAction='print' only. The zoom-in cursor says "this opens something", and
|
||
// the focus-visible outline is not optional: it is how a keyboard user sees where
|
||
// they are on a control that is otherwise just a picture.
|
||
'.img.is-clickable{cursor:zoom-in}' +
|
||
'.img.is-clickable:hover{outline:2px solid #38bdf8;outline-offset:-2px}' +
|
||
'.img.is-clickable:focus-visible{outline:2px solid #38bdf8;outline-offset:-2px}' +
|
||
'.is-dark .pstock{background:#12211a;border-color:#2c4a3b;color:#9fb3a8}' +
|
||
'.is-dark .pbtn{color:#9fb3a8}' +
|
||
// The section is the sticky header's CONTAINING BLOCK: a sticky element only
|
||
// stays pinned while its parent is in view, so each header releases exactly when
|
||
// its own countries have scrolled past and the next region takes over. That
|
||
// hand-off is what makes it read as a TreeTable rather than one floating bar.
|
||
// Bottom margin is on a spacer INSIDE the section (not the section itself), or
|
||
// the header would stay pinned over 56px of empty paper.
|
||
// ★ EACH SECTION IS A STACKING CONTEXT, EARLIER ONES ON TOP (2026-09-30, reported from a phone): the LAST
|
||
// poster of a section opened its Prayer-List / Card-deck menu UNDER the next section's header, so its
|
||
// buttons could not be tapped (measured at 390: Card-deck 3/5 and 4/5 tappable). The open card's z-index 5
|
||
// sits deliberately below its OWN sticky header (6), but the menu hangs past the section's end, where the
|
||
// NEXT section's header is also 6. The template gives each section a z-index that decreases down the wall:
|
||
// inside a section nothing changes, and an earlier section's hanging menu paints over the later header.
|
||
// (A card z-index above 6 would instead paint an open menu over its own pinned header when scrolled.)
|
||
'.sec{position:relative;max-width:1180px;margin:0 auto;padding-bottom:44px}' +
|
||
// TREETABLE ROW HEADER — STICKY, so it stays visible while its own children
|
||
// scroll past: while a reader scrolls through a section's posters, its header stays
|
||
// in view, so the section can be collapsed at any point instead of scrolled to its end.
|
||
//
|
||
// WHY position:sticky RATHER THAN A COMPONENT LIBRARY. The reference was
|
||
// PrimeVue's TreeTable, but doxa-maps ships no component framework and this wall
|
||
// renders INSIDE A SHADOW ROOT — a third-party table would bring its own global
|
||
// stylesheet, its own theme layer and a dependency, to reproduce two behaviours
|
||
// (expand a row, freeze its header) that are ~6 lines of CSS here. The
|
||
// INTERACTION is what was asked for, not the package.
|
||
//
|
||
// Sticky needs an opaque background or the cards scroll THROUGH the header, and
|
||
// it must not be inside a transformed/overflow-hidden ancestor. `.wall` is the
|
||
// scroll container, so `top:0` is relative to it.
|
||
// OPAQUE, not translucent. A sticky bar with a see-through background lets the
|
||
// poster cards scroll visibly THROUGH the region name — measured on the country
|
||
// wall, where Asia's header sat on top of four posters at once. backdrop-filter
|
||
// is not enough; the fill has to be solid. A soft shadow separates the pinned bar
|
||
// from the content passing under it.
|
||
'.sec-head{display:flex;align-items:baseline;gap:10px;margin:0 2px 14px;' +
|
||
'padding:11px 10px 9px;border-bottom:1px solid rgba(0,0,0,.10);' +
|
||
'position:sticky;top:0;z-index:6;background:#f6f7f9;' +
|
||
'box-shadow:0 6px 10px -8px rgba(0,0,0,.35);border-radius:4px 4px 0 0}' +
|
||
'.is-dark .sec-head{border-bottom-color:rgba(255,255,255,.12);background:#0a1410;' +
|
||
'box-shadow:0 6px 10px -8px rgba(0,0,0,.7)}' +
|
||
// ★ THE TRANSPARENT STRIP ABOVE A PINNED HEADER: while scrolling, the posters showed through a
|
||
// transparent band between the pinned header and the top of the page.
|
||
//
|
||
// CAUSE, MEASURED at 1440px: `position:sticky; top:0` pins to the scroll container's PADDING
|
||
// BOX, not its border box. .wall has padding-top:22px, so a pinned header comes to rest 22px
|
||
// down and the cards keep scrolling through those 22px. Probed at scrollTop 400/900/1600 the
|
||
// element painting in that strip was IMG.img every time — literally posters flying past above
|
||
// the header, exactly as reported.
|
||
//
|
||
// WHY A PSEUDO-ELEMENT AND NOT `top:-22px`. Pulling the header up by the padding does pin it
|
||
// flush, but sticky clips nothing: the header's own first 22px would then sit outside the
|
||
// container and its text would be cut off. And padding-top on the header itself would move the
|
||
// text down instead of covering anything. A ::before that extends the SAME opaque fill upward
|
||
// covers the strip while leaving both layout and the header's own position untouched — the
|
||
// `outline` vs `border` distinction again: paint outside the box, do not re-lay-out.
|
||
//
|
||
// Height comes from --wall-pad-top (set on .wall), never a second literal 22px.
|
||
// Full-bleed on x: the header is inset 2px by its own margin and the strip has to cover the
|
||
// container's 18px side padding too, or the posters show through at the edges.
|
||
'.sec-head::before{content:"";position:absolute;left:-20px;right:-20px;' +
|
||
'bottom:100%;height:var(--wall-pad-top,0px);background:inherit;pointer-events:none}' +
|
||
// Nothing to cover when the wall is not the scroller (--wall-pad-top is 0px there), but be
|
||
// explicit: an embedded wall must never paint a bar over its host.
|
||
'.wall--embedded .sec-head::before{display:none}' +
|
||
// A collapsed row is a plain bar — no bottom rule reaching for content that is
|
||
// not there.
|
||
'.sec-head.is-shut{border-bottom-color:transparent;margin-bottom:6px}' +
|
||
// The whole head is the hit target when collapsible — a 15px caret is a poor one.
|
||
// NOTE `background` is NOT reset here. A <button> needs its UA background
|
||
// cleared, but this selector is MORE SPECIFIC than the .sec-head rule above and
|
||
// therefore silently won the cascade — so `background:none` here made the sticky
|
||
// header transparent and the poster cards scrolled visibly through the region
|
||
// name. The base rule owns the fill; this rule owns only button chrome.
|
||
// ★ calc(100% - 4px), NOT 100%: the head keeps the 2px side margins of .sec-head, so a full-width button
|
||
// overflowed the wall's content box by 4px. Its full-bleed ::before (left/right -20px) then reached 2px past
|
||
// the wall on each side, and a collapsible wall scrolled sideways by 4px at any width (scrollWidth 394 in 390).
|
||
'.sec-head.is-toggle{cursor:pointer;user-select:none;width:calc(100% - 4px);text-align:left;' +
|
||
'border-top:0;border-left:0;border-right:0;font:inherit;color:inherit}' +
|
||
'.sec-head.is-toggle:hover .sec-name{color:#10b981}' +
|
||
'.sec-caret{display:inline-block;width:12px;font-size:10px;opacity:.55;' +
|
||
'transition:transform .18s ease}' +
|
||
'.sec-caret.is-open{transform:rotate(90deg)}' +
|
||
'.sec-name{margin:0;font-size:15px;font-weight:700;letter-spacing:.01em}' +
|
||
'.sec-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}' +
|
||
// Poster aspect is 3ft x 3ft SQUARE — the real print ratio, so the wall previews
|
||
// the actual artefact rather than an arbitrary box.
|
||
// ★ THE BANNER IS NOT THE MAP — and until now it effectively was.
|
||
// This box is a BANNER that DISPLAYS the map, the way a label displays a design. A banner taped
|
||
// to a wall gets a sticker at the top and one at the bottom, each straddling its edge: a slight
|
||
// overlap over the banner, never over the banner's content, because tape over the map would
|
||
// spoil it.
|
||
//
|
||
// MEASURED before this change, and it is the whole problem: banner 240-426, map 241-425.
|
||
// whiteSpaceTopOfBanner 1px, whiteSpaceBottomOfBanner 1px. The map filled the banner edge to
|
||
// edge, so there was NO banner surface for a sticker to sit on — the bottom sticker could
|
||
// only ever hang off into space (measured straddle: 1px on, 67px off) or cover the map.
|
||
// Both wrong, and no amount of moving the sticker fixes it; the BANNER had to grow.
|
||
//
|
||
// --banner-pad is that room. .img is object-fit:contain, which fits the CONTENT box, so
|
||
// padding reserves banner surface and the map shrinks to sit inside it rather than spilling.
|
||
// The bottom gets MORE than the top because the footer sticker is taller than the header one
|
||
// and, per the brief, should overhang the page BELOW the banner more than it covers the
|
||
// banner itself ("probably an equal or more overlap").
|
||
//
|
||
// ⚠ aspect-ratio applies to the BORDER box here (box-sizing is border-box in this root), so
|
||
// the square is the BANNER's shape, not the map's. The map inside is correspondingly shorter
|
||
// than it is wide, which is correct: the banner is the thing being framed.
|
||
//
|
||
// overflow IS VISIBLE, DELIBERATELY. .pfs lives INSIDE this banner so its top:100% resolves
|
||
// here; under `hidden` the print row was clipped to nothing. Nothing else needs the clip:
|
||
// .img is object-fit:contain (never paints past its box) and .ph is a smaller flow box.
|
||
// ⚠ DERIVED FROM THE STICKER HEIGHTS, NOT CHOSEN — and derived with SLACK, because the
|
||
// heights are fractional. A sticker centred on the banner edge puts HALF its height onto the
|
||
// banner, so the reserved white space must exceed that half.
|
||
// attempt 1: 10px/26px by eye vs 34px/68px stickers -> map covered by 6px and 8px.
|
||
// attempt 2: exactly half + 4px (21px/38px) -> STILL 1px of overlap on 27 cards in en and
|
||
// ro but NOT zh, where the print row is 54px rather than 68px. The rects are
|
||
// fractional (68.x), so half of them rounds past an integer constant; and the
|
||
// row's height is LOCALE-DEPENDENT, which no fixed number can track.
|
||
// Hence generous slack rather than a tighter constant: 12px past the widest measured half.
|
||
// The gate asserts the relationship (map-covered 0), so if a future locale wraps the row to
|
||
// three lines this fails loudly instead of quietly shaving the map.
|
||
// Now that the OVERLAP is a constant (--tape-overlap on .card) rather than half a
|
||
// content-dependent height, the banner only has to reserve that overlap plus daylight —
|
||
// the same number in every locale at every width. It was 29/50 chasing half of a row that
|
||
// is 68px in en, 54px in zh and taller still when ro wraps at 390px; that chase is what put
|
||
// 4px of sticker on the map. 16 + 8 of daylight, both ends.
|
||
'.main{--banner-pad-top:24px;--banner-pad-bottom:24px;' +
|
||
'position:relative;aspect-ratio:1/1;border:1px solid #dfe3e7;overflow:visible;' +
|
||
'padding:var(--banner-pad-top) 10px var(--banner-pad-bottom);' +
|
||
'background:#fbfcfd;display:flex;align-items:center;justify-content:center}' +
|
||
'.is-dark .main{background:#0c1712;border-color:rgba(255,255,255,.08)}' +
|
||
// CONTAIN, not cover: `cover` crops to fill, which on a square sheet sliced off
|
||
// the title and the footer scripture.
|
||
'.img{width:100%;height:100%;object-fit:contain;display:block}' +
|
||
'.ph{padding:14px 10px;text-align:center}' +
|
||
'.ph-label{font-size:12.5px;font-weight:700;line-height:1.3}' +
|
||
'.ph-count{margin-top:5px;font-size:11px;opacity:.7}' +
|
||
'.ph-note{margin-top:10px;font-size:9.5px;text-transform:uppercase;letter-spacing:.09em;opacity:.45}' +
|
||
// NOT-YET card — amber, deliberately distinct from a failed load.
|
||
'.ph.is-soon{background:rgba(245,158,11,.06)}' +
|
||
'.ph-soon{margin-top:9px;font-size:10px;line-height:1.45;color:#8a6414}' +
|
||
'.is-dark .ph-soon{color:#e8b862}' +
|
||
'.modal-scrim{position:fixed;inset:0;z-index:1000;background:rgba(8,12,16,.55);' +
|
||
'display:grid;place-items:center;padding:22px}' +
|
||
'.modal{width:min(340px,100%);background:#fff;color:#0f1419;border-radius:14px;' +
|
||
'padding:20px 22px;box-shadow:0 10px 40px rgba(0,0,0,.35);text-align:center}' +
|
||
'.is-dark .modal{background:#132019;color:#e5e7eb}' +
|
||
'.modal-kind{font-size:10px;text-transform:uppercase;letter-spacing:.1em;opacity:.55}' +
|
||
'.modal-title{margin:3px 0 14px;font-size:15px;font-weight:700;line-height:1.3}' +
|
||
'.bar{height:5px;border-radius:99px;background:rgba(0,0,0,.10);overflow:hidden}' +
|
||
'.is-dark .bar{background:rgba(255,255,255,.12)}' +
|
||
'.bar-fill{height:100%;background:#10b981;border-radius:99px;transition:width .25s ease}' +
|
||
'.modal-phase{margin-top:10px;font-size:12.5px;font-weight:600}' +
|
||
'.modal-hint{margin-top:6px;font-size:11px;line-height:1.5;opacity:.62}' +
|
||
'.modal-soon{font-size:13px;line-height:1.5;color:#5f5849;margin:6px 0 14px}' +
|
||
'.modal-err{font-size:12.5px;line-height:1.55;color:#b42318;margin-bottom:14px}' +
|
||
'.is-dark .modal-err{color:#fda29b}' +
|
||
'.modal-btn{border:1px solid rgba(0,0,0,.14);background:transparent;color:inherit;' +
|
||
'font:inherit;font-size:12.5px;padding:6px 18px;border-radius:8px;cursor:pointer}' +
|
||
'.modal-btn:hover{background:rgba(0,0,0,.06)}',
|
||
'doxa-poster-wall',
|
||
|
||
+ '/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,\n and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */\n@media (prefers-reduced-motion: reduce) {\n .pw-card, .pw-grid, .pw-overlay, .pw-btn { transition: none !important; animation: none !important; }\n}\n'
|
||
)
|
||
// ★ EACH SECTION IS A PosterSection (the head + its cards), drawn from THIS wall's state and actions, provided here.
|
||
const { collapsible, columnsControl, headerAction, hidePrayerListWhenSoon, instanceId, previewSrc, sectionHeads, unit, unsupported } = toRefs(props)
|
||
provide(POSTER_WALL_CTX, {
|
||
collapsible, columnsControl, headerAction, hidePrayerListWhenSoon, instanceId, previewSrc, sectionHeads, unit, unsupported, PER_ROW_AUTO, T, canPrintRows, countOf, deckKey, deckOpenFor, emit, exportPoster, generated, gridStyle, hud, isCollapsed, lastShown, missing, onPreviewError, packetVariants, perRowChoices, perRowShown, pickedStock, previewIsControl, previewUrl, printDeckAt, printPacketAs, sectionCount, setPreviewEl, showOnMap, stockLabel, stockOptions, t, toggleDeck, toggleSection,
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="wallEl" class="wall" :class="{ 'is-dark': isDark, 'wall--embedded': embedded }">
|
||
<template v-if="!sectionOnly">
|
||
<h2 class="wall-h">{{ heading }}</h2>
|
||
<div v-if="tag" class="wall-tag">{{ tag }}</div>
|
||
<p v-if="intro" class="wall-sub" v-html="intro"></p>
|
||
</template>
|
||
|
||
<!-- Each section stacks ABOVE the ones after it (z-index decreasing down the wall; see .sec), so the LAST
|
||
poster's open menu, which hangs past its section's end, sits over the next section's header. -->
|
||
<PosterSection v-for="(sec, i) in sections" :key="sec.name" :sec="sec" :z="sections.length - i" />
|
||
|
||
<div v-if="exportState" ref="scrimRef" :style="scrimStyle" class="modal-scrim" @click.self="(exportState.phase === 'error' || exportState.phase === 'ready') && dismissExport()">
|
||
<div class="modal" role="dialog" aria-live="polite">
|
||
<div class="modal-kind">{{ exportState.kind }}</div>
|
||
<div class="modal-title">{{ exportState.label }}</div>
|
||
|
||
<template v-if="exportState.phase === 'error'">
|
||
<div class="modal-err">{{ exportState.message }}</div>
|
||
<button type="button" class="modal-btn" @click="dismissExport">{{ t('buttons.close') }}</button>
|
||
</template>
|
||
|
||
<template v-else-if="exportState.phase === 'ready'">
|
||
<div class="modal-soon">{{ exportState.message }}</div>
|
||
<button type="button" class="modal-btn" @click="exportState.action && exportState.action()">{{ hud('wall.hud.printNow', 'Print') }}</button>
|
||
</template>
|
||
|
||
<template v-else-if="exportState.phase === 'soon'">
|
||
<div class="modal-soon">{{ exportState.message }}</div>
|
||
<button type="button" class="modal-btn" @click="dismissExport">{{ t('buttons.close') }}</button>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div class="bar"><div class="bar-fill" :style="{ width: exportState.pct + '%' }"></div></div>
|
||
<div class="modal-phase">{{ exportState.message }}<template v-if="exportState.stepN"> · {{ hud('wall.hud.stepOf', 'step {n} of {m}', { n: exportState.stepN, m: exportState.stepM }) }}</template><template v-if="elapsedS >= 1"> · {{ hud('wall.hud.elapsed', '{s} s', { s: elapsedS }) }}</template></div>
|
||
<div class="modal-hint">
|
||
{{ t('buttons.renderingLocal') }}
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|