diff --git a/documentation/how-to-build-mobile-apps-from-plugins/README.md b/documentation/how-to-build-mobile-apps-from-plugins/README.md new file mode 100644 index 000000000..cb008c9f2 --- /dev/null +++ b/documentation/how-to-build-mobile-apps-from-plugins/README.md @@ -0,0 +1,131 @@ +# How to build a mobile app from a plugin + +The plugins in this repo are web pages, and an Android app can wrap them with no new code. This guide +turns one into an app you can install, using **Deep Research** as the worked example. It takes one +command once the tools are installed. + +## Why start with Deep Research + +Deep Research is the plugin that is already a whole app. It is the app shell in +`plugin-designs/doxa-maps-spa/` (its `app` preset). One screen holds all seven production maps: +- Big Picture; +- Simple Research; +- Regions; +- Prayer Progress; +- UPG-100 List; +- Cities with Internet; +- Gospel Resources. + +The experimental maps are there too. It is made for phones: +- a round **☰** at the bottom left switches maps; +- each legend starts folded; +- the ⓘ opens as a sheet over the map; +- posters, packets and cards print from the phone. + +It draws with the free MapLibre engine, so it needs no Mapbox key. And it needs no new code: the app +is the same built folder the web uses, opened on Deep Research instead of on the Plugin Studio. + +## The short version + +```bash +bun install +bun run check:mobile # checks the app is wired up; needs no Android tools +bun run phone:deep-research # makes the app and installs it on your phone or emulator +``` + +Then open **Doxa Maps** on the phone. + +## What you need + +- This repo, cloned, and [Bun](https://bun.sh). The README's quick start covers both. +- **Android Studio**, which brings the Android SDK and `adb`, and **JDK 17**. +- An Android phone plugged in over USB with USB debugging turned on, or an emulator started from + Android Studio. `adb devices` should list it. + +iOS is not set up yet. It needs a Mac with Xcode, and its project would live beside the Android one, +at `deployment/capacitor/ios`. + +## Step by step + +### 1. Check the wiring (no Android tools needed) + +```bash +bun run check:mobile +``` + +This copies the built folder, `public/dx-maps/`, into the Android project (Capacitor's `cap sync`). +It then checks that the copy landed. It should end with `OK webDir copied`. + +### 2. Make the app and install it + +```bash +bun run phone:deep-research +``` + +This runs, in order: + +1. `cap sync android`: copies the built folder into the Android project. +2. The start-page step, `APP_START=doxa-maps-spa/doxa-maps-spa/app`: the app opens Deep Research + instead of the Plugin Studio. +3. `cap:verify`: stops if the copy is empty, which would show as a blank white screen. +4. `./gradlew assembleDebug`: builds `app-debug.apk`. +5. `adb install -r`: installs it on the phone. + +It uses the built folder as it is committed, so nothing is rebuilt. On a fresh clone that folder is +the current build. + +### 3. Open it + +The app is called **Doxa Maps**. On a phone it opens on Simple Research, because Big Picture needs a +larger screen. The round **☰** at the bottom left lists every map. + +## Changed a plugin first? + +Rebuild the one you changed, then run the same command: + +```bash +BUNDLE= bun x vite build && bun run tree && bun run fix:sri +bun run phone:deep-research +``` + +## Any other plugin as an app + +It is the same command with another start page. Name the page's folder inside `public/dx-maps/`, as +`/` or `//`: + +```bash +APP_START=doxa-simple-map/doxa-simple-map bun run phone:from-tree # Prayer Progress +bun run phone:from-tree # unset: the UPG-100 list +``` + +Pick a page that works on a phone; Big Picture, for one, is desktop only. The start-page step +refuses a page that is not in the build, and one whose links would not resolve at the app's root. + +## Make it your own + +- **The name under the icon:** `app_name` in + `deployment/capacitor/android/app/src/main/res/values/strings.xml`. +- **Icon and splash screen:** the images in `deployment/capacitor/android/app/src/main/res/`. +- **Version:** `versionCode` and `versionName` in `deployment/capacitor/android/app/build.gradle`. +- **A store release:** it needs its own app id and a signing key. The key never goes in the repo. + +## If something goes wrong + +- **A blank white screen:** the copy is empty. Run `bun run check:mobile`; it must say + `OK webDir copied`. +- **The app opens the Plugin Studio:** the start-page step did not run. Run + `bun run phone:deep-research` again. +- **`adb` finds no device:** turn on USB debugging and accept the prompt on the phone, or start an + emulator. +- **Maps without their data:** several maps load live data, so the phone needs internet. +- **Using a Mapbox token:** the app's address is `https://localhost`. A token service must allow that + address (CORS). Deep Research's `app` preset uses MapLibre and needs no token. + +## How it works + +The app is a WebView around the same built folder the web serves; `capacitor.config.json` points at +it (`webDir`). The start-page step, `internal/set-app-start-page.mjs`, copies the chosen page to the +folder's root in the app's copy and fixes its links. So the app opens that page, and the web folder +itself is untouched. The Plugin Studio stays inside the app at `studio.html`. + +One build, three outputs: the web folder, this Android app, and the desktop app (see the README). diff --git a/documentation/how-to.md b/documentation/how-to.md index a3462dd52..72d11f83c 100644 --- a/documentation/how-to.md +++ b/documentation/how-to.md @@ -303,13 +303,16 @@ Contracts and gotchas (PROFILES shape, tag-vs-bundle names, `--no-token`): **Mobile is separate from `bun run build`.** The default build produces **only** the `dx-maps` web folder — the embeddable maps (and the SPA, if configured). It never touches Capacitor. The Android/iOS app is an **opt-in** step that *reuses* that web build: Capacitor's `webDir` points at -`public/` (`appId: life.doxa.maps`, `appName: "Doxa Maps"`), so `cap sync` wraps the already-built +`public/dx-maps/` (`appId: life.doxa.maps`, `appName: "Doxa Maps"`), so `cap sync` wraps the already-built folder into a native app. You never need to build mobile to ship the web maps. +**The quickest app is one plugin, with no rebuild:** `bun run phone:deep-research` installs Deep Research +as an app. The whole tutorial: [how to build mobile apps from plugins](how-to-build-mobile-apps-from-plugins/README.md). + **Android** (project is committed; needs Android Studio + a JDK): ```bash -bun run apk:debug # build web → cap sync → verify → assemble a debug APK +bun run apk:debug # build web → cap sync → verify → assemble a debug APK (the full build asks for BUILD_ALLOW=1) bun run apk:install # adb install onto a connected device/emulator ``` diff --git a/internal/check-packet-a1-structure.mjs b/internal/check-packet-a1-structure.mjs index 58a702cfc..6b1c32c59 100644 --- a/internal/check-packet-a1-structure.mjs +++ b/internal/check-packet-a1-structure.mjs @@ -3,9 +3,9 @@ * check-packet-a1-structure — the prayer packet is built to its specified structure. * * FULL DETAIL is four front pages, the profiles, four back pages: - * map · triangle · life calendar · % evangelical · profiles… · bounded set · centered set · wheel · points + * map · triangle · life calendar · % evangelical · profiles… · bounded set · centered set · wheel · points · word * LIGHTWEIGHT is two front pages, the profiles, two back pages: - * map · triangle · profiles… · wheel · points + * map · triangle · profiles… · wheel · points · word * and the pages that carry the design are the ones described: page 1 is the zone's map on a VERTICAL * page with a title saying which set it belongs to; page 2 is one inverted triangle narrowing from * every people group to THIS packet; the wheel is the Metacamp Module 1 wheel, in its colours and its @@ -75,7 +75,8 @@ function sequence(html) { : /sheet-worksheet/.test(cls) ? 'worksheet' : /wheel-title/.test(seg) ? 'wheel' : /class="card"/.test(seg) ? 'profiles' - : /p4-passage|points_title|Prayer Points/.test(seg) ? 'points' + : /word-title/.test(seg) ? 'word' // point 4's passage in full + the Father's-heart list (2026-09-30) + : /points-title|points_title|Prayer Points/.test(seg) ? 'points' : `?${cls.trim()}` if ((kind === 'profiles' || kind === 'worksheet') && out[out.length - 1] === kind) return // one entry per run out.push(kind) @@ -85,8 +86,8 @@ function sequence(html) { const liftedMap = R.liftMap(MAP_ONLY_SHEET) const full = R.buildPacket(zone, groups, new Map(), liftedMap, withK, christian, 'full', sets) const light = R.buildPacket(zone, groups, new Map(), liftedMap, withK, christian, 'lightweight', sets) -const FULL = 'map · triangle · life · christian · profiles · bounded · centered · wheel · points' -const LIGHT = 'map · triangle · worksheet · wheel · points' +const FULL = 'map · triangle · life · christian · profiles · bounded · centered · wheel · points · word' +const LIGHT = 'map · triangle · worksheet · wheel · points · word' ok(sequence(full) === FULL, `full-detail order is\n ${sequence(full)}\n expected\n ${FULL}`, `full detail: ${FULL}`) ok(sequence(light) === LIGHT, `lightweight order is\n ${sequence(light)}\n expected\n ${LIGHT}`, `lightweight: ${LIGHT}`) diff --git a/internal/check-packet-browser.mjs b/internal/check-packet-browser.mjs index f408edd59..c4a8df3c2 100644 --- a/internal/check-packet-browser.mjs +++ b/internal/check-packet-browser.mjs @@ -201,7 +201,7 @@ console.log(` prayer points : ${r.points} / 12`) console.log(` wheel segments : ${r.wheel} (${r.wheelLabels}/12 labelled)`) console.log(` map (page 1) : ${r.mapGeoPaths} geography paths`) console.log(` printed artefact : ${(pdfBytes/1024/1024).toFixed(1)}MB, ${pdfFonts} embedded font(s)`) -console.log(` point 4's list : ${r.heartItems} readings`) +console.log(` point 4 : Mark 5 in full ${r.mark5Len} chars, ${r.heartItems} readings`) // beside a PASS for as long as this gate has existed. The ASSERTION below always read // the right key, so the requirement was protected; only the display was blind. console.log(` metacamp.org : ${r.metacamp}`) @@ -253,7 +253,12 @@ if (pdfFonts === 0) { } if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`) // Mark 5:1-20 in full, by LENGTH rather than an English proper noun. -// Point 4 prints THE HEAVENLY FATHER'S HEART (seven readings) in place of Mark 5:1-20 in full (2026-09-30). +// Point 4's sheet prints Mark 5:1-20 in full AND THE HEAVENLY FATHER'S HEART (seven readings) (2026-09-30). +if (r.mark5Len < 1200) { + fails.push(r.mark5Len === 0 + ? 'Mark 5:1-20 is NOT printed (no .p4-passage block)' + : `Mark 5:1-20 looks truncated: ${r.mark5Len} chars`) +} if (r.heartItems !== 7) { fails.push(r.heartItems === 0 ? "point 4's reading list is NOT printed (no .p4-heart block)" diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs index 028d98a28..c3f888762 100644 --- a/internal/check-packet-render.mjs +++ b/internal/check-packet-render.mjs @@ -405,6 +405,7 @@ const r = await page.evaluate(() => { // readings. Counted by STRUCTURE (the .p4-heart hook and its items), which holds in any language. heartItems: document.querySelectorAll('.p4-heart li').length, heartTitle: (document.querySelector('.p4-heart-h')?.textContent || '').trim().length, + v19Len: (document.querySelector('.p4-passage .p4-v19')?.textContent || '').trim().length, // ⚠ COUNT THE POINTS. The sheet-sum invariant below already required a points PAGE — // and a page with three points on it is still exactly one sheet, so a packet headed // "12 Prayer Points" printed THREE and every number this gate asserted stayed correct. @@ -538,6 +539,16 @@ if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal // (zh is typically ~40% shorter in characters). // Point 4 prints THE HEAVENLY FATHER'S HEART: a heading and seven readings, each addressed in the reader's // own Bible. It replaced Mark 5:1-20 in full (2026-09-30), so the passage is no longer asserted; the list is. +// ★ AND THE PASSAGE IS BACK IN FULL (2026-09-30), on its own sheet after the points, with verse 19 in bold. +// English is 2,141 chars; 1,200 is a floor no truncation or placeholder survives, with room for languages +// that render the same text more compactly. +const MARK5_MIN = 1200 +if (r.mark5Len < MARK5_MIN) { + fails.push(r.mark5Len === 0 + ? 'Mark 5:1-20 is NOT printed in full (a requirement for point 4): no .p4-passage block' + : `Mark 5:1-20 looks truncated: ${r.mark5Len} chars, expected at least ${MARK5_MIN}`) +} +if (!r.v19Len) fails.push('Mark 5:19 is not in bold (no .p4-v19 inside the passage)') const HEART_ITEMS = 7 if (r.heartItems !== HEART_ITEMS || !r.heartTitle) { fails.push(r.heartItems === 0 @@ -703,7 +714,9 @@ const setsSheets = r.setsSheets || 0 const wsPages = (n) => (n <= 0 ? 0 : 1 + Math.ceil(Math.max(0, n - 20) / 24)) const listSheets = r.cards ? Math.ceil(r.cards / PER_PAGE) : wsPages(r.wsRows) if (r.wsRows && r.wsSheets !== wsPages(r.wsRows)) fails.push(`${r.wsRows} worksheet rows should take ${wsPages(r.wsRows)} page(s) but the packet has ${r.wsSheets}`) -const expectedSheets = 1 + lifeSheets + funnelSheets + listSheets + setsSheets + 2 +// ⚠ + 3, NOT + 2, SINCE 2026-09-30: the back matter is the wheel, the points AND point 4's own sheet (Mark 5:1-20 +// in full + the Father's-heart list, wordPage). Counted as a known term for the same reason as the rest. +const expectedSheets = 1 + lifeSheets + funnelSheets + listSheets + setsSheets + 3 // ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ────────────────────────────────── // A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a // christian page whose shading silently fell to zero would all still be one sheet. 5,200 is the @@ -876,7 +889,7 @@ console.log(` funnel (page 2) : ${r.funnel : 'absent' + (EXPECT_FUNNEL ? ' (REQUIRED — see FAIL above)' : ' (not required)')}`) console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`) console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`) -console.log(` point 4's list : ${r.heartItems} readings (the full-passage block: ${r.mark5Len} chars, retired)`) +console.log(` point 4 : Mark 5 in full ${r.mark5Len} chars, verse 19 bold ${r.v19Len} chars, ${r.heartItems} readings`) console.log(` metacamp.org : ${r.metacamp}`) console.log(` map (page 1) : ${r.mapFaults.length ? `${r.mapFaults.join('; ')}${expectMap ? '' : ` [declared deliberate: ${/\bnomap\b/.test(build) ? 'data-packet-build="' + build + '"' : 'PACKET_EXPECT_MAP=0'}]`}` diff --git a/internal/check-packet-strings-parity.mjs b/internal/check-packet-strings-parity.mjs index 83ed5b00e..e9032a23f 100644 --- a/internal/check-packet-strings-parity.mjs +++ b/internal/check-packet-strings-parity.mjs @@ -55,6 +55,7 @@ const DECLARED_ABSENT = { // wearing more confidence. This entry shrinks as each locale's text is cited, not as a batch. point4_passage: 'Mark 5:1-20 — awaiting a CITED public-domain translation for hi/zh/pt/it/ro', point4_passage_credit: 'the attribution line for the passage above', + point4_passage_bold: 'verse 19, verbatim from the passage above (so only where that passage is on file)', // ── The new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by // the evangelical share of these groups' population, and the funnel's equation sidebar + tip. // ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten diff --git a/internal/check-poster-prints.mjs b/internal/check-poster-prints.mjs index bf80b86b4..cdfcef16d 100644 --- a/internal/check-poster-prints.mjs +++ b/internal/check-poster-prints.mjs @@ -163,10 +163,16 @@ if (!/no records provider/i.test(r.noProvMsg)) { // The packet is the product's core artefact: map page + people pages + points + // wheel. Assert its SHAPE, not merely that bytes were produced. With 2 groups // that is 1 map + 1 people page + points + wheel = 4 sheets. -if (r.packetSheets !== 4) fails.push(`the packet printed ${r.packetSheets} sheets, expected 4`) +// + point 4's own sheet (Mark 5:1-20 in full + the Father's-heart list) since 2026-09-30: 5. +if (r.packetSheets !== 5) fails.push(`the packet printed ${r.packetSheets} sheets, expected 5`) if (r.packetCards !== 2) fails.push(`the packet printed ${r.packetCards} people cards, expected 2`) if (r.packetWheel !== 12) fails.push(`the prayer wheel has ${r.packetWheel} segments, expected 12`) -// Point 4 prints THE HEAVENLY FATHER'S HEART, seven readings, in place of Mark 5:1-20 in full (2026-09-30). +// Point 4's sheet prints Mark 5:1-20 in full AND THE HEAVENLY FATHER'S HEART, seven readings (2026-09-30). +if (r.packetMark5Len < 1200) { + fails.push(r.packetMark5Len === 0 + ? 'the packet is missing Mark 5:1-20 in full (no .p4-passage block)' + : `Mark 5:1-20 looks truncated: ${r.packetMark5Len} chars`) +} if (r.packetHeartItems !== 7) { fails.push(r.packetHeartItems === 0 ? "the packet is missing point 4's reading list (no .p4-heart block)" @@ -177,7 +183,7 @@ if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`) console.log(` list print : ${r.listRows} rows, checkbox=${r.listHasCheckbox}`) console.log(` without provider : phase=${r.noProvPhase} "${r.noProvMsg.slice(0, 60)}"`) console.log(` packet : ${r.packetSheets} sheets, ${r.packetCards} cards, ` - + `${r.packetWheel} wheel segments, point 4 list=${r.packetHeartItems}`) + + `${r.packetWheel} wheel segments, Mark5=${r.packetMark5Len} chars, point 4 list=${r.packetHeartItems}`) console.log(` page errors : ${errs.length}`) if (fails.length) { diff --git a/internal/check-share-policy.mjs b/internal/check-share-policy.mjs index 3d61a5619..24bd3256b 100644 --- a/internal/check-share-policy.mjs +++ b/internal/check-share-policy.mjs @@ -68,6 +68,7 @@ const POLICY = { 'key-cities': 'none', // not published 'template-bundle': 'none', // the STARTER, not published: a copy adds ShareButton when it becomes a real map 'all-people-groups': 'own-button', // was debt-no-share; ShareButton added 2026-09-24 + 'regions-dashboard': 'own-button', // a map since its phone popup rules (2026-09-30); its one ShareButton // ⚠ prayer-walk-planner took a SECOND LOOK before this flipped, and the reason is worth // keeping: it builds a PERSONAL WALKING ROUTE from polygons the reader draws, so a share // link that silently drops them arrives as an empty planner — a button that reliably diff --git a/internal/set-app-start-page.mjs b/internal/set-app-start-page.mjs index 1ea1f8eb8..f1a95ce46 100644 --- a/internal/set-app-start-page.mjs +++ b/internal/set-app-start-page.mjs @@ -26,7 +26,11 @@ * verified means there is no second copy of that hash to drift. Only the relative depth changes. * * Usage: node internal/set-app-start-page.mjs [--check] - * --check assert the app root is already the UPG-100 page; do not write. Exit 1 if not. + * --check assert the app root is already the start page; do not write. Exit 1 if not. + * APP_START=/[/] start on another plugin page instead of the UPG-100 list, + * as the tree lays it out: APP_START=doxa-maps-spa/doxa-maps-spa/app is Deep Research + * (`bun run phone:deep-research`). Unset, the app opens the UPG-100 list, as before. + * How-to: documentation/how-to-build-mobile-apps-from-plugins/README.md */ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' @@ -39,13 +43,21 @@ const CHECK = process.argv.includes('--check') // The assets tree `cap sync` writes. NOT public/ — see the header. const APP_PUBLIC = join(ROOT, 'deployment/capacitor/android/app/src/main/assets/public') const APP_ROOT = join(APP_PUBLIC, 'index.html') -// The showcase leaf, inside the served tree. +// The showcase leaf, inside the served tree: the UPG-100 list unless APP_START names another page. const TREE = TREE_NAME -const LEAF_REL = `my-upg-100-list/upg-100-mfe/index.html` // the app's web root IS the tree (webDir public/) +const DEFAULT_START = 'my-upg-100-list/upg-100-mfe' +const START = (process.env.APP_START || DEFAULT_START).trim().replace(/^\/+|\/+$/g, '') +if (!/^[a-z0-9_-]+(\/[a-z0-9_-]+){1,2}$/i.test(START)) { + console.error(`APP-START-PAGE: FAIL — APP_START must be /[/], got "${START}".`) + process.exit(1) +} +const NAME = START === DEFAULT_START ? 'the UPG-100 list' : START +const LEAF_REL = `${START}/index.html` // the app's web root IS the tree (webDir public/) const LEAF = join(APP_PUBLIC, LEAF_REL) // A marker so --check can tell the app page apart from the studio page without parsing HTML. -const MARKER = `` +// The default keeps its original text, so an app built before APP_START existed still checks. +const MARKER = `` if (!existsSync(APP_PUBLIC)) { console.log('APP-START-PAGE: SKIP — no synced assets tree.') @@ -62,23 +74,25 @@ if (!existsSync(LEAF)) { if (CHECK) { const cur = existsSync(APP_ROOT) ? readFileSync(APP_ROOT, 'utf8') : '' if (cur.includes(MARKER)) { - console.log('APP-START-PAGE: PASS — the app opens the UPG-100 list.') + console.log(`APP-START-PAGE: PASS — the app opens ${NAME}.`) process.exit(0) } - console.error('APP-START-PAGE: FAIL — the app root is NOT the UPG-100 page.') + console.error(`APP-START-PAGE: FAIL — the app root is NOT ${NAME}.`) console.error(cur.includes(': a recipient opening the app meets the dev studio.' - : ' No UPG-100 marker found.') + : ` No start-page marker for ${START} found.`) console.error(' Fix: node internal/set-app-start-page.mjs (after cap:sync).') process.exit(1) } // ── REWRITE THE LEAF FOR ONE LEVEL UP ──────────────────────────────────────── -// The leaf lives at /my-upg-100-list/upg-100-mfe/index.html and reaches its bundle with -// `../my-upg-100-list.js`. At the app root that same relative path resolves ABOVE the web root. -// Every leaf-relative reference gains the leaf's own directory prefix; absolute and protocol URLs -// (the Mapbox CDN tags) are left alone. -const LEAF_DIR = `my-upg-100-list/upg-100-mfe/` +// The leaf lives at //index.html (the UPG-100 list: my-upg-100-list/upg-100-mfe/) and +// reaches its bundle with `../.js`. At the app root that same relative path resolves ABOVE +// the web root. Every leaf-relative reference gains the leaf's own directory prefix; absolute and +// protocol URLs (the Mapbox CDN tags) are left alone. An inline "assetsBase":"../../assets" is not +// rewritten and needs no rewrite: a path that climbs past the web root stops AT it, and the tree's +// assets/ is there. +const LEAF_DIR = `${START}/` const html = readFileSync(LEAF, 'utf8') const rebased = html.replace( /\b(src|href)=(["'])(?!https?:|data:|#|\/)([^"']+)\2/g, @@ -110,7 +124,7 @@ const STUDIO_KEEP = join(APP_PUBLIC, 'studio.html') if (existsSync(APP_ROOT) && !existsSync(STUDIO_KEEP)) copyFileSync(APP_ROOT, STUDIO_KEEP) writeFileSync(APP_ROOT, rebased.replace(//i, `\n ${MARKER}`)) -console.log('APP-START-PAGE: the app now opens the UPG-100 list.') +console.log(`APP-START-PAGE: the app now opens ${NAME}.`) console.log(` from ${LEAF_REL}`) console.log(` wrote assets/public/index.html (${referenced.length} reference(s) rebased, all present)`) console.log(' the studio page is kept at assets/public/studio.html') diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index 31bee572f..f0e05250b 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -1146,52 +1146,38 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // ── PAGE n+1: the 12 prayer points ────────────────────────────────────────── // ★ LARGER TYPE (2026-09-30: the points read too small in print). The sheet prints at 0.544 (1500px → - // 8.5in), so the old 17px body was about 7pt on paper. Point 4's reading list below is far shorter than - // the full passage it replaces, and that room goes to the twelve: body 17 → 22px, heading 19 → 24px, - // references 14 → 17px, and 13 → 16px between points. class="points-body" is a TEST HOOK: the whole page must still fit ONE sheet - // in every locale (its scrollHeight within its clientHeight). - const PT = { head: 24, sub: 20, text: 22, refs: 17, gap: 16 }; + // 8.5in), so the old 17px body was about 7pt on paper. Point 4's passage now has its own sheet + // (wordPage, next), so the twelve get the room: body 17 → 22px, heading 19 → 24px, references + // 14 → 17px, more space between points. class="points-body" is a TEST HOOK: the page must still fit + // ONE sheet in every locale (its scrollHeight within its clientHeight). + const PT = { head: 24, sub: 20, text: 22, refs: 17, gap: 24 }; + // ⚠ ISOLATE EACH REFERENCE FOR BIDI. Scripture references stay in English + // (a reader looks them up in their own Bible), so on an RTL page they are + // LTR runs inside an RTL paragraph. Without isolation the Unicode bidi + // algorithm reorders a run that STARTS WITH A DIGIT: measured on the Arabic + // page, "1 Peter 5:6–11" rendered as "Peter 5:6-11 1". dir="ltr" on each + // reference pins its internal order without affecting the line's direction. + // ★ THE BOOK NAME IS PROSE, THE ADDRESS IS NOT. refs stay canonical English in the data (one + // address, checkable against any Bible), and the BOOK is named in the packet's language at print + // time from its `books` table: a Chinese packet cited "Matthew 27:27", English in every locale. + // A localized name in a right-to-left script is isolated rtl, so the book reads on the right and + // the chapter:verse keeps its digit order; a Latin reference stays the ltr isolate described above. + const refSpan = (r) => { + const m = String(r).match(/^(.*?)\s+(\d[\d:–\-,; ]*)$/); + const book = m && (L.books || {})[m[1]]; + const text = book ? `${book} ${m[2]}` : String(r); + const dir = book && RTL.has(lang) ? 'rtl' : 'ltr'; + return `${esc(text)}`; + }; function pointsPage() { - // ⚠ ISOLATE EACH REFERENCE FOR BIDI. Scripture references stay in English - // (a reader looks them up in their own Bible), so on an RTL page they are - // LTR runs inside an RTL paragraph. Without isolation the Unicode bidi - // algorithm reorders a run that STARTS WITH A DIGIT: measured on the Arabic - // page, "1 Peter 5:6–11" rendered as "Peter 5:6-11 1". dir="ltr" on each - // reference pins its internal order without affecting the line's direction. - // ★ THE BOOK NAME IS PROSE, THE ADDRESS IS NOT. refs stay canonical English in the data (one - // address, checkable against any Bible), and the BOOK is named in the packet's language at print - // time from its `books` table: a Chinese packet cited "Matthew 27:27", English in every locale. - // A localized name in a right-to-left script is isolated rtl, so the book reads on the right and - // the chapter:verse keeps its digit order; a Latin reference stays the ltr isolate described above. - const refSpan = (r) => { - const m = String(r).match(/^(.*?)\s+(\d[\d:–\-,; ]*)$/); - const book = m && (L.books || {})[m[1]]; - const text = book ? `${book} ${m[2]}` : String(r); - const dir = book && RTL.has(lang) ? 'rtl' : 'ltr'; - return `${esc(text)}`; - }; const rows = POINTS.map((p) => { const refs = (p.refs || []).map(refSpan).join(' · '); const sub = p.sub ? ` — ${esc(p.sub)}` : ''; - const heart = p.print_passage - // ★ POINT 4 IS READING THE WORD, AND IT PRINTS A SHORT READING LIST (2026-09-30). It printed Mark - // 5:1-20 in full at 14px, the longest and smallest text in the packet. Now it is one heading and - // seven short readings, each a household the Father saved through one person: the person named in - // the packet's language, the passage addressed in the reader's own Bible (refs stay canonical - // English in the data and the book is localized, like the point refs above). - // class="p4-heart" is a TEST HOOK (the gates count its seven items), as p4-passage was. - ? `
-
${esc(t('point4_heart_title'))}
-
${esc(t('point4_heart_lede'))}
-
    ${ - HEART.map((h) => `
  1. ${esc(h.who)} · ${refSpan(h.ref)}
  2. `).join('')}
` - : ''; return `
${p.n}. ${esc(tr(p.rhythm))}${sub}
${esc(p.text)}
${refs}
- ${heart}
`; }).join(''); return sheet(` @@ -1203,6 +1189,43 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
${esc(t('points_attrib'))}
`); } + // ★ THE HEAVENLY FATHER'S HEART (2026-09-30): seven short readings, each a household the Father saved + // through one person. The person is named in the packet's language; the passage is addressed in the + // reader's own Bible (refs stay canonical English in the data and the book is localized, like the point + // refs). class="p4-heart" is a TEST HOOK: the gates count its seven items. + const heartBlock = () => `
+
${esc(t('point4_heart_title'))}
+
${esc(t('point4_heart_lede'))}
+
    ${ + HEART.map((h) => `
  1. ${esc(h.who)} · ${refSpan(h.ref)}
  2. `).join('')}
`; + // ── PAGE n+2: READ THE WORD — point 4, in full ───────────────────────────── + // ★ POINT 4 IS READING THE WORD, SO THE PACKET CARRIES THE WORD TO READ (2026-09-30). Mark 5:1-20 in + // full, in the packet's language where a public-domain text is on file (point4_passage, with its credit; + // the English WEB text otherwise, as the packet always did), with verse 19 in bold: the man sent home to + // tell how much the Lord has done for him. point4_passage_bold holds that verse VERBATIM from the same + // text, so the bold is a substring match and never a second rendering of scripture. Then THE HEAVENLY + // FATHER'S HEART. Its own sheet, right after the points: the passage at a readable size and the twelve + // points at theirs do not share one page. class="p4-passage" / "p4-v19" are TEST HOOKS. + function wordPage() { + const passage = String(t('point4_passage') || ''); + const bold = String(t('point4_passage_bold') || ''); + const at = bold ? passage.indexOf(bold) : -1; + const body = at < 0 ? esc(passage) + : `${esc(passage.slice(0, at))}${esc(bold)}${esc(passage.slice(at + bold.length))}`; + const four = POINTS.find((p) => p.print_passage) || {}; + const sub = four.sub ? ` — ${esc(four.sub)}` : ''; + return sheet(` +
+
4. ${esc(tr('read'))}${sub}
+
${refSpan('Mark 5:1–20')}
+
${body}
+
${esc(t('point4_passage_credit'))}
+ ${heartBlock()} +
`); + // No footer here: the Metacamp attribution belongs to the wheel and the points (check:packet-browser counts + // exactly those two), and this sheet carries its own credit line, the scripture's. + } + // ── PAGE n+1: the BOUNDED and CENTERED sets ───────────────────────────────── // Brief A1 BACK MATTER: after the profiles, before the wheel. FULL DETAIL ONLY — the lightweight // packet is map · triangle · profiles · wheel · points, and these two figures are the part a @@ -1500,6 +1523,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de keep('sets') && setsPage(zone, sets), keep('wheel') && wheelPage(), keep('points') && pointsPage(), + keep('points') && wordPage(), // point 4's reading, in full: follows the points it belongs to ].filter(Boolean); // ⚠ dir MUST be set for right-to-left languages or the packet renders Arabic // left-aligned — readable glyphs in the wrong place, which looks like a @@ -1513,5 +1537,5 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // VARIANTS is exported so the UI enumerates the packets it can print instead of hardcoding two // strings in a menu — add a third variant here and the caret gains an entry with no UI change. - return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, liftMap, mapFromImage, mapSheetFiles, PER_PAGE, W, H, VARIANTS }; + return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wordPage, wheelPage, css, mapCss, liftMap, mapFromImage, mapSheetFiles, PER_PAGE, W, H, VARIANTS }; } diff --git a/library/packet/poster-render.integrity.js b/library/packet/poster-render.integrity.js index 720bc2c0b..95b29c4e5 100644 --- a/library/packet/poster-render.integrity.js +++ b/library/packet/poster-render.integrity.js @@ -12,4 +12,4 @@ export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-DZa1KOOlRZMm17kxVEyMXNGafsBBt // The sha384 of library/scripts/posters/packet-strings.json, served at /lib/packet-strings.json. The // packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only // when it matches; otherwise the print falls back to the bundled English block. -export const PACKET_STRINGS_SHA384 = 'sha384-4WSG3wwwJPbiHnP+Jn6hxsv1KpgOp4BL9NWA19W3DcYbb9DBrrhpQZDAoPzVj1wo' +export const PACKET_STRINGS_SHA384 = 'sha384-uynM+7fzv0kx5nEw1By6Hd2B6XgjWAjDIU8VX4Uy756CkM4k7z/Cs/tpgdznZw2s' diff --git a/library/scripts/posters/packet-strings.json b/library/scripts/posters/packet-strings.json index a9fbd41a6..87df4abc6 100644 --- a/library/scripts/posters/packet-strings.json +++ b/library/scripts/posters/packet-strings.json @@ -169,6 +169,7 @@ ], "point4_passage": "And they came to the other side of the sea, into the country of the Gadarenes. When he had come out of the boat, immediately a man with an unclean spirit met him out of the tombs. He lived in the tombs. Nobody could bind him any more, not even with chains, because he had been often bound with fetters and chains, and the chains had been torn apart by him, and the fetters broken in pieces. Nobody had the strength to tame him. Always, night and day, in the tombs and in the mountains, he was crying out, and cutting himself with stones. When he saw Jesus from afar, he ran and bowed down to him, and crying out with a loud voice, he said, “What have I to do with you, Jesus, you Son of the Most High God? I adjure you by God, don’t torment me.” For he said to him, “Come out of the man, you unclean spirit!” He asked him, “What is your name?” He said to him, “My name is Legion, for we are many.” He begged him much that he would not send them away out of the country. Now on the mountainside there was a great herd of pigs feeding. All the demons begged him, saying, “Send us into the pigs, that we may enter into them.” At once Jesus gave them permission. The unclean spirits came out and entered into the pigs. The herd of about two thousand rushed down the steep bank into the sea, and they were drowned in the sea. Those who fed them fled, and told it in the city and in the country. The people came to see what it was that had happened. They came to Jesus, and saw him who had been possessed by demons sitting, clothed, and in his right mind, even him who had the legion; and they were afraid. Those who saw it declared to them what happened to him who was possessed by demons, and about the pigs. They began to beg him to depart from their region. As he was entering into the boat, he who had been possessed by demons begged him that he might be with him. He didn’t allow him, but said to him, “Go to your house, to your friends, and tell them what great things the Lord has done for you and how he had mercy on you.” He went his way, and began to proclaim in Decapolis how Jesus had done great things for him, and everyone marveled.", "point4_passage_credit": "Mark 5:1–20 · World English Bible (public domain)", + "point4_passage_bold": "He didn’t allow him, but said to him, “Go to your house, to your friends, and tell them what great things the Lord has done for you and how he had mercy on you.”", "point4_heart_title": "THE HEAVENLY FATHER'S HEART", "point4_heart_lede": "They need to know who their Father in heaven is: He loves them, He wants to save them, and through them He wants to reach their neighbours who have never heard. Read how He did it for these seven:", "point4_heart": [ @@ -491,6 +492,7 @@ "pray_do": "Qué hacer", "point4_passage": "Y VINIERON de la otra parte de la mar á la provincia de los Gadarenos. Y salido él del barco, luego le salió al encuentro, de los sepulcros, un hombre con un espíritu inmundo, Que tenía domicilio en los sepulcros, y ni aun con cadenas le podía alguien atar; Porque muchas veces había sido atado con grillos y cadenas, mas las cadenas habían sido hechas pedazos por él, y los grillos desmenuzados; y nadie le podía domar. Y siempre, de día y de noche, andaba dando voces en los montes y en los sepulcros, é hiriéndose con las piedras. Y como vió á Jesús de lejos, corrió, y le adoró. Y clamando á gran voz, dijo: ¿Qué tienes conmigo, Jesús, Hijo del Dios Altísimo? Te conjuro por Dios que no me atormentes. Porque le decía: Sal de este hombre, espíritu inmundo. Y le preguntó: ¿Cómo te llamas? Y respondió diciendo: Legión me llamo; porque somos muchos. Y le rogaba mucho que no le enviase fuera de aquella provincia. Y estaba allí cerca del monte una grande manada de puercos paciendo. Y le rogaron todos los demonios, diciendo: Envíanos á los puercos para que entremos en ellos. Y luego Jesús se lo permitió. Y saliendo aquellos espíritus inmundos, entraron en los puercos, y la manada cayó por un despeñadero en la mar; los cuales eran como dos mil; y en la mar se ahogaron. Y los que apacentaban los puercos huyeron, y dieron aviso en la ciudad y en los campos. Y salieron para ver qué era aquello que había acontecido. Y vienen á Jesús, y ven al que había sido atormentado del demonio, y que había tenido la legión, sentado y vestido, y en su juicio cabal; y tuvieron miedo. Y les contaron los que lo habían visto, cómo había acontecido al que había tenido el demonio, y lo de los puercos. Y comenzaron á rogarle que se fuese de los términos de ellos. Y entrando él en el barco, le rogaba el que había sido fatigado del demonio, para estar con él. Mas Jesús no le permitió, sino le dijo: Vete á tu casa, á los tuyos, y cuéntales cuán grandes cosas el Señor ha hecho contigo, y cómo ha tenido misericordia de ti. Y se fué, y comenzó á publicar en Decápolis cuán grandes cosas Jesús había hecho con él: y todos se maravillaban.", "point4_passage_credit": "Marcos 5:1–20 · Reina Valera 1909 (dominio público) · ebible.org", + "point4_passage_bold": "Mas Jesús no le permitió, sino le dijo: Vete á tu casa, á los tuyos, y cuéntales cuán grandes cosas el Señor ha hecho contigo, y cómo ha tenido misericordia de ti.", "point4_heart_title": "EL CORAZÓN DEL PADRE CELESTIAL", "point4_heart_lede": "Necesitan conocer quién es su Padre celestial: los ama, quiere salvarlos y, por medio de ellos, alcanzar a sus vecinos que nunca han oído. Lee cómo lo hizo con estos siete:", "point4_heart": [ @@ -766,6 +768,7 @@ "pray_do": "Ce qu’il faut faire", "point4_passage": "Ils arrivèrent de l'autre côté de la mer, dans la contrée des Gadaréniens. Et aussitôt que Jésus fut descendu de la barque, un homme, possédé d'un esprit immonde, sortit des sépulcres et vint au-devant de lui. Il faisait sa demeure dans les sépulcres, et personne ne pouvait le tenir lié, pas même avec des chaînes; Car souvent, ayant eu les fers aux pieds, et ayant été lié de chaînes, il avait rompu les chaînes et brisé les fers; et personne ne le pouvait dompter. Et il demeurait continuellement, nuit et jour, sur les montagnes et dans les sépulcres, criant et se meurtrissant avec des pierres. Quand il eut vu Jésus de loin, il accourut et se prosterna devant lui, Et il dit, criant à haute voix: Qu'y a-t-il entre toi et moi, Jésus, Fils du Dieu très-haut? Je te conjure par le nom de Dieu de ne point me tourmenter. Car Jésus lui disait: Esprit immonde, sors de cet homme. Et Jésus lui demanda: Comment t'appelles-tu? Et il répondit: Je m'appelle Légion; car nous sommes plusieurs. Et il le priait instamment de ne pas les envoyer hors de cette contrée. Or, il y avait là, vers les montagnes, un grand troupeau de pourceaux qui paissait. Et tous les démons le priaient en disant: Envoie-nous dans ces pourceaux, afin que nous y entrions. Et aussitôt Jésus le leur permit. Alors ces esprits immondes étant sortis, entrèrent dans les pourceaux, et le troupeau se précipita avec impétuosité dans la mer, et ils se noyèrent dans la mer; or il y en avait environ deux mille. Et ceux qui paissaient les pourceaux s'enfuirent, et en portèrent la nouvelle dans la ville et par la campagne. Alors le peuple sortit pour voir ce qui était arrivé; et ils vinrent vers Jésus et virent le démoniaque, celui qui avait été possédé de la légion, assis, habillé et dans son bon sens; et ils furent remplis de crainte. Et ceux qui avaient vu cela, leur racontèrent ce qui était arrivé au démoniaque et aux pourceaux. Alors ils se mirent à le prier de se retirer de leurs quartiers. Et quand il fut entré dans la barque, celui qui avait été possédé le pria de lui permettre d'être avec lui. Mais Jésus ne le lui permit pas, et il dit: Va-t'en dans ta maison vers tes parents, et raconte-leur les grandes choses que le Seigneur t'a faites, et comment il a eu pitié de toi. Et il s'en alla, et se mit à publier dans la Décapole les grandes choses que Jésus lui avait faites; et ils étaient tous dans l'admiration.", "point4_passage_credit": "Marc 5:1-20 · French Ostervald Bible (domaine public) · ebible.org", + "point4_passage_bold": "Mais Jésus ne le lui permit pas, et il dit: Va-t'en dans ta maison vers tes parents, et raconte-leur les grandes choses que le Seigneur t'a faites, et comment il a eu pitié de toi.", "point4_heart_title": "LE CŒUR DU PÈRE CÉLESTE", "point4_heart_lede": "Ils ont besoin de savoir qui est leur Père céleste : il les aime, il veut les sauver et, à travers eux, atteindre leurs voisins qui n'ont jamais entendu. Lisez comment il l'a fait pour ces sept :", "point4_heart": [ @@ -1314,6 +1317,7 @@ "pray_do": "Was tun", "point4_passage": "Und sie kamen jenseits des Meers in die Gegend der Gadarener. Und als er aus dem Schiff trat, lief ihm alsbald entgegen aus den Gräbern ein besessener Mensch mit einem unsaubern Geist, der seine Wohnung in den Gräbern hatte; und niemand konnte ihn binden, auch nicht mit Ketten. Denn er war oft mit Fesseln und Ketten gebunden gewesen, und hatte die Ketten abgerissen und die Fesseln zerrieben; und niemand konnte ihn zähmen. Und er war allezeit, Tag und Nacht, auf den Bergen und in den Gräbern, schrie und schlug sich mit Steinen. Da er aber Jesum sah von ferne, lief er zu und fiel vor ihm nieder, schrie laut und sprach: Was habe ich mit dir zu tun, o Jesu, du Sohn Gottes, des Allerhöchsten? Ich beschwöre dich bei Gott, daß du mich nicht quälest! Denn er sprach zu ihm: Fahre aus, du unsauberer Geist, von dem Menschen! Und er fragte ihn: Wie heißt du? Und er antwortete und sprach: Legion heiße ich; denn wir sind unser viele. Und er bat ihn sehr, daß er sie nicht aus der Gegend triebe. Und es war daselbst an den Bergen eine große Herde Säue auf der Weide. Und die Teufel baten ihn alle und sprachen: Laß uns in die Säue fahren! Und alsbald erlaubte es ihnen Jesus. Da fuhren die unsauberen Geister aus und fuhren in die Säue; und die Herde stürzte sich von dem Abhang ins Meer (ihrer waren aber bei zweitausend) und ersoffen im Meer. Und die Sauhirten flohen und verkündigten das in der Stadt und auf dem Lande. Und sie gingen hinaus, zu sehen, was da geschehen war, und kamen zu Jesu und sahen den, der von den Teufeln besessen war, daß er saß und war bekleidet und vernünftig, und fürchteten sich. Und die es gesehen hatten, sagten ihnen, was dem Besessenen widerfahren war, und von den Säuen. Und sie fingen an und baten ihn, daß er aus ihrer Gegend zöge. Und da er in das Schiff trat, bat ihn der Besessene, daß er möchte bei ihm sein. Aber Jesus ließ es nicht zu, sondern sprach zu ihm: Gehe hin in dein Haus und zu den Deinen und verkündige ihnen, wie große Wohltat dir der HERR getan und sich deiner erbarmt hat. Und er ging hin und fing an, auszurufen in den zehn Städten, wie große Wohltat ihm Jesus getan hatte; und jedermann verwunderte sich.", "point4_passage_credit": "Markus 5,1–20 · German Luther Bible 1912 (gemeinfrei) · ebible.org", + "point4_passage_bold": "Aber Jesus ließ es nicht zu, sondern sprach zu ihm: Gehe hin in dein Haus und zu den Deinen und verkündige ihnen, wie große Wohltat dir der HERR getan und sich deiner erbarmt hat.", "point4_heart_title": "DAS HERZ DES HIMMLISCHEN VATERS", "point4_heart_lede": "Sie müssen wissen, wer ihr Vater im Himmel ist: Er liebt sie, er will sie retten und durch sie ihre Nachbarn erreichen, die noch nie von ihm gehört haben. Lies, wie er es bei diesen sieben getan hat:", "point4_heart": [ @@ -2135,6 +2139,7 @@ "pray_do": "Что сделать", "point4_passage": "И пришли на другой берег моря, в страну Гадаринскую. И когда вышел Он из лодки, тотчас встретил Его вышедший из гробов человек, одержимый нечистым духом, он имел жилище в гробах, и никто не мог его связать даже цепями, потому что многократно был он скован оковами и цепями, но разрывал цепи и разбивал оковы, и никто не в силах был укротить его; всегда, ночью и днем, в горах и гробах, кричал он и бился о камни; увидев же Иисуса издалека, прибежал и поклонился Ему, и, вскричав громким голосом, сказал: что Тебе до меня, Иисус, Сын Бога Всевышнего? заклинаю Тебя Богом, не мучь меня! Ибо Иисус сказал ему: выйди, дух нечистый, из сего человека. И спросил его: как тебе имя? И он сказал в ответ: легион имя мне, потому что нас много. И много просили Его, чтобы не высылал их вон из страны той. Паслось же там при горе большое стадо свиней. И просили Его все бесы, говоря: пошли нас в свиней, чтобы нам войти в них. Иисус тотчас позволил им. И нечистые духи, выйдя, вошли в свиней; и устремилось стадо с крутизны в море, а их было около двух тысяч; и потонули в море. Пасущие же свиней побежали и рассказали в городе и в деревнях. И жители вышли посмотреть, что случилось. Приходят к Иисусу и видят, что бесновавшийся, в котором был легион, сидит и одет, и в здравом уме; и устрашились. Видевшие рассказали им о том, как это произошло с бесноватым, и о свиньях. И начали просить Его, чтобы отошел от пределов их. И когда Он вошел в лодку, бесновавшийся просил Его, чтобы быть с Ним. Но Иисус не дозволил ему, а сказал: иди домой к своим и расскажи им, что сотворил с тобою Господь и как помиловал тебя. И пошел и начал проповедывать в Десятиградии, что сотворил с ним Иисус; и все дивились.", "point4_passage_credit": "Марка 5:1–20 · Russian Synodal Bible (общественное достояние) · ebible.org", + "point4_passage_bold": "Но Иисус не дозволил ему, а сказал: иди домой к своим и расскажи им, что сотворил с тобою Господь и как помиловал тебя.", "point4_heart_title": "СЕРДЦЕ НЕБЕСНОГО ОТЦА", "point4_heart_lede": "Им нужно узнать, кто их Небесный Отец: Он любит их, хочет спасти их и через них достичь их соседей, которые никогда не слышали. Прочитайте, как Он сделал это для этих семерых:", "point4_heart": [ @@ -2410,6 +2415,7 @@ "pray_do": "ماذا أفعل", "point4_passage": "وَجَاءُوا إِلَى عَبْرِ ٱلْبَحْرِ إِلَى كُورَةِ ٱلْجَدَرِيِّينَ. وَلَمَّا خَرَجَ مِنَ ٱلسَّفِينَةِ لِلْوَقْتِ ٱسْتَقْبَلَهُ مِنَ ٱلْقُبُورِ إِنْسَانٌ بِهِ رُوحٌ نَجِسٌ، كَانَ مَسْكَنُهُ فِي ٱلْقُبُورِ، وَلَمْ يَقْدِرْ أَحَدٌ أَنْ يَرْبِطَهُ وَلَا بِسَلَاسِلَ، لِأَنَّهُ قَدْ رُبِطَ كَثِيرًا بِقُيُودٍ وَسَلَاسِلَ فَقَطَّعَ ٱلسَّلَاسِلَ وَكَسَّرَ ٱلْقُيُودَ، فَلَمْ يَقْدِرْ أَحَدٌ أَنْ يُذَلِّلَهُ. وَكَانَ دَائِمًا لَيْلًا وَنَهَارًا فِي ٱلْجِبَالِ وَفِي ٱلْقُبُورِ، يَصِيحُ وَيُجَرِّحُ نَفْسَهُ بِٱلْحِجَارَةِ. فَلَمَّا رَأَى يَسُوعَ مِنْ بَعِيدٍ رَكَضَ وَسَجَدَ لَهُ، وَصَرَخَ بِصَوْتٍ عَظِيمٍ وَقَالَ: «مَا لِي وَلَكَ يَا يَسُوعُ ٱبْنَ ٱللهِ ٱلْعَلِيِّ؟ أَسْتَحْلِفُكَ بِٱللهِ أَنْ لَا تُعَذِّبَنِي!». لِأَنَّهُ قَالَ لَهُ: «ٱخْرُجْ مِنَ ٱلْإِنْسَانِ يَا أَيُّهَا ٱلرُّوحُ ٱلنَّجِسُ». وَسَأَلَهُ: «مَا ٱسْمُكَ؟». فَأَجَابَ قَائِلًا: «ٱسْمِي لَجِئُونُ، لِأَنَّنَا كَثِيرُونَ». وَطَلَبَ إِلَيْهِ كَثِيرًا أَنْ لَا يُرْسِلَهُمْ إِلَى خَارِجِ ٱلْكُورَةِ. وَكَانَ هُنَاكَ عِنْدَ ٱلْجِبَالِ قَطِيعٌ كَبِيرٌ مِنَ ٱلْخَنَازِيرِ يَرْعَى، فَطَلَبَ إِلَيْهِ كُلُّ ٱلشَّيَاطِينِ قَائِلِينَ: «أَرْسِلْنَا إِلَى ٱلْخَنَازِيرِ لِنَدْخُلَ فِيهَا». فَأَذِنَ لَهُمْ يَسُوعُ لِلْوَقْتِ. فَخَرَجَتِ ٱلْأَرْوَاحُ ٱلنَّجِسَةُ وَدَخَلَتْ فِي ٱلْخَنَازِيرِ، فَٱنْدَفَعَ ٱلْقَطِيعُ مِنْ عَلَى ٱلْجُرْفِ إِلَى ٱلْبَحْرِ. وَكَانَ نَحْوَ أَلْفَيْنِ، فَٱخْتَنَقَ فِي ٱلْبَحْرِ. وَأَمَّا رُعَاةُ ٱلْخَنَازِيرِ فَهَرَبُوا وَأَخْبَرُوا فِي ٱلْمَدِينَةِ وَفِي ٱلضِّيَاعِ. فَخَرَجُوا لِيَرَوْا مَا جَرَى. وَجَاءُوا إِلَى يَسُوعَ فَنَظَرُوا ٱلْمَجْنُونَ ٱلَّذِي كَانَ فِيهِ ٱللَّجِئُونُ جَالِسًا وَلَابِسًا وَعَاقِلًا، فَخَافُوا. فَحَدَّثَهُمُ ٱلَّذِينَ رَأَوْا كَيْفَ جَرَى لِلْمَجْنُونِ وَعَنِ ٱلْخَنَازِيرِ. فَٱبْتَدَأُوا يَطْلُبُونَ إِلَيْهِ أَنْ يَمْضِيَ مِنْ تُخُومِهِمْ. وَلَمَّا دَخَلَ ٱلسَّفِينَةَ طَلَبَ إِلَيْهِ ٱلَّذِي كَانَ مَجْنُونًا أَنْ يَكُونَ مَعَهُ، فَلَمْ يَدَعْهُ يَسُوعُ، بَلْ قَالَ لَهُ: «ٱذْهَبْ إِلَى بَيْتِكَ وَإِلَى أَهْلِكَ، وَأَخْبِرْهُمْ كَمْ صَنَعَ ٱلرَّبُّ بِكَ وَرَحِمَكَ». فَمَضَى وَٱبْتَدَأَ يُنَادِي فِي ٱلْعَشْرِ ٱلْمُدُنِ كَمْ صَنَعَ بِهِ يَسُوعُ. فَتَعَجَّبَ ٱلْجَمِيعُ.", "point4_passage_credit": "مرقس ٥:‏١–٢٠ · Arabic Van Dyck Bible (ملك عام) · ebible.org", + "point4_passage_bold": "فَلَمْ يَدَعْهُ يَسُوعُ، بَلْ قَالَ لَهُ: «ٱذْهَبْ إِلَى بَيْتِكَ وَإِلَى أَهْلِكَ، وَأَخْبِرْهُمْ كَمْ صَنَعَ ٱلرَّبُّ بِكَ وَرَحِمَكَ».", "point4_heart_title": "قلب الآب السماوي", "point4_heart_lede": "يحتاجون أن يعرفوا مَن هو أبوهم السماوي: إنه يحبهم ويريد أن يخلّصهم، وأن يصل من خلالهم إلى جيرانهم الذين لم يسمعوا قط. اقرأ كيف فعل ذلك مع هؤلاء السبعة:", "point4_heart": [ diff --git a/package.json b/package.json index 87174f1bb..6920d87b9 100644 --- a/package.json +++ b/package.json @@ -351,6 +351,8 @@ "packet:ci": "node internal/build-packet-fixtures.mjs", "packets": "cd library/scripts/posters && VITE_API_BASE_URL=https://pray.doxa.life node packet.mjs", "phone": "bun run apk:debug && bun run apk:install", + "phone:deep-research": "APP_START=doxa-maps-spa/doxa-maps-spa/app bun run phone:from-tree", + "phone:from-tree": "bun run cap:sync && bun run app:start-page && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk", "posters": "cd library/scripts/posters && SPARSE=1 OFFLINE=1 VITE_API_BASE_URL=https://pray.doxa.life FORMAT=3x3 node generate.mjs", "posters:export": "cd library/scripts/posters && node export-public.mjs", "posters:gallery": "cd library/scripts/posters && node build-gallery.mjs && node build-hd-gallery.mjs", diff --git a/plugin-designs/cities-internet/connectivity-map.vue b/plugin-designs/cities-internet/connectivity-map.vue index 9fed464f1..3d5e0d680 100644 --- a/plugin-designs/cities-internet/connectivity-map.vue +++ b/plugin-designs/cities-internet/connectivity-map.vue @@ -93,6 +93,39 @@ const mapboxToken = computed(() => profileConfig.value?.tk || injectedTk?.value const assetsBase = computed(() => resolveAssetsBase(profileConfig.value)) const mapEl = ref(null) +// ★ THE LICENCE CREDIT, COMPACT (plugin-designer 5.8, the bottom-of-map standard). The engine's own +// AttributionControl, with geoBoundaries as custom attribution (its licence asks for the name and a link back). +// On a narrow map the engine draws it compact, EXPANDED at first; CREDIT_FOLD_MS after it first shows, +// foldCredit folds it to its (i) the way the engine's own drag does (it drops the -compact-show class), and a +// tap on the (i) opens it again. The style may still be loading when the map is built, so it looks every half +// second, for up to 30 s, and stops once the credit is there and folded, or is full width (not compact). +const GB_CREDIT = 'geoBoundaries' +const CREDIT_FOLD_MS = 3000 +let creditTimer = 0 +function foldCredit(triesLeft = 60) { + creditTimer = 0 + const box = mapEl.value + if (!box) return + const shown = box.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show, .mapboxgl-ctrl-attrib.mapboxgl-compact-show') + // The clock starts once the STYLE's own credit has joined geoBoundaries (it arrives with the style's + // sources), so the first expanded show names the basemap too; measured, the fold otherwise came while + // only "geoBoundaries" had shown. After 30 s it starts anyway. + const inner = shown && shown.querySelector('.maplibregl-ctrl-attrib-inner, .mapboxgl-ctrl-attrib-inner') + const full = !!(inner && inner.textContent.replace('geoBoundaries', '').replace(/[|\s]/g, '').length) + if (shown && (full || triesLeft <= 0)) { + creditTimer = setTimeout(() => { creditTimer = 0; shown.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show') }, CREDIT_FOLD_MS) + return + } + const credit = box.querySelector('.maplibregl-ctrl-attrib, .mapboxgl-ctrl-attrib') + if (!shown && credit && !/attrib-empty/.test(credit.className)) return + if (triesLeft > 0) creditTimer = setTimeout(() => foldCredit(triesLeft - 1), 500) +} +function addCompactCredit(map, gl) { + if (typeof gl?.AttributionControl !== 'function') return false + try { map.addControl(new gl.AttributionControl({ customAttribution: GB_CREDIT }), 'bottom-right') } catch (_) { return false } + foldCredit() + return true +} const mapRef = shallowRef(null) const rows = ref([]) const selected = ref(null) @@ -517,7 +550,10 @@ onMounted(async () => { // NO attribution at all. It SHADES geoBoundaries polygons, so the geoBoundaries // credit + link back is required, and gbOpen is mixed-licence with several // countries carrying a real attribution requirement (India CC BY 2.5 IN, etc). - addMapAttribution(map, { boundaries: true }) + // ★ NOW ONE CREDIT, THE ENGINE'S OWN COMPACT CONTROL (plugin-designer 5.8): the basemap credit comes from the + // style's own sources and geoBoundaries rides in it as custom attribution, with its link back, so every term + // above is still met. An engine without the control keeps the shared credit line. + if (!addCompactCredit(map, gl)) addMapAttribution(map, { boundaries: true }) map.once('load', async () => { // The BASEMAP's place names in the reader's language (Mapbox setLanguage / MapLibre name:), // the library's own helper. useMapInstance applies it for every map built through it; this map is @@ -538,6 +574,7 @@ onMounted(async () => { } catch (e) { err.value = String(e.message || e); loading.value = false } }) onBeforeUnmount(() => { + if (creditTimer) { clearTimeout(creditTimer); creditTimer = 0 } if (typeof window !== 'undefined') window.removeEventListener('dxmaps:mapinfo-host', reannounceInfoPending) try { legendRO?.disconnect() } catch (_) {} try { detachEdges?.() } catch (_) {} @@ -583,11 +620,15 @@ useShadowStyles( ':host{display:block;position:relative;width:100%;height:100%;min-height:420px}' + '.gm{position:absolute;inset:0;overflow:hidden}' + '.gm-map{position:absolute;inset:0}' + - // THE GALLERY'S ROUND ☰ sits at a phone's bottom corner (bottom-left; bottom-right in Arabic) and publishes - // --map-fab-clearance while it shows. The engine's bottom corners, where this map's licence credit (and a - // Mapbox wordmark) live, lift by it: the rule every LegendMobile map already has. Unset anywhere else, so - // desktop and a map without the button are unchanged. It covered the credit's first/last ~30-40px before. - '.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right{transform:translateY(calc(-1 * var(--map-fab-clearance,0px)))}' + + // THE CREDIT CORNER (plugin-designer 5.8; this map has no footer sheet, its legend panel is at the top). The + // engine's compact credit sits at the bottom END corner, opposite the gallery's round ☰ (bottom-right; + // bottom-left in Arabic), and is NOT lifted by --map-fab-clearance: lifted, it floated up the page. Where the + // round button can show, the corner sits above the scroll-edge strips so a tap on its (i) lands, and the + // expanded credit stops 64px short of the far edge, clear of the button. + '@media(max-width:767.98px){' + + '.gm .maplibregl-ctrl-bottom-right,.gm .mapboxgl-ctrl-bottom-right{inset-inline-end:0 !important;inset-inline-start:auto !important;max-width:calc(100% - 64px);z-index:3}' + + '.gm .maplibregl-ctrl-bottom-right .maplibregl-ctrl,.gm .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl{margin:0 0 10px;margin-inline-end:10px}' + + '}' + // LEGEND: a BOUNDED, left-anchored panel — NOT a full-width strip. // It used to be `left:8px;right:8px`, which is a strip spanning the whole map // (MEASURED 1264px of a 1280px map, 98.75%). That collided with the floating diff --git a/plugin-designs/gospel-resources/resources-map.vue b/plugin-designs/gospel-resources/resources-map.vue index 784e88a47..fabe6f5ef 100644 --- a/plugin-designs/gospel-resources/resources-map.vue +++ b/plugin-designs/gospel-resources/resources-map.vue @@ -136,6 +136,39 @@ const grProvider = resolveMapProvider(profileConfig) const assetsBase = computed(() => resolveAssetsBase(profileConfig.value)) const mapEl = ref(null) +// ★ THE LICENCE CREDIT, COMPACT (plugin-designer 5.8, the bottom-of-map standard). The engine's own +// AttributionControl, with geoBoundaries as custom attribution (its licence asks for the name and a link back). +// On a narrow map the engine draws it compact, EXPANDED at first; CREDIT_FOLD_MS after it first shows, +// foldCredit folds it to its (i) the way the engine's own drag does (it drops the -compact-show class), and a +// tap on the (i) opens it again. The style may still be loading when the map is built, so it looks every half +// second, for up to 30 s, and stops once the credit is there and folded, or is full width (not compact). +const GB_CREDIT = 'geoBoundaries' +const CREDIT_FOLD_MS = 3000 +let creditTimer = 0 +function foldCredit(triesLeft = 60) { + creditTimer = 0 + const box = mapEl.value + if (!box) return + const shown = box.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show, .mapboxgl-ctrl-attrib.mapboxgl-compact-show') + // The clock starts once the STYLE's own credit has joined geoBoundaries (it arrives with the style's + // sources), so the first expanded show names the basemap too; measured, the fold otherwise came while + // only "geoBoundaries" had shown. After 30 s it starts anyway. + const inner = shown && shown.querySelector('.maplibregl-ctrl-attrib-inner, .mapboxgl-ctrl-attrib-inner') + const full = !!(inner && inner.textContent.replace('geoBoundaries', '').replace(/[|\s]/g, '').length) + if (shown && (full || triesLeft <= 0)) { + creditTimer = setTimeout(() => { creditTimer = 0; shown.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show') }, CREDIT_FOLD_MS) + return + } + const credit = box.querySelector('.maplibregl-ctrl-attrib, .mapboxgl-ctrl-attrib') + if (!shown && credit && !/attrib-empty/.test(credit.className)) return + if (triesLeft > 0) creditTimer = setTimeout(() => foldCredit(triesLeft - 1), 500) +} +function addCompactCredit(map, gl) { + if (typeof gl?.AttributionControl !== 'function') return false + try { map.addControl(new gl.AttributionControl({ customAttribution: GB_CREDIT }), 'bottom-right') } catch (_) { return false } + foldCredit() + return true +} const mapRef = shallowRef(null) const rows = ref([]) const selected = ref(null) @@ -1828,10 +1861,11 @@ onMounted(async () => { * against https://tiles.openfreemap.org/planet before writing this. */ const isMaplibre = grProvider === 'maplibre' - addMapAttribution(map, { boundaries: true, basemap: !isMaplibre }) - if (isMaplibre && typeof gl.AttributionControl === 'function') { - try { map.addControl(new gl.AttributionControl({ compact: false })) } catch (_) { /* credit is not worth breaking the map for */ } - } + // ★ NOW ONE CREDIT: the engine's own control on BOTH engines, compact on a narrow map (plugin-designer 5.8), + // with geoBoundaries as custom attribution. It still reads the basemap wording from the style, as above; under + // Mapbox the style names Mapbox and OpenStreetMap. An engine without the control keeps the shared line, with + // the basemap credit it can name. + if (!addCompactCredit(map, gl)) addMapAttribution(map, { boundaries: true, basemap: !isMaplibre }) /** * ═══ 'load' MAY HAVE ALREADY FIRED ════════════════════════════════════════════════════ * @@ -1862,6 +1896,7 @@ onMounted(async () => { } catch (e) { err.value = String(e.message || e) } }) onBeforeUnmount(() => { + if (creditTimer) { clearTimeout(creditTimer); creditTimer = 0 } if (typeof window !== 'undefined') window.removeEventListener('dxmaps:mapinfo-host', reannounceInfoPending) try { legendRO?.disconnect() } catch (_) {} try { detachEdges?.() } catch (_) {} @@ -2012,11 +2047,15 @@ useShadowStyles( // 2,107 pins to queryRenderedFeatures, because that API does not care whether anything // is visible. The size is stated outright now so no engine stylesheet can take it. '.gm-map{position:absolute;inset:0;width:100%;height:100%}' + - // THE GALLERY'S ROUND ☰ sits at a phone's bottom corner (bottom-left; bottom-right in Arabic) and publishes - // --map-fab-clearance while it shows. The engine's bottom corners, where this map's licence credit (and a - // Mapbox wordmark) live, lift by it: the rule every LegendMobile map already has. Unset anywhere else, so - // desktop and a map without the button are unchanged. It covered the credit's first/last ~30-40px before. - '.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right{transform:translateY(calc(-1 * var(--map-fab-clearance,0px)))}' + + // THE CREDIT CORNER (plugin-designer 5.8; this map has no footer sheet, its legend panel is at the top). The + // engine's compact credit sits at the bottom END corner, opposite the gallery's round ☰ (bottom-right; + // bottom-left in Arabic), and is NOT lifted by --map-fab-clearance: lifted, it floated up the page. Where the + // round button can show, the corner sits above the scroll-edge strips so a tap on its (i) lands, and the + // expanded credit stops 64px short of the far edge, clear of the button. + '@media(max-width:767.98px){' + + '.gm .maplibregl-ctrl-bottom-right,.gm .mapboxgl-ctrl-bottom-right{inset-inline-end:0 !important;inset-inline-start:auto !important;max-width:calc(100% - 64px);z-index:3}' + + '.gm .maplibregl-ctrl-bottom-right .maplibregl-ctrl,.gm .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl{margin:0 0 10px;margin-inline-end:10px}' + + '}' + // LEGEND: a BOUNDED, left-anchored panel — NOT a full-width strip. // It used to be `left:8px;right:8px`, which is a strip spanning the whole map // (MEASURED 1264px of a 1280px map, 98.75%). That collided with the floating diff --git a/plugin-designs/people-groups-grid/people-groups-grid.vue b/plugin-designs/people-groups-grid/people-groups-grid.vue index df3f7942e..57ceeee08 100644 --- a/plugin-designs/people-groups-grid/people-groups-grid.vue +++ b/plugin-designs/people-groups-grid/people-groups-grid.vue @@ -1037,7 +1037,7 @@ onMounted(() => { :model-value="localSearchTerm" :placeholder="t.search || 'Search people groups…'" :aria-label="t.search || 'Search people groups'" - max-width="480px" + max-width="600px" @search="onSearch" />