diff --git a/documentation/how-to.md b/documentation/how-to.md
index 72d11f83c..87b0e186a 100644
--- a/documentation/how-to.md
+++ b/documentation/how-to.md
@@ -415,6 +415,7 @@ every third-party script is pinned with an integrity hash (`library/constants/cd
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `
diff --git a/plugin-designs/bible-posters/index.html b/plugin-designs/bible-posters/index.html
new file mode 100644
index 000000000..232e3fca0
--- /dev/null
+++ b/plugin-designs/bible-posters/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ doxa-maps · bible-posters staging
+
+
+
+
+
+
bible-posters · staging
+
An entire book of the Bible on one poster, in the languages of the unreached.
+ Text is fetched at render time from public-domain sources; no Bibles are stored.
+
+
+
+
+
+
+
diff --git a/plugin-designs/bible-posters/index.js b/plugin-designs/bible-posters/index.js
new file mode 100644
index 000000000..757a92145
--- /dev/null
+++ b/plugin-designs/bible-posters/index.js
@@ -0,0 +1,32 @@
+/**
+ * plugin-designs/bible-posters/index.js
+ *
+ * Bundle entry for the Bible Posters generator (Posterverse → Experimental).
+ * Folder name = bundle name = tag. One flat profile: bible-posters.vue.
+ * Builds to public/dx-maps/bible-posters/bible-posters.js.
+ *
+ * This plugin has NO map — it is a text/poster generator — so it registers no colour strategies and
+ * needs no Mapbox token; it honours only the reader's LOCALE (interface language) through the provided
+ * i18n instance, exactly like every other plugin.
+ *
+ *
+ *
+ */
+import { defineMapElement } from '@map/elements/defineMapElement.js'
+
+const profileModules = import.meta.glob('./*.vue')
+
+const BiblePostersElement = defineMapElement({
+ profileModules,
+ configureApp: (app, { pinia, i18n }) => {
+ // defineMapElement already installs Pinia + i18n and provides the i18n instance to ProfileLoader
+ // (that is how a host sets the interface language). This hook is only for extra app-level provides;
+ // Bible Posters needs none.
+ },
+})
+
+if (typeof customElements !== 'undefined' && !customElements.get('bible-posters')) {
+ customElements.define('bible-posters', BiblePostersElement)
+}
+
+export default BiblePostersElement
diff --git a/plugin-designs/bible-posters/src/biblePoster.js b/plugin-designs/bible-posters/src/biblePoster.js
new file mode 100644
index 000000000..688cbd9b0
--- /dev/null
+++ b/plugin-designs/bible-posters/src/biblePoster.js
@@ -0,0 +1,156 @@
+/**
+ * biblePoster.js — the Bible-book poster layout engine (pure: plan maths + an HTML string, no Vue).
+ *
+ * Modeled on wall-of-people-groups/src/blessPoster.js (the wall's engine): a `fit()` search for the
+ * largest readable type size, an HTML document for the shared in-place print frame, and a screen model
+ * that mirrors the print exactly ("what the wall shows is what prints"). The CONTENT differs — a book
+ * is flowing text, not a card grid — so the body is CSS multi-column justified text rather than a grid.
+ *
+ * The approved layout: a bold BOOK-NAME header, then MULTIPLE COLUMNS about the width of a phone
+ * screen, ~7-10 words per line, JUSTIFIED, the whole book visible at once. RTL scripts flow right-to-left.
+ *
+ * ⚠ This is a MODEL of the layout (character-advance estimates), not a measurement of the rendered page.
+ * A headless plan proves the arithmetic; only a browser check proves the page. Every number the model
+ * uses is named here so a rendered measurement can correct the constants.
+ */
+
+const PT = 1 / 72 // one point in inches
+export const MIN_READABLE_PT = 6 // a book printed below this is grey noise on a poster
+export const MAX_PT = 16
+export const PX_IN = 96 // screen world: CSS px per inch (matches blessPoster.screenPoster)
+
+// Layout targets (the approved design, made numeric — correct against a rendered measurement).
+const WORDS_PER_LINE = 8 // "~7-10 words per line"
+const AVG_WORD_CHARS = 6 // 5 letters + a space, averaged
+const CHARS_PER_LINE = WORDS_PER_LINE * AVG_WORD_CHARS // 48
+const CHAR_EM = 0.5 // average glyph advance as a fraction of the em (justified serif)
+const LINE_LH = 1.32 // body line-height
+const COL_GAP_IN = 0.28 // gap between columns
+const MARGIN_IN = 0.5 // poster margin (all sides)
+const HEAD_FRAC = 0.10 // header band as a fraction of poster height (min 0.6in)
+const CREDIT_IN = 0.32 // credit-line band height
+const CONTENT_SLACK = 1.15 // justification + word-break slack over raw character count
+
+/** Column width (inches) that yields ~WORDS_PER_LINE words per line at this type size. */
+function colWidthIn(pt) { return CHARS_PER_LINE * CHAR_EM * pt * PT }
+
+/**
+ * Plan a book onto a poster of widthIn × heightIn. Returns the chosen columns + type size and whether
+ * the whole book fits at a readable size.
+ * @param {{widthIn:number,heightIn:number}} size
+ * @param {{charCount:number,verseCount:number,blocks:Array}} book
+ */
+export function planBiblePoster(size, book) {
+ const W = size.widthIn - 2 * MARGIN_IN
+ const headH = Math.max(0.6, size.heightIn * HEAD_FRAC)
+ const H = size.heightIn - 2 * MARGIN_IN - headH - CREDIT_IN
+ const headingCount = (book.blocks || []).filter((b) => b.kind === 'heading').length
+ // effective characters the body must hold: text + verse-number glyphs + a line's worth per heading
+ const effective = Math.ceil(book.charCount * CONTENT_SLACK + book.verseCount * 3 + headingCount * CHARS_PER_LINE)
+
+ const capacityAt = (pt) => {
+ const colW = colWidthIn(pt)
+ const cols = Math.max(1, Math.floor((W + COL_GAP_IN) / (colW + COL_GAP_IN)))
+ const lineIn = pt * LINE_LH * PT
+ const linesPerCol = Math.max(0, Math.floor(H / lineIn))
+ return { pt, colW, cols, linesPerCol, capacity: cols * linesPerCol * CHARS_PER_LINE }
+ }
+
+ // Largest type size (0.5pt steps) whose capacity holds the whole book.
+ let chosen = null
+ for (let pt = MAX_PT; pt >= MIN_READABLE_PT - 0.001; pt -= 0.5) {
+ const c = capacityAt(pt)
+ if (c.capacity >= effective) { chosen = c; break }
+ }
+ const fits = !!chosen
+ const plan = chosen || capacityAt(MIN_READABLE_PT) // if nothing fits, report the floor's numbers
+ return {
+ fits, effective,
+ widthIn: size.widthIn, heightIn: size.heightIn,
+ marginIn: MARGIN_IN, headHIn: headH, creditHIn: CREDIT_IN,
+ contentWIn: W, contentHIn: H,
+ cols: plan.cols, colWIn: plan.colW, colGapIn: COL_GAP_IN,
+ fontPt: plan.pt, lineHeight: LINE_LH, linesPerCol: plan.linesPerCol,
+ capacity: plan.capacity,
+ }
+}
+
+/** Given a book and a set of candidate sizes, the smallest that fits (for the "which size" hint). */
+export function fitLadder(sizes, book) {
+ return sizes.map((s) => {
+ const p = planBiblePoster({ widthIn: s.widthIn, heightIn: s.heightIn }, book)
+ return { id: s.id, label: s.label, fits: p.fits, fontPt: p.fontPt, cols: p.cols }
+ })
+}
+
+const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
+
+/** The body HTML: headings and verses in reading order; verse numbers are bidi-isolated superscripts. */
+function bodyHtml(book) {
+ let html = ''
+ let lastChapter = 0
+ for (const b of book.blocks) {
+ if (b.kind === 'heading') { html += '
'
+}
+
+/** The screen model (WYSIWYG): the same poster, scaled to CSS px. Returns { css, html, worldW, worldH }. */
+export function screenBiblePoster(book, size, texts = {}) {
+ const dir = texts.dir || book.dir || 'ltr'
+ const plan = planBiblePoster(size, book)
+ const css = posterCss(plan, dir, PX_IN)
+ const html = '
' + esc(book.name) + '
'
+ + '
' + bodyHtml(book) + '
'
+ + '
' + esc(texts.credit || '') + '
'
+ return { css, html, dir, lang: texts.lang || '', plan, worldW: size.widthIn * PX_IN, worldH: size.heightIn * PX_IN }
+}
diff --git a/plugin-designs/bible-posters/src/bibleSource.js b/plugin-designs/bible-posters/src/bibleSource.js
new file mode 100644
index 000000000..62fd5560e
--- /dev/null
+++ b/plugin-designs/bible-posters/src/bibleSource.js
@@ -0,0 +1,114 @@
+/**
+ * bibleSource.js — the RENDER-TIME text client for the Free Use Bible API (bible.helloao.org).
+ *
+ * Fetches a whole book on demand and returns a normalized structure the renderer can lay out.
+ * NOTHING is written to the repo or public/: whole Bibles are never stored (the licence gate and the
+ * §4 spec). A small in-memory (per-bundle, per-session) cache avoids re-fetching a book the reader
+ * just looked at; it is discarded on reload.
+ *
+ * The API (verified 2026-09-30):
+ * {base}/{translationId}/books.json → { books: [{ id, commonName, numberOfChapters, ... }] }
+ * {base}/{translationId}/{BOOK}/{chapter}.json → { chapter: { content: [ {type:'verse',number,content:[...]}
+ * | {type:'heading',content:[...]} ] } }
+ * CORS is open (Access-Control-Allow-Origin: *) and no key is required.
+ */
+
+export const API_BASE = 'https://bible.helloao.org/api'
+
+const booksCache = new Map() // translationId -> [{id, name, chapters, order}]
+const bookCache = new Map() // `${translationId}/${bookId}` -> normalized book
+
+async function getJson(url, signal) {
+ const res = await fetch(url, { signal, headers: { accept: 'application/json' } })
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url)
+ return res.json()
+}
+
+/** The translation's book table: id → localized name + chapter count. Used to build the picker and to
+ * know which of the 12 packet books this translation actually has (OT books need an OT translation). */
+export async function fetchBooks(translationId, { signal } = {}) {
+ if (booksCache.has(translationId)) return booksCache.get(translationId)
+ const d = await getJson(`${API_BASE}/${translationId}/books.json`, signal)
+ const raw = Array.isArray(d) ? d : (d.books || [])
+ const books = raw.map((b) => ({
+ id: b.id,
+ name: b.commonName || b.name || b.id,
+ chapters: b.numberOfChapters || 0,
+ order: b.order || 0,
+ }))
+ booksCache.set(translationId, books)
+ return books
+}
+
+/** One verse/heading item → plain text. Verse `content` is an array of strings and, occasionally,
+ * objects (poetry lines, notes). We keep the readable text and drop note apparatus. */
+function itemText(item) {
+ const parts = Array.isArray(item.content) ? item.content : [item.content]
+ return parts
+ .map((p) => {
+ if (typeof p === 'string') return p
+ if (p && typeof p === 'object') return p.text || (Array.isArray(p.content) ? p.content.filter((x) => typeof x === 'string').join(' ') : '')
+ return ''
+ })
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+/**
+ * Fetch an ENTIRE book: every chapter, flattened to an ordered list of blocks.
+ * Returns { bookId, name, dir, chapters: N, blocks: [{kind:'heading'|'verse', chapter, number?, text}],
+ * verseCount, charCount }. `onProgress(done, total)` fires per chapter.
+ */
+export async function fetchBook(translationId, bookId, { signal, onProgress, dir = 'ltr', concurrency = 4 } = {}) {
+ const key = `${translationId}/${bookId}`
+ if (bookCache.has(key)) { onProgress?.(1, 1); return bookCache.get(key) }
+
+ const books = await fetchBooks(translationId, { signal })
+ const meta = books.find((b) => b.id === bookId)
+ if (!meta) throw new Error(`${translationId} has no book ${bookId}`)
+ const total = meta.chapters
+ const chapterBlocks = new Array(total) // keep chapter order regardless of fetch order
+ let done = 0
+
+ // One book is one atomic fetch: a chapter failure ABORTS the siblings (never a half book), while still
+ // honouring the caller's own signal. An internal controller chains to it so we can abort from inside.
+ const internal = new AbortController()
+ const onExt = () => internal.abort()
+ if (signal) { if (signal.aborted) internal.abort(); else signal.addEventListener('abort', onExt, { once: true }) }
+
+ async function one(ch) {
+ try {
+ const d = await getJson(`${API_BASE}/${translationId}/${bookId}/${ch}.json`, internal.signal)
+ const content = (d.chapter || d).content || []
+ const blocks = []
+ for (const item of content) {
+ if (item.type === 'heading') { const t = itemText(item); if (t) blocks.push({ kind: 'heading', chapter: ch, text: t }) }
+ else if (item.type === 'verse') { const t = itemText(item); if (t) blocks.push({ kind: 'verse', chapter: ch, number: item.number, text: t }) }
+ }
+ chapterBlocks[ch - 1] = blocks
+ done += 1
+ onProgress?.(done, total)
+ } catch (e) { internal.abort(); throw e } // stop the other workers on the first failure
+ }
+
+ // bounded-concurrency pool over chapters 1..total
+ const queue = []
+ for (let ch = 1; ch <= total; ch++) queue.push(ch)
+ const workers = new Array(Math.min(concurrency, total)).fill(0).map(async () => {
+ while (queue.length && !internal.signal.aborted) { const ch = queue.shift(); await one(ch) }
+ })
+ try { await Promise.all(workers) }
+ finally { if (signal) signal.removeEventListener('abort', onExt) }
+
+ const blocks = chapterBlocks.flat().filter(Boolean)
+ let verseCount = 0, charCount = 0
+ for (const b of blocks) { if (b.kind === 'verse') verseCount++; charCount += b.text.length }
+ const book = { bookId, name: meta.name, dir, chapters: total, blocks, verseCount, charCount }
+ bookCache.set(key, book)
+ return book
+}
+
+/** Test seam: clear the session caches. */
+export function _clearCache() { booksCache.clear(); bookCache.clear() }
diff --git a/plugin-designs/bible-posters/src/books.js b/plugin-designs/bible-posters/src/books.js
new file mode 100644
index 000000000..240e987b2
--- /dev/null
+++ b/plugin-designs/bible-posters/src/books.js
@@ -0,0 +1,39 @@
+/**
+ * books.js — the 12 books the UPG-100 prayer packet cites, in packet-citation order.
+ *
+ * The Bible Posters generator shows an ENTIRE BOOK on one poster. These 12 are the starting set
+ * (the approved starting set): every book referenced by the en packet in
+ * library/scripts/posters/packet-strings.json. The verse list is the index; the poster offers the book.
+ *
+ * `id` — the USFM/OSIS book id the Free Use Bible API (bible.helloao.org) uses in its URLs.
+ * `en` — an English fallback label for the UI picker BEFORE the fetched translation is known;
+ * the POSTER HEADER itself uses the fetched translation's own `commonName` (in the text
+ * language), so a reader sees "يوحنا" / "Johannes", never the English name.
+ * `testament` — 'OT' books (Genesis, Joshua) exist only in a translation that HAS an Old Testament,
+ * which narrows the offered language set; the other 10 are New Testament and fetch from
+ * any NT-only translation too.
+ * `chaptersHint` — the chapter count for the WEB text (eng_web), used only to size a progress bar and a
+ * fit pre-estimate; the real count comes from the translation's own books.json at render.
+ */
+export const PACKET_BOOKS = [
+ { id: 'ACT', en: 'Acts', testament: 'NT', chaptersHint: 28 },
+ { id: 'MAT', en: 'Matthew', testament: 'NT', chaptersHint: 28 },
+ { id: 'JHN', en: 'John', testament: 'NT', chaptersHint: 21 },
+ { id: 'MRK', en: 'Mark', testament: 'NT', chaptersHint: 16 },
+ { id: 'LUK', en: 'Luke', testament: 'NT', chaptersHint: 24 },
+ { id: 'HEB', en: 'Hebrews', testament: 'NT', chaptersHint: 13 },
+ { id: 'GEN', en: 'Genesis', testament: 'OT', chaptersHint: 50 },
+ { id: '1JN', en: '1 John', testament: 'NT', chaptersHint: 5 },
+ { id: 'COL', en: 'Colossians', testament: 'NT', chaptersHint: 4 },
+ { id: '1CO', en: '1 Corinthians', testament: 'NT', chaptersHint: 16 },
+ { id: '1PE', en: '1 Peter', testament: 'NT', chaptersHint: 5 },
+ { id: 'JOS', en: 'Joshua', testament: 'OT', chaptersHint: 24 },
+]
+
+export const BOOK_BY_ID = Object.fromEntries(PACKET_BOOKS.map((b) => [b.id, b]))
+
+/** Books this translation can serve: OT books need a translation whose books.json includes them. */
+export function booksForTranslation(availableBookIds) {
+ const have = new Set(availableBookIds || [])
+ return PACKET_BOOKS.filter((b) => have.has(b.id))
+}
diff --git a/plugin-designs/bible-posters/src/sizes.js b/plugin-designs/bible-posters/src/sizes.js
new file mode 100644
index 000000000..1871e2403
--- /dev/null
+++ b/plugin-designs/bible-posters/src/sizes.js
@@ -0,0 +1,35 @@
+/**
+ * sizes.js — the poster size ladder for Bible Posters, taken from the SHARED table.
+ *
+ * Consumes the centrally-owned ladder (library/constants/posterSizes.js, imported as
+ * '@map/constants/posterSizes.js'); this plugin only SELECTS its subset of keys and keeps its own
+ * locale-neutral dimension labels until the shared picker (with localized names) lands. The size table
+ * is never edited here.
+ *
+ * A book is laid out on the FINISHED poster (width × height, portrait). `sheets` is the shared entry's
+ * panel count; the multi-panel PRINT path (reusing library panelSheets.js) is a later milestone — the
+ * fit MODEL uses the finished dimensions.
+ */
+import { POSTER_LADDER, resolvePosterSize } from '@map/constants/posterSizes.js'
+
+// The 5 ladder keys this plugin offers (portrait; a whole book needs the large ones). 'paper' carries
+// Letter inches in the shared table; sizing it to A4 for an A4 reader (via paperFor) is a later refinement.
+const BIBLE_KEYS = ['paper', '36x36', '36x60', '66x66', '60x96']
+
+// Locale-neutral DIMENSION labels (in/ft are unit symbols, like cm) so no English word leaks into a
+// non-English UI. Swap for the shared picker's localized names when it lands.
+const LABELS = {
+ paper: '8.5 × 11 in',
+ '36x36': '3 × 3 ft',
+ '36x60': '3 × 5 ft',
+ '66x66': '5.5 × 5.5 ft',
+ '60x96': '5 × 8 ft',
+}
+
+export const BIBLE_POSTER_SIZES = BIBLE_KEYS.map((k) => {
+ const e = resolvePosterSize(k) || POSTER_LADDER[k]
+ return { id: k, key: k, label: LABELS[k], widthIn: e.inches.w, heightIn: e.inches.h, sheets: e.sheets.count }
+})
+
+export const SIZE_BY_ID = Object.fromEntries(BIBLE_POSTER_SIZES.map((s) => [s.id, s]))
+export const DEFAULT_SIZE_ID = '36x60'
diff --git a/plugin-designs/bible-posters/src/translations.js b/plugin-designs/bible-posters/src/translations.js
new file mode 100644
index 000000000..8a872b6bf
--- /dev/null
+++ b/plugin-designs/bible-posters/src/translations.js
@@ -0,0 +1,63 @@
+/**
+ * translations.js — the LICENCE-VETTED text sources for the Bible Posters generator.
+ *
+ * ⚠ LICENSING GATE (this plugin's first task). A full BOOK is displayed IN FULL on a
+ * printed, white-label giveaway poster, so ONLY texts whose licence permits that may ship. This v1
+ * list is STRICT PUBLIC-DOMAIN ONLY — the exact translations the UPG-100 packet already ships as
+ * public domain, plus the WEB seed. It is safe under EITHER answer to the open licensing question
+ * (PD-only vs. a licensed/broader set), so it is what builds now.
+ *
+ * The CC-BY / CC-BY-SA expansion (the IRV family — Hindi, Tamil, Telugu, Punjabi — and the wider
+ * UPG languages) is DEFERRED pending the open licensing decision; do not add a non-public-domain text
+ * here without explicit sign-off and a per-translation licence check (eBible details + Door43),
+ * never the transport's blanket "no restrictions" claim nor eBible's Redistributable flag alone.
+ *
+ * Transport: the Free Use Bible API (bible.helloao.org) — CORS, no key, per-chapter JSON, fetched at
+ * RENDER TIME. No whole Bibles are stored in the repo or public/. Each translation carries a
+ * `licenseUrl` back to its eBible record; `credit` is the poster's printed credit line.
+ *
+ * `iso639_3` is the text language, so the picker can offer the languages of the top unreached people
+ * groups (the Gospel Resources map data keys on iso639_3) once the CC-BY-SA set is approved.
+ */
+
+/** @typedef {Object} Translation
+ * @property {string} id Free Use Bible API translation id (its URL segment)
+ * @property {string} iso639_3 text language code
+ * @property {string} language English language name (UI grouping)
+ * @property {string} name the translation's English name
+ * @property {'ltr'|'rtl'} dir text direction
+ * @property {string} licence the licence class (v1: all 'public-domain')
+ * @property {string} credit the poster credit line
+ * @property {string} licenseUrl the licence record of source
+ */
+
+/** v1 STRICT PUBLIC-DOMAIN seed — the packet's 6 + WEB. Every entry has all 66 books (OT+NT). */
+export const TRANSLATIONS = [
+ { id: 'eng_web', iso639_3: 'eng', bcp47: 'en', language: 'English', endonym: 'English', name: 'World English Bible',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'World English Bible · public domain · ebible.org · bible.helloao.org',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=eng-web' },
+ { id: 'spa_r09', iso639_3: 'spa', bcp47: 'es', language: 'Spanish', endonym: 'Español', name: 'Reina Valera 1909',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Reina Valera 1909 · dominio público · ebible.org · bible.helloao.org',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=spaRV1909' },
+ { id: 'fra_ost', iso639_3: 'fra', bcp47: 'fr', language: 'French', endonym: 'Français', name: 'French Ostervald Bible',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Bible Ostervald · domaine public · ebible.org · bible.helloao.org',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=fra_fob' },
+ { id: 'deu_l12', iso639_3: 'deu', bcp47: 'de', language: 'German', endonym: 'Deutsch', name: 'German Luther Bible 1912',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Lutherbibel 1912 · gemeinfrei · ebible.org · bible.helloao.org',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=deu1912' },
+ { id: 'rus_syn', iso639_3: 'rus', bcp47: 'ru', language: 'Russian', endonym: 'Русский', name: 'Russian Synodal Bible',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Синодальный перевод · общественное достояние · ebible.org · bible.helloao.org',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=russyn' },
+ { id: 'arb_vdv', iso639_3: 'arb', bcp47: 'ar', language: 'Arabic', endonym: 'العربية', name: 'Arabic Van Dyck Bible',
+ dir: 'rtl', licence: 'public-domain',
+ credit: 'فان دايك · ملك عام · ebible.org · bible.helloao.org',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=arb-vd' },
+]
+
+export const TRANSLATION_BY_ID = Object.fromEntries(TRANSLATIONS.map((t) => [t.id, t]))
+export const DEFAULT_TRANSLATION_ID = 'eng_web'
diff --git a/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue b/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue
index 9e623ab96..9ba1093be 100644
--- a/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue
+++ b/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue
@@ -374,7 +374,10 @@ const NOT_EXPERIMENTAL = new Set([
// posterverse is the OTHER capability-5 shell (the poster plugins). Shown here it would be a
// whole second rail inside this pane: a shell does not nest a shell, for the same reason this
// shell excludes itself above.
- 'posterverse'
+ 'posterverse',
+ // bible-posters lives on POSTERVERSE's Experimental shelf, and only there: one poster plugin, shown
+ // once, in the shell that gathers the poster plugins.
+ 'bible-posters'
])
// ── EXPERIMENTAL ORDER = KINGDOM IMPACT, not the alphabet ────────────────────────────────
diff --git a/plugin-designs/posterverse/posterverse.meta.json b/plugin-designs/posterverse/posterverse.meta.json
index 115ba40ef..c8a6b3dc4 100644
--- a/plugin-designs/posterverse/posterverse.meta.json
+++ b/plugin-designs/posterverse/posterverse.meta.json
@@ -1,11 +1,12 @@
{
- "_comment": "Declares the composability tier. 'nested-mfe-spa' = capability 5: an app shell whose single element renders a vertical-tab rail of whole poster plugins, each mounted as its own real element from the manifest (no iframes). The tier is DERIVED from `composes` at tree generation (composing a cap-4 makes this cap-5) and this `type` is checked against it. `composes` lists every row in src/tabs.js whose bundle exists; a provisional row (bible-posters) joins the list in the SAME push as its bundle, since an unresolved ref fails check:capability-derived and a missing one fails check:posterverse-shelf.",
+ "_comment": "Declares the composability tier. 'nested-mfe-spa' = capability 5: an app shell whose single element renders a vertical-tab rail of whole poster plugins, each mounted as its own real element from the manifest (no iframes). The tier is DERIVED from `composes` at tree generation (composing a cap-4 makes this cap-5) and this `type` is checked against it. `composes` lists every row in src/tabs.js. A new row's ref joins this list in the SAME push as its bundle, since an unresolved ref fails check:capability-derived and a missing one fails check:posterverse-shelf.",
"type": "nested-mfe-spa",
"composes": [
"my-upg-100-list/upg-100-mfe",
"country-posters/country-posters-mfe",
"poster-design/poster-design-mfe",
- "wall-of-people-groups/wall-of-people-groups"
+ "wall-of-people-groups/wall-of-people-groups",
+ "bible-posters/bible-posters"
],
"label": "Posterverse (vertical-tab app shell: every poster plugin)"
}
diff --git a/plugin-designs/posterverse/src/tabs.js b/plugin-designs/posterverse/src/tabs.js
index 2e53cd35e..c251b6605 100644
--- a/plugin-designs/posterverse/src/tabs.js
+++ b/plugin-designs/posterverse/src/tabs.js
@@ -10,10 +10,10 @@
* appears, whatever the manifest holds.
*
* ★ A ROW WHOSE BUNDLE IS NOT IN THE TREE IS PRUNED, not painted dead. That is how a row can be listed
- * before its plugin lands: Bible Posters is declared with PROVISIONAL ids and stays invisible until a
- * bundle of that id exists. Its owner confirms the ids; change the `bundle`/`profile` here to match,
- * never the row `id` (the id is the stable key for ?tab= links). A provisional row's `composes` entry
- * lands in the same push as its bundle (check:posterverse-shelf arm D).
+ * before its plugin lands: mark it `provisional: true` and it stays invisible until a bundle of that id
+ * exists. Its owner confirms the ids; change the `bundle`/`profile` here to match, never the row `id`
+ * (the id is the stable key for ?tab= links). A new row's `composes` entry lands in the same push as
+ * its bundle (check:posterverse-shelf arm D).
*
* ★ MOUNT THE COMPOSITE, NOT A LEAF. The profile named here is the one that mounts. my-upg-100-list and
* country-posters each ship a leaf map, a leaf wall and the capability-4 composite of the two; the
@@ -32,8 +32,7 @@ export const TABS = [
{ id: 'country-posters', section: 'posters', icon: 'map', bundle: 'country-posters', profile: 'country-posters-mfe' },
{ id: 'poster-design', section: 'posters', icon: 'poster', bundle: 'poster-design', profile: 'poster-design-mfe' },
{ id: 'wall-of-people-groups', section: 'experimental', icon: 'flask', bundle: 'wall-of-people-groups', profile: 'wall-of-people-groups' },
- // Provisional ids: no bible-posters bundle exists yet, so this row is pruned until one does.
- { id: 'bible-posters', section: 'experimental', icon: 'flask', bundle: 'bible-posters', profile: 'bible-posters', provisional: true },
+ { id: 'bible-posters', section: 'experimental', icon: 'flask', bundle: 'bible-posters', profile: 'bible-posters' },
]
// Bundles that must never be a Posterverse row. The allow-list above already excludes them; this set
diff --git a/public/README.md b/public/README.md
index ad63eaff5..237643526 100644
--- a/public/README.md
+++ b/public/README.md
@@ -2,7 +2,7 @@
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
-- **`dx-maps/`** — the output folder: 27 plugin bundles and 41 pages, self-contained.
+- **`dx-maps/`** — the output folder: 28 plugin bundles and 42 pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
- `dx-maps//.js` — a plugin's bundle (a custom element; load it with a `
+ bible-posters · bible-posters
+
+
+
+
+
+
+