Merge main into bundler-2/work
# Conflicts: # public/doxa-maps/index.html # public/doxa-maps/manifest.json # public/doxa-maps/regions-dashboard/region-dashboards/index.html # public/doxa-maps/regions-dashboard/regions-dashboard.js # public/doxa-maps/regions-dashboard/regions-list/index.html # public/doxa-maps/regions-dashboard/regions-mfe/index.html
133
README.md
|
|
@ -1,5 +1,24 @@
|
|||
# doxa-maps — one build, maps that embed everywhere
|
||||
|
||||
> **The map cruncher.** Many hand-built maps go in; one shared library, one repo and one build
|
||||
> come out — and every map fans out to five deployment targets from the same artifacts.
|
||||
>
|
||||
> ```text
|
||||
> map · map · map · map · map · map · map (7 production maps, once 7 codebases)
|
||||
> \ \ | | | / /
|
||||
> +----- plugin-designs/ ------+ one folder per plugin, free zone
|
||||
> |
|
||||
> library/ (@map) shared, parameterized, high-bar zone
|
||||
> |
|
||||
> ONE bun run build
|
||||
> |
|
||||
> doxa-maps/ (drop-anywhere tree: bundles + leaf pages + manifest.json)
|
||||
> / | \ \ \
|
||||
> iframe JS-embed mobile WordPress desktop
|
||||
> (proven) (proven) (built) (built) (built)
|
||||
> ```
|
||||
|
||||
|
||||
**A Vite bundler that turns each map into a self-contained web component.** Write a map in
|
||||
`plugin-designs/<your-map>/`, run one build, and get a folder you can drop on any host — iframe it,
|
||||
script-embed it, wrap it as a mobile app, or open every map at once as one SPA.
|
||||
|
|
@ -12,18 +31,20 @@ This README has three parts, in the order you need them:
|
|||
|
||||
---
|
||||
|
||||
## 1. The 5 plugin capabilities
|
||||
## 1. The 5 plugin capabilities — most advanced first
|
||||
|
||||
A "capability" is **how much a plugin nests** — not how hard it is. Every screenshot below is a real
|
||||
bundle from this repo's build output.
|
||||
A "capability" is **how much a plugin nests** — not how hard it is to use. The tour below starts from
|
||||
the most advanced thing this repo produces (a whole app of apps) and walks down to the simplest
|
||||
(one element), because the top of the ladder is the point: every rung below it stays independently
|
||||
embeddable. Every screenshot is a real bundle from this repo's build output.
|
||||
|
||||
| # | Capability | What it is | Example bundle |
|
||||
|---|------------|------------|----------------|
|
||||
| 1 | **Single element** | One folder → one bundle → one custom element. | `all-people-groups` |
|
||||
| 2 | **Prop-table element** | One `root.vue` + a preset table → many maps from one plugin. | `doxa-simple-map` |
|
||||
| 3 | **Monolithic element** | One `root.vue` nests many views internally → one element. | `doxa-research-map` |
|
||||
| 4 | **Nested-element SPA** | A composite that nests *other plugins* and wires them with props/events. | `big-picture` |
|
||||
| 5 | **Nested-SPA / app shell** | Many SPAs nested into one deep-linkable app. | `doxa-maps-spa` |
|
||||
| 4 | **Nested-element SPA** | A composite that nests *other plugins* and wires them with props/events. | `big-picture` |
|
||||
| 3 | **Monolithic element** | One `root.vue` nests many views internally → one element. | `doxa-research-map` |
|
||||
| 2 | **Prop-table element** | One `root.vue` + a preset table → many maps from one plugin. | `doxa-simple-map` |
|
||||
| 1 | **Single element** | One folder → one bundle → one custom element. | `all-people-groups` |
|
||||
|
||||
> The tier is decided by the files, not by how simple a folder looks: an `*.instances.json` makes a
|
||||
> bundle **cap-2** however light it is, and a `meta.json` `type` declares tiers 3/4/5. The authoritative,
|
||||
|
|
@ -31,38 +52,7 @@ bundle from this repo's build output.
|
|||
> **[documentation/reference-capability-exemplars.md](./documentation/reference-capability-exemplars.md)**
|
||||
> — cite that file rather than remembering.
|
||||
|
||||
### Capability 1 — single element
|
||||
|
||||
One folder, one `.vue`, one embeddable element. `plugin-designs/all-people-groups/` shows every people
|
||||
group on one map.
|
||||
|
||||

|
||||
|
||||
### Capability 2 — prop-table element
|
||||
|
||||
One `root.vue`, many maps. `plugin-designs/doxa-simple-map/` ships a preset prop-table
|
||||
(`simple-map.instances.json`) that renders it three ways — each a standalone embeddable page from the
|
||||
**same** code.
|
||||
|
||||
| Prayer | Engagement | Adoption |
|
||||
|--------|------------|----------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 3 — monolithic element
|
||||
|
||||
A whole multi-view map from one `root.vue`. The trade-off: you cannot embed just one of its tabs.
|
||||
|
||||

|
||||
|
||||
### Capability 4 — nested-element SPA
|
||||
|
||||
A composite that nests **lower-tier plugins** and wires them with props and events. Each inner plugin
|
||||
stays independently embeddable, and the composite is embeddable too. `plugin-designs/my-upg-100-list/`
|
||||
ships three leaves:
|
||||
|
||||
| Composite (`upg-100-mfe`) | List (`upg-100-list`) | Poster wall (`poster-wall`) |
|
||||
|---------------------------|-----------------------|-----------------------------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 5 — nested-SPA / app shell
|
||||
|
||||
|
|
@ -79,6 +69,39 @@ studio without any wiring. This is [section 3](#3-the-plugin-studio).
|
|||
|
||||

|
||||
|
||||
### Capability 4 — nested-element SPA
|
||||
|
||||
A composite that nests **lower-tier plugins** and wires them with props and events. Each inner plugin
|
||||
stays independently embeddable, and the composite is embeddable too. `plugin-designs/my-upg-100-list/`
|
||||
ships three leaves:
|
||||
|
||||
| Composite (`upg-100-mfe`) | List (`upg-100-list`) | Poster wall (`poster-wall`) |
|
||||
|---------------------------|-----------------------|-----------------------------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 3 — monolithic element
|
||||
|
||||
A whole multi-view map from one `root.vue`. The trade-off: you cannot embed just one of its tabs.
|
||||
|
||||

|
||||
|
||||
### Capability 2 — prop-table element
|
||||
|
||||
One `root.vue`, many maps. `plugin-designs/doxa-simple-map/` ships a preset prop-table
|
||||
(`simple-map.instances.json`) that renders it three ways — each a standalone embeddable page from the
|
||||
**same** code.
|
||||
|
||||
| Prayer | Engagement | Adoption |
|
||||
|--------|------------|----------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 1 — single element
|
||||
|
||||
One folder, one `.vue`, one embeddable element. `plugin-designs/all-people-groups/` shows every people
|
||||
group on one map.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 2. The 5 deployment methods
|
||||
|
|
@ -100,6 +123,17 @@ Android SDK for method 2, a live WordPress for method 4, and a GUI (this box is
|
|||
|
||||
Methods **1 and 3 are the two proven-live paths.** If you need a map on a site today, use one of those.
|
||||
|
||||
Both were **certified cross-origin on 2026-09-24** — the entire capability-5 app, embedded on a
|
||||
foreign host page, every inner plugin riding along (prove-the-whole = prove-the-parts):
|
||||
|
||||
| Method 1 — iframe on a host site | Method 3 — JS embed on a host site |
|
||||
|----------------------------------|-------------------------------------|
|
||||
|  |  |
|
||||
|
||||
> For a JS embed on another site the host page sets `window.MAP_TOKEN` (nested elements read it)
|
||||
> and the serving layer must send `Access-Control-Allow-Origin: *` — the tree is fetched
|
||||
> cross-origin by `manifest.json`-composed surfaces.
|
||||
|
||||
The full per-method command matrix, with every prerequisite, is in
|
||||
**[documentation/README-SECTION-3-cli-commands.md](./documentation/README-SECTION-3-cli-commands.md)**.
|
||||
|
||||
|
|
@ -138,6 +172,29 @@ runs on it yet).
|
|||
|
||||
---
|
||||
|
||||
## The repo at a glance
|
||||
|
||||
```text
|
||||
doxa-maps/
|
||||
├── plugin-designs/ YOUR maps live here — one folder per plugin (the FREE zone).
|
||||
│ ├── doxa-maps-spa/ the capability-5 app (nests everything below)
|
||||
│ ├── dashboard-plus-grid/ capability-4 composite: dashboard + people-groups grid
|
||||
│ ├── my-upg-100-list/ capability-4: UPG-100 lists + poster wall + map
|
||||
│ ├── doxa-simple-map/ capability-2: one root.vue, three preset maps
|
||||
│ └── template-bundle/ copy this to start a new plugin
|
||||
├── library/ the SHARED zone (@map) — reusable, parameterized only.
|
||||
│ │ A change here reaches EVERY bundle: high bar, see
|
||||
│ │ documentation/contributing/.
|
||||
│ ├── components/ map controls, legends, search, poster, detail panels
|
||||
│ ├── composables/ useMapInstance, shadow styles, print-in-place, retry
|
||||
│ └── styles/ colors/ data/ style registry, palettes, lookup tables
|
||||
├── data/ the public datasets the maps ship with
|
||||
├── documentation/ tutorial · how-to · reference · explanation + architecture
|
||||
├── internal/ the build generator + the verification gates
|
||||
└── public/doxa-maps/ the BUILD OUTPUT — the drop-anywhere tree (bundles,
|
||||
leaf pages, manifest.json). What gets served + embedded.
|
||||
```
|
||||
|
||||
## 3. The Plugin Studio
|
||||
|
||||
★ **This is where you work — and the keyword is INTEROPERABILITY.**
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 633 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 904 KiB After Width: | Height: | Size: 686 KiB |
BIN
documentation/readme-images/deploy1-iframe-hostsite.png
Normal file
|
After Width: | Height: | Size: 565 KiB |
BIN
documentation/readme-images/deploy3-js-embed-hostsite.png
Normal file
|
After Width: | Height: | Size: 603 KiB |
|
|
@ -107,10 +107,16 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/poster/useMapPoster.js'`
|
||||
- **usePosterLayout** — usePosterLayout — slot positioning math for poster compositions.
|
||||
`import … from '@map/composables/poster/usePosterLayout.js'`
|
||||
- **printInPlace** — printInPlace.js — SHARED (@map/composables/printInPlace.js).
|
||||
`import … from '@map/composables/printInPlace.js'`
|
||||
- **retryableAsyncComponent** — retryableAsyncComponent.js — SHARED (@map/composables/retryableAsyncComponent.js).
|
||||
`import … from '@map/composables/retryableAsyncComponent.js'`
|
||||
- **useAvatarGenerator** — Avatar Generator Composable
|
||||
`import … from '@map/composables/useAvatarGenerator.js'`
|
||||
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
|
||||
`import … from '@map/composables/useCountryOutline.js'`
|
||||
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
|
||||
`import … from '@map/composables/useDocumentFont.js'`
|
||||
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
|
||||
`import … from '@map/composables/useEdgeScrollAffordance.js'`
|
||||
- **useGeocoderSearch** — Shared geocoder→map search logic (kind-switch + Mapbox filter builder + the
|
||||
|
|
@ -149,6 +155,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useSelectedPin.js'`
|
||||
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
|
||||
`import … from '@map/composables/useShadowStyles.js'`
|
||||
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
|
||||
`import … from '@map/composables/useSharedMapToken.js'`
|
||||
|
||||
### Utilities (pure helper functions)
|
||||
|
||||
|
|
|
|||
126
internal/check-packet-render.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// check-packet-render — the UPG-100 prayer packet must RENDER, not merely generate.
|
||||
//
|
||||
// Every bug this gate exists for produced a file of the right size, with no error, no
|
||||
// broken image, and a generator that reported success. They were only visible at the
|
||||
// RENDERED layer, which is why this drives a real browser and reads computed style.
|
||||
//
|
||||
// ★ THE LAW THIS GATE ENCODES: never print a number without asserting on it. An earlier
|
||||
// version of this probe PRINTED "PDF pages: 16" for several runs while reporting
|
||||
// "✓ packet verified", because the failure counter never looked at that value. A number
|
||||
// that is displayed but not asserted on is decoration, not verification.
|
||||
//
|
||||
// What it catches, each measured on a real regression:
|
||||
// 1. PAGE COUNT. transform:scale() is PAINT-time, so a scaled sheet still RESERVES its
|
||||
// full layout box and the paginator splits it — 8 sheets came out as 16 PDF pages,
|
||||
// inking only the top of each. (zoom is LAYOUT-time and paginates correctly.)
|
||||
// The packet is printed front-and-back, so an exact page count is load-bearing.
|
||||
// 2. MAP PALETTE. The map SVG carries almost no presentation attributes; it is styled
|
||||
// from the poster document's stylesheet. Lift the <svg> without those rules and SVG
|
||||
// `fill` falls back to its INITIAL value, which is BLACK — 670 elements painted solid
|
||||
// black with no error of any kind. Asserted as a WHOLE-MAP property, because the first
|
||||
// attempt at this check tested one hand-picked <rect> (the one just repaired) and went
|
||||
// green while 647 others were still black.
|
||||
// 3. Content the Driver specified explicitly: Mark 5:1-20 in full, 12 wheel segments,
|
||||
// the prayer rhythms, and exactly two minimal metacamp.org attributions.
|
||||
//
|
||||
// Usage: node internal/check-packet-render.mjs [path/to/packet.html]
|
||||
// Default path is overridable with PACKET_HTML so this is not machine-specific.
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const target = process.argv[2] || process.env.PACKET_HTML
|
||||
if (!target) {
|
||||
console.log('PACKET-RENDER: SKIP — no packet given (pass a path or set PACKET_HTML).')
|
||||
console.log(' The packet is generated on demand, so this gate is opt-in rather than')
|
||||
console.log(' failing a build on a file that is not expected to exist.')
|
||||
process.exit(0)
|
||||
}
|
||||
const file = resolve(target)
|
||||
if (!existsSync(file)) {
|
||||
console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } })
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
await page.goto(pathToFileURL(file).href, { waitUntil: 'networkidle' })
|
||||
|
||||
const r = await page.evaluate(() => {
|
||||
const body = document.body.innerText
|
||||
const imgs = [...document.querySelectorAll('.card img')]
|
||||
const RHYTHMS = ['PRAISE', 'WAIT', 'CONFESS', 'READ THE WORD', 'ASK', 'INTERCESSION',
|
||||
'PRAY THE WORD', 'THANK', 'SING', 'MEDITATE', 'LISTEN']
|
||||
|
||||
// Whole-map property, NOT one sampled element — see header note 2.
|
||||
const mapFaults = (() => {
|
||||
const svg = document.querySelector('.sheet svg')
|
||||
if (!svg) return ['no map svg found']
|
||||
const faults = []
|
||||
const names = [...new Set((svg.outerHTML.match(/var\((--[a-z0-9-]+)/g) || []).map((v) => v.slice(4)))]
|
||||
const cs = getComputedStyle(svg)
|
||||
for (const n of names) if (!cs.getPropertyValue(n).trim()) faults.push(`unresolved ${n}`)
|
||||
const all = [...svg.querySelectorAll('path,circle,text,rect,line')]
|
||||
const black = all.filter((e) => getComputedStyle(e).fill === 'rgb(0, 0, 0)')
|
||||
if (all.length && black.length > all.length * 0.25) {
|
||||
faults.push(`${black.length}/${all.length} map elements fill BLACK`)
|
||||
}
|
||||
return faults
|
||||
})()
|
||||
|
||||
return {
|
||||
sheets: document.querySelectorAll('.sheet').length,
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
imgs: imgs.length,
|
||||
broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length,
|
||||
nophoto: document.querySelectorAll('.nophoto').length,
|
||||
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
|
||||
rhythmsTotal: RHYTHMS.length,
|
||||
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
|
||||
hasMark5: body.includes('Gadarenes'), // a word unique to Mark 5:1-20
|
||||
metacamp: (body.match(/metacamp\.org/g) || []).length,
|
||||
mapFaults,
|
||||
}
|
||||
})
|
||||
|
||||
const pdf = await page.pdf({ preferCSSPageSize: true })
|
||||
await browser.close()
|
||||
|
||||
const raw = pdf.toString('latin1')
|
||||
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]])
|
||||
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
|
||||
const landscape = boxes.filter(([w, h]) => h <= w).length
|
||||
|
||||
// Counts are RELATIVE (sheets vs pages, cards vs photos) so this gate is not pinned to
|
||||
// one zone's group count — a per-zone packet has a different number of both.
|
||||
const fails = []
|
||||
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
|
||||
if (!r.cards) fails.push('no people-group cards')
|
||||
if (r.broken) fails.push(`${r.broken} broken photo(s)`)
|
||||
if (r.wheelSegs !== 12) fails.push(`prayer wheel has ${r.wheelSegs} segments, expected 12`)
|
||||
if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal} prayer rhythms present`)
|
||||
if (!r.hasMark5) fails.push('Mark 5:1-20 is NOT printed in full (Driver requirement for point 4)')
|
||||
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
|
||||
if (r.mapFaults.length) fails.push(`map: ${r.mapFaults.join('; ')}`)
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
if (landscape) fails.push(`${landscape} landscape page(s) — the packet must be portrait`)
|
||||
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be exactly one page`)
|
||||
|
||||
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
|
||||
console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`)
|
||||
console.log(` wheel segments : ${r.wheelSegs}`)
|
||||
console.log(` Mark 5 in full : ${r.hasMark5}`)
|
||||
console.log(` metacamp.org : ${r.metacamp}`)
|
||||
console.log(` map : ${r.mapFaults.length ? r.mapFaults.join('; ') : 'palette resolves, not black'}`)
|
||||
console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nPACKET-RENDER: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(` - ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nPACKET-RENDER: PASS — ${r.sheets} sheets, ${r.sheets} pages, portrait, map painted, content intact.`)
|
||||
|
|
@ -1453,7 +1453,21 @@ function main() {
|
|||
// while an absolute assetsBaseUrl serves the originals on demand (the same escape hatch used
|
||||
// for partner-site and mobile embeds). Set bundlePosters:true to ship them anyway for a
|
||||
// fully offline drop, accepting the size.
|
||||
const assetDirs = DEST_CONFIG.bundlePosters ? ['previews', 'posters'] : ['previews']
|
||||
// ★ fonts/ MUST be staged (measured 2026-09-24). A leaf page resolves its assetsBase to
|
||||
// <tree>/assets, so a vendored @font-face at assets/fonts/... is fetched from INSIDE the
|
||||
// tree — and this staging step is an allowlist, so a dir absent from it 404s. The regions
|
||||
// clone vendors Bebas Neue (public/assets/fonts/bebas-neue, OFL) because DX-life serves
|
||||
// theirs without CORS; with fonts/ unstaged the face registered, requested
|
||||
// <tree>/assets/fonts/..., failed, and every heading silently rendered on the fallback.
|
||||
// document.fonts reported the face in 'error' status while a computed-style diff of the
|
||||
// two pages said 0 differences — both sides agreeing on a FALLBACK is not a match, and a
|
||||
// font that 404s is invisible to every check that reads font-family rather than asking
|
||||
// the engine whether the family is usable.
|
||||
// Small and self-contained: a woff2 pair is ~22KB, and the tree's self-contained promise
|
||||
// is exactly why it has to travel with it.
|
||||
const assetDirs = DEST_CONFIG.bundlePosters
|
||||
? ['previews', 'posters', 'fonts']
|
||||
: ['previews', 'fonts']
|
||||
for (const dirName of assetDirs) {
|
||||
const src = join(pubAssets, dirName)
|
||||
if (!existsSync(src)) { console.warn(`[generate-tree] WARN missing tree asset dir: assets/${dirName}`); continue }
|
||||
|
|
|
|||
|
|
@ -63,6 +63,22 @@ const LEAK = [
|
|||
[/\beco-dx\b/, 'internal folder name (eco-dx)'], [/qa-cycle/, 'internal folder name (qa-cycle)'], [/\b_archive\b/, 'internal folder name (_archive)'],
|
||||
[/localhost:(5174|3001|3033)\b/, 'dev-server URL'],
|
||||
]
|
||||
// ★ ADDED 2026-09-24 (public-exposure audit, policies P3/P4/P7). A public tree carries
|
||||
// ARTIFACTS, never PROCESS or operational context — each of these was found live in a public
|
||||
// mirror by anonymous clone before this rule existed. They are WARNINGS in the normal build
|
||||
// (the source tree's own public/ still carries them, and failing build:unlocked for every
|
||||
// writer overnight would get this gate disabled) and FINDINGS under --strict, which the
|
||||
// publish/export flow MUST run (policy P5: publishing is a gated action, not a habit).
|
||||
const LEAK_STRICT = [
|
||||
[/\bthe Driver\b/, 'internal role noun (the Driver) — process vocabulary'],
|
||||
[/\bDriver\b/, 'internal role noun (Driver) — process vocabulary'],
|
||||
[/doxa-life\/doxa-maps/, 'private repo name'],
|
||||
[/\bdoxa-life\b/, 'private org name'],
|
||||
[/\bforgejo\b/i, 'self-hosted forge name'],
|
||||
[/\bnuxtbase\b/, 'internal platform name'],
|
||||
[/\bpersecution\b/, 'operational-context vocabulary (allowed only inside data CSVs)'],
|
||||
[/\.forgejo\/workflows/, 'CI workflow path — process, never ships in a public tree'],
|
||||
]
|
||||
// ★ WIDENED 2026-09-21. This held ONE pattern (the Mapbox JWT), on the reasoning that public/
|
||||
// is a tree we GENERATE, so the only realistic leak is the token the build itself writes.
|
||||
// THAT REASONING WAS WRONG: 855 files under public/ are TRACKED, 95 of them text — boundaries,
|
||||
|
|
@ -106,6 +122,24 @@ for (const f of files) {
|
|||
if (!isText || f.size > 40 * 1024 * 1024) continue
|
||||
const t = readFileSync(f.abs, 'utf8')
|
||||
for (const [re, why] of LEAK) if (re.test(t)) hit(findings, 'leaks', f.rel, why)
|
||||
for (const [re, why] of LEAK_STRICT) if (re.test(t) && !/\.csv$/.test(f.rel)) hit(STRICT ? findings : warnings, 'leaks', f.rel, why)
|
||||
// ★ P2 (2026-09-24): a credential is a DOCUMENT, not a string. Every JWT-shaped value gets
|
||||
// its payload DECODED and its claims reviewed: 'it is a public token' is not sufficient —
|
||||
// the pk. accepted by the 2026-09-21 ruling turned out to carry a `u` claim naming a real,
|
||||
// searchable ACCOUNT in base64 anyone can decode. The ruling covered the token's scope,
|
||||
// not the identity inside it. Flag any claim value that looks like a meaningful name.
|
||||
for (const m of t.matchAll(/\b(?:pk|sk|tk)\.(eyJ[A-Za-z0-9_-]{8,})/g)) {
|
||||
try {
|
||||
const pad = m[1] + '='.repeat((4 - (m[1].length % 4)) % 4)
|
||||
const claims = JSON.parse(Buffer.from(pad, 'base64').toString('utf8'))
|
||||
for (const [k, v] of Object.entries(claims)) {
|
||||
if (typeof v === 'string' && /^[a-z][a-z0-9_-]{3,}$/i.test(v) && !/^[0-9a-f]+$/i.test(v)) {
|
||||
hit(STRICT ? findings : warnings, 'secrets', f.rel, `JWT claim "${k}" carries a name-like value (${v.length} chars) — decode + review before publishing`)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch { /* not decodable — not a JWT payload */ }
|
||||
}
|
||||
for (const [re, why] of SECRET) if (re.test(t)) hit(findings, 'secrets', f.rel, why)
|
||||
if (/sourceMappingURL=/.test(t)) hit(warnings, 'debug', f.rel, 'sourceMappingURL comment (map file absent → harmless, but points at a missing map)')
|
||||
if (ext === '.js' && /console\.(log|debug)\(/.test(t)) hit(warnings, 'debug', f.rel, 'console.log/debug in a shipped bundle')
|
||||
|
|
|
|||
|
|
@ -116,6 +116,15 @@ createServer((req, res) => {
|
|||
'content-type': TYPES[extname(file).toLowerCase()] || 'application/octet-stream',
|
||||
'content-length': st.size,
|
||||
'cache-control': 'no-cache',
|
||||
// ★ CORS: the tree is PUBLIC STATIC ASSETS built to be embedded on OTHER sites (the
|
||||
// drop-anywhere contract). A cross-origin <script> tag loads fine without this, but the
|
||||
// composed surfaces (cap-4/cap-5) then FETCH manifest.json + assets/ -- and fetch()
|
||||
// enforces CORS, so on any third-party host the shell mounted and every manifest-composed
|
||||
// tab died with 'Could not load ...' (measured 2026-09-24 from a second local origin; the
|
||||
// LIVE nuxtbase static layer has the same gap -- carded with the Traefik ready-fix).
|
||||
// ACAO:* is the standard for credential-less public assets; production parity is the
|
||||
// whole point of this server, so it must teach the same requirement.
|
||||
'access-control-allow-origin': '*',
|
||||
})
|
||||
createReadStream(file).pipe(res)
|
||||
}).listen(PORT, HOST, () => {
|
||||
|
|
|
|||
94
library/colors/wagf-region.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* WAGF region color strategy — colors pins by the region they belong to.
|
||||
*
|
||||
* WHY THIS EXISTS. The regions element's no-selection state is a CHOOSER: the Driver's brief was
|
||||
* "some type of map that would show the regions and countries and let you select one". For the
|
||||
* seven regions to read AS regions the moment the map loads, the pins have to be colored by
|
||||
* region — which is a categorical encoding no existing strategy provides. (`_registry.js` and
|
||||
* `religion.js` both mention a `doxa-region.js`; it does not exist in this tree.)
|
||||
*
|
||||
* WHY IT LIVES IN library/colors/ AND NOT IN THE PLUGIN. Not a preference — check:one-palette
|
||||
* requires it. That gate exists because a dashboard composable once declared PRIVATE palettes the
|
||||
* map had never seen, and two surfaces then disagreed about what a color meant; worse, one keyed
|
||||
* color by ARRAY INDEX over a count-sorted list, so a religion CHANGED COLOR as you filtered.
|
||||
* Every hex a consumer paints with must come from a strategy module here, discovered by
|
||||
* `_registry.js`. A lookup table inside the component is the exact shape that caused the bug.
|
||||
*
|
||||
* COLOR FOLLOWS THE ENTITY, NEVER ITS RANK. The seven slots below are assigned in a FIXED
|
||||
* ALPHABETICAL ORDER of the region's own `value`, deliberately NOT in count order: Asia has 1309
|
||||
* people groups and Oceania 24 today, and if the palette were ordered by size then a cross-filter
|
||||
* that changed the counts would repaint the survivors. Alphabetical is arbitrary but STABLE, which
|
||||
* is the property that matters.
|
||||
*
|
||||
* THE PALETTE WAS VALIDATED, NOT CHOSEN BY EYE. Run through the dataviz validator
|
||||
* (scripts/validate_palette.js, light mode, surface #fcfcfb) and re-stepped twice until every
|
||||
* check passed with no warning:
|
||||
* Lightness band PASS all 7 inside L 0.43–0.77
|
||||
* Chroma floor PASS all 7 >= 0.1 (an earlier teal #0e7490 FAILED at 0.094 — read gray)
|
||||
* CVD separation PASS worst adjacent #dc2626 <-> #15803d dE 8.6 deutan (>= 8; 6–8 is a floor
|
||||
* band that would have been legal only with secondary encoding)
|
||||
* Normal vision PASS worst adjacent dE 30.6
|
||||
* Contrast PASS all 7 >= 3:1 (an earlier amber #f59e0b WARNed at 2.09:1)
|
||||
* The order of the hexes below IS the validated order — the CVD check measures ADJACENT pairs, so
|
||||
* a legend must render the regions in this same fixed order for that result to still hold.
|
||||
*
|
||||
* Field on the Mapbox feature: `region` (the wagf_region VALUE, e.g. 'latin_america_&_caribbean').
|
||||
*/
|
||||
|
||||
export const PROPERTY_KEY = 'region'
|
||||
|
||||
const DEFAULT_COLOR = '#6d766e' // DX-life --color-brand-lighter: an unknown region reads as chrome
|
||||
|
||||
/** Region value → display label, as the prayer API labels them. Used by the legend. */
|
||||
export const WAGF_REGIONS = {
|
||||
'africa': 'Africa',
|
||||
'asia': 'Asia',
|
||||
'europe': 'Europe',
|
||||
'latin_america_&_caribbean': 'Latin America & Caribbean',
|
||||
'middle_east': 'Middle East',
|
||||
'north_america_&_non-spanish_caribbean': 'North America & Non-Spanish Caribbean',
|
||||
'oceania': 'Oceania',
|
||||
}
|
||||
|
||||
/** Region value → color, in the validated order. Do not reorder without re-running the validator. */
|
||||
export const PALETTE = {
|
||||
'africa': '#2563eb',
|
||||
'asia': '#d97706',
|
||||
'europe': '#7c3aed',
|
||||
'latin_america_&_caribbean': '#15803d',
|
||||
'middle_east': '#dc2626',
|
||||
'north_america_&_non-spanish_caribbean': '#0891b2',
|
||||
'oceania': '#be185d',
|
||||
}
|
||||
|
||||
/** The fixed render order — the same order the palette was validated in. */
|
||||
export const REGION_ORDER = Object.keys(PALETTE)
|
||||
|
||||
/** Per-feature color resolver, for the JS layer. */
|
||||
export function getColor(properties) {
|
||||
const v = properties?.region || properties?._raw?.wagf_region?.value || properties?._raw?.wagf_region
|
||||
return PALETTE[v] || DEFAULT_COLOR
|
||||
}
|
||||
|
||||
/** Mapbox match expression colored by region value. */
|
||||
export function applyColor() {
|
||||
const match = ['match', ['coalesce', ['get', 'region'], '']]
|
||||
for (const key of REGION_ORDER) match.push(key, PALETTE[key])
|
||||
match.push(/* default */ DEFAULT_COLOR)
|
||||
return match
|
||||
}
|
||||
|
||||
// Registry-level alias, mirroring religion.js / language-family.js.
|
||||
export const buildColorExpression = applyColor
|
||||
|
||||
export default {
|
||||
name: 'WAGF Region',
|
||||
propertyKey: PROPERTY_KEY,
|
||||
palette: PALETTE,
|
||||
regions: WAGF_REGIONS,
|
||||
order: REGION_ORDER,
|
||||
getColor,
|
||||
applyColor,
|
||||
buildColorExpression,
|
||||
fallback: DEFAULT_COLOR,
|
||||
}
|
||||
93
library/composables/useDocumentFont.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
|
||||
*
|
||||
* WHY THIS IS NOT useShadowStyles. @font-face is DOCUMENT-SCOPED: a face declared inside a shadow
|
||||
* root is ignored by every engine, so a custom element cannot register its own webfont the way it
|
||||
* registers its own CSS. The face has to go in the host document; only then can shadow CSS refer
|
||||
* to the family by name. That asymmetry is the whole reason this file exists next to
|
||||
* useShadowStyles rather than inside it.
|
||||
*
|
||||
* WHY A WEBFONT AT ALL (measured 2026-09-24). The regions plugin is an exact clone of DX-life's
|
||||
* /regions pages, whose headings are Bebas Neue. We cannot @font-face from their origin — their
|
||||
* font file is served without CORS headers, so a cross-origin face silently fails and every
|
||||
* heading falls back to a condensed system stack. That substitution was the ONLY difference left
|
||||
* between our region dashboard and theirs: 7 computed properties, of which 3 were the family
|
||||
* itself and 4 were knock-ons (an uppercase hack to imitate Bebas's caps, and two font-sizes
|
||||
* stepped down because a wider fallback face overflowed their card widths). Vendoring the font
|
||||
* collapses all 7 and DELETES those workarounds rather than adding more.
|
||||
*
|
||||
* SELF-HOSTED, NEVER A CDN — the same rule public/assets/vendor/tabulator follows, and the
|
||||
* reason the files live under public/assets/fonts with their licence beside them.
|
||||
*
|
||||
* IDEMPOTENT per page: keyed by family name on document.head, so N elements composing on one page
|
||||
* register one face. Safe to call from setup(); no-ops server-side and in any context without a
|
||||
* document.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} family the family name shadow CSS will ask for, e.g. 'Bebas Neue'
|
||||
* @param {Array} faces [{ src, unicodeRange? }] — src RELATIVE to assetsBase
|
||||
* @param {string} assetsBase where the shared asset tree is served from (profileConfig.assetsBase)
|
||||
* @param {object} [opts] { weight = '400', style = 'normal', display = 'swap' }
|
||||
*/
|
||||
export function useDocumentFont(family, faces, assetsBase, opts = {}) {
|
||||
if (typeof document === 'undefined' || !family || !faces?.length) return
|
||||
const key = 'doxa-font-' + String(family).toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
||||
if (document.getElementById(key)) return
|
||||
|
||||
const base = String(assetsBase || './assets').replace(/\/+$/, '')
|
||||
const weight = opts.weight || '400'
|
||||
const style = opts.style || 'normal'
|
||||
const display = opts.display || 'swap'
|
||||
|
||||
const css = faces.map((f) => {
|
||||
// Per-face weight, because a real family is several files: Poppins ships 400/500/600/700 and
|
||||
// each weight is its own woff2 per subset. A face without its own weight falls back to the
|
||||
// call's single weight, so the one-weight callers (Bebas) are unchanged.
|
||||
const w = f.weight || weight
|
||||
// The unicode-range values are Google's own (css2 API), carried through verbatim so the
|
||||
// subset split behaves as it does on their site rather than pulling the larger file always.
|
||||
const range = f.unicodeRange ? ` unicode-range: ${f.unicodeRange};\n` : ''
|
||||
return `@font-face {\n font-family: '${family}';\n font-style: ${style};\n` +
|
||||
` font-weight: ${w};\n font-display: ${display};\n` +
|
||||
` src: url('${base}/${String(f.src).replace(/^\/+/, '')}') format('woff2');\n${range}}`
|
||||
}).join('\n')
|
||||
|
||||
const el = document.createElement('style')
|
||||
el.id = key
|
||||
el.setAttribute('data-doxa-font', family)
|
||||
el.textContent = css
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
|
||||
// Bebas Neue, as DX-life serves it: two subsets, Google's own ranges (css2 v16).
|
||||
export const BEBAS_NEUE_FACES = [
|
||||
{
|
||||
src: 'fonts/bebas-neue/bebas-neue-latin.woff2',
|
||||
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, ' +
|
||||
'U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, ' +
|
||||
'U+FEFF, U+FFFD',
|
||||
},
|
||||
{
|
||||
src: 'fonts/bebas-neue/bebas-neue-latin-ext.woff2',
|
||||
unicodeRange: 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, ' +
|
||||
'U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, ' +
|
||||
'U+2113, U+2C60-2C7F, U+A720-A7FF',
|
||||
},
|
||||
]
|
||||
|
||||
// Poppins — DX-life's --font-family-base, the body face of every page these components clone.
|
||||
// Only the four weights the regions surface asks for (measured), latin + latin-ext only, 44 KB.
|
||||
// Google's own unicode-ranges (css2 v24) so the subset split behaves as it does on their site.
|
||||
const POPPINS_LATIN = 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD'
|
||||
const POPPINS_LATIN_EXT = 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF'
|
||||
export const POPPINS_FACES = [
|
||||
{ weight: '400', src: 'fonts/poppins/poppins-400-latin.woff2', unicodeRange: POPPINS_LATIN },
|
||||
{ weight: '400', src: 'fonts/poppins/poppins-400-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
|
||||
{ weight: '500', src: 'fonts/poppins/poppins-500-latin.woff2', unicodeRange: POPPINS_LATIN },
|
||||
{ weight: '500', src: 'fonts/poppins/poppins-500-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
|
||||
{ weight: '600', src: 'fonts/poppins/poppins-600-latin.woff2', unicodeRange: POPPINS_LATIN },
|
||||
{ weight: '600', src: 'fonts/poppins/poppins-600-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
|
||||
{ weight: '700', src: 'fonts/poppins/poppins-700-latin.woff2', unicodeRange: POPPINS_LATIN },
|
||||
{ weight: '700', src: 'fonts/poppins/poppins-700-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
|
||||
]
|
||||
51
library/scripts/posters/data/README.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Vendored map data
|
||||
|
||||
## `ne-places.json` — capitals and major cities
|
||||
|
||||
**Source:** Natural Earth 1:10m populated places (`ne_10m_populated_places_simple`)
|
||||
**Licence:** **Public domain.** Natural Earth explicitly places its data in the public
|
||||
domain: no attribution required, no notice to carry onto a printed packet.
|
||||
**URL:** https://www.naturalearthdata.com/
|
||||
|
||||
### Why vendored rather than fetched
|
||||
|
||||
A build must be reproducible offline and must not add a runtime dependency — the
|
||||
posters are generated token-free and the packets are printed and carried. Vendoring
|
||||
also pins the data, so a poster regenerated next year looks like the one printed
|
||||
today.
|
||||
|
||||
### How it was trimmed (4.9 MB → 170 KB, 7,342 → 2,573 places)
|
||||
|
||||
Kept:
|
||||
- every national capital (`Admin-0 capital`, `Admin-0 capital alt`)
|
||||
- every first-order administrative capital (`Admin-1 capital`, and the
|
||||
`Admin-0/1 region capital` variants)
|
||||
- ordinary populated places only where `pop_max >= 250000`
|
||||
|
||||
A poster cannot legibly carry 7,342 dots, and the packet is downloaded, so the
|
||||
filter serves legibility and weight at once. Field names are single letters for the
|
||||
same reason:
|
||||
|
||||
n name (ascii) c ISO3 country
|
||||
k 0 national capital / 1 admin-1 capital / 2 city
|
||||
p pop_max y latitude x longitude
|
||||
|
||||
To regenerate, fetch `ne_10m_populated_places_simple.geojson` from
|
||||
`nvkelso/natural-earth-vector` and apply that filter.
|
||||
|
||||
## ⚠ Why there is no `ne-roads.json`
|
||||
|
||||
Roads were requested and **deliberately not shipped**. Natural Earth's 1:10m roads
|
||||
layer was fetched (50 MB) and measured: it has **ZERO features** for
|
||||
|
||||
LAO MMR KHM VNM THA NPL BTN TCD NER MLI SOM AFG YEM IDN IND CHN
|
||||
|
||||
Its attributed coverage is essentially USA (7,417 segments), Canada (900) and
|
||||
Mexico (897); 47,262 of 56,600 segments carry no country at all.
|
||||
|
||||
Shipping it would draw a dense road network on a United States map and **nothing**
|
||||
on every country these packets are actually for — which reads as a broken map
|
||||
precisely where it matters most. A roads layer needs a source with real coverage in
|
||||
South/Southeast Asia and Africa (an OpenStreetMap extract), which is a much larger
|
||||
dependency with an attribution obligation (ODbL). That is a separate piece of work,
|
||||
carded rather than half-shipped.
|
||||
1
library/scripts/posters/data/ne-places.json
Normal file
|
|
@ -53,6 +53,29 @@ const adm0 = readJSON(join(DATA, 'adm0', 'world-adm0.geojson'));
|
|||
const adm1Ind = readJSON(join(DATA, 'gb-IND-ADM1.geojson'));
|
||||
const adm1Chn = readJSON(join(DATA, 'gb-CHN-ADM1.geojson'));
|
||||
|
||||
// CAPITALS + MAJOR CITIES. Driver: "a typical map poster needs details on the map
|
||||
// that is not hard to see, but not so much that it becomes too complicated" —
|
||||
// "cities and capitals makes sense".
|
||||
//
|
||||
// Natural Earth 1:10m, PUBLIC DOMAIN: no attribution required, no licence notice to
|
||||
// carry onto a printed packet, and vendored so a build is reproducible offline and
|
||||
// adds no runtime dependency. Trimmed at vendor time from 4.9 MB / 7,342 places to
|
||||
// 170 KB / 2,573 (national + admin-1 capitals, plus cities >= 250k) because a poster
|
||||
// cannot legibly carry 7,342 dots and the packet is downloaded.
|
||||
//
|
||||
// ⚠ ROADS WERE DELIBERATELY NOT ADDED, though the Driver asked for "main roads".
|
||||
// Natural Earth's 10m roads layer has ZERO features for LAO, MMR, KHM, VNM, THA,
|
||||
// NPL, BTN, TCD, NER, MLI, SOM, AFG, YEM, IDN, IND and CHN — its coverage is
|
||||
// essentially USA/CAN/MEX. Shipping it would draw a rich road network on a US map
|
||||
// and NOTHING on the countries these packets are actually for, which reads as a
|
||||
// broken map exactly where it matters. A roads layer needs a source with real
|
||||
// coverage in South/Southeast Asia and Africa (OSM extracts), which is a much
|
||||
// bigger dependency — carded rather than half-shipped.
|
||||
const nePlaces = (() => {
|
||||
try { return readJSON(join(__dirname, 'data', 'ne-places.json')).places || []; }
|
||||
catch { return []; } // absent data degrades to no cities, never a crash
|
||||
})();
|
||||
|
||||
// CONFIG-ONLY, FAIL-CLOSED — mirrors src/api-connections/apiBaseUrl.js. The data
|
||||
// host comes SOLELY from build-time config; there is deliberately NO default host
|
||||
// so an unconfigured run never silently phones home to someone else's data source.
|
||||
|
|
@ -1258,6 +1281,104 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
|
||||
const laid = layoutLabels(settled, proj);
|
||||
|
||||
// ── CAPITALS + MAJOR CITIES ────────────────────────────────────────────────
|
||||
// Orientation marks, deliberately SUBORDINATE to the people-group pins. Imhof's
|
||||
// figure-ground again: this layer answers "where am I?" and must never compete
|
||||
// with the layer that answers "who do I pray for?".
|
||||
//
|
||||
// Three devices keep it quiet:
|
||||
// 1. A DENSITY BUDGET, not a fixed list. A sheet showing one small country and
|
||||
// one showing half a continent need different numbers of dots; ranking by
|
||||
// importance and capping the count keeps both legible.
|
||||
// 2. IMPORTANCE ORDER — national capitals first, then admin-1 capitals, then
|
||||
// big cities by population, so what survives the cap is what orients best.
|
||||
// 3. COLLISION REJECTION in SHEET space, not lng/lat degrees. Degrees are not
|
||||
// a constant distance on the page (and shrink with latitude), so a
|
||||
// degree-based spacing test scatters at one zoom and overlaps at another.
|
||||
let cityDots = '';
|
||||
const cityPlaced = [];
|
||||
if (!CITIES) { // city sheets have OSM streets already
|
||||
const inView = nePlaces.filter((p) =>
|
||||
p.x >= viewBbox[0] && p.x <= viewBbox[2] && p.y >= viewBbox[1] && p.y <= viewBbox[3]);
|
||||
// Budget scales with frame area so a dense sheet does not turn into a dot field.
|
||||
const budget = Math.max(6, Math.min(28, Math.round((FRAME_W * FRAME_H) / 42000)));
|
||||
// ★ RANK BY RELEVANCE TO THE SUBJECT, NOT BY GLOBAL IMPORTANCE.
|
||||
// Sorting purely by capital-rank then population looks reasonable and is wrong:
|
||||
// neighbours are often bigger than the subject. Measured on the Laos sheet, that
|
||||
// sort spent the budget on 12 Vietnamese and 8 Thai cities and printed exactly
|
||||
// ONE Laotian place (Vientiane) while 12 more sat available — a poster of Laos
|
||||
// labelled almost entirely with other countries' cities. The subject's places
|
||||
// orient a reader inside the zone; neighbours only fill what is left over.
|
||||
// ⚠ Derive the subject countries from THE PINS ON THIS SHEET, not from the zone
|
||||
// definition. `zone.adm0` exists only on generated city/country zones — the
|
||||
// hand-authored lists in clusters.json carry a `selector` in SEVEN different
|
||||
// shapes (pg_slugs, country_code, country_codes, wagf_block, wagf_region, union,
|
||||
// deaf_predicate), and the most common of them names no country at all. Reading
|
||||
// zone.adm0 therefore produced an EMPTY set for the Laos sheet and this whole
|
||||
// sort silently did nothing. The pins always know which country they are in.
|
||||
const memberIso = new Set(pins.map((p) => String(p.country_code || '').toUpperCase())
|
||||
.filter(Boolean));
|
||||
const ranked = inView.sort((a, b) =>
|
||||
(memberIso.has(b.c) - memberIso.has(a.c)) // subject countries first
|
||||
|| (a.k - b.k) // then national > admin-1 > city
|
||||
|| (b.p - a.p)); // then by population
|
||||
for (const p of ranked) {
|
||||
if (cityPlaced.length >= budget) break;
|
||||
const [cx, cy] = proj(p.x, p.y);
|
||||
if (cx < FRAME_X + 6 || cx > FRAME_X + FRAME_W - 6) continue;
|
||||
if (cy < FRAME_Y + 6 || cy > FRAME_Y + FRAME_H - 6) continue;
|
||||
// Reject anything too close to an already-placed city, IN PAGE PIXELS.
|
||||
const MIN_PX = p.k === 0 ? 26 : 34; // a capital may crowd a bit closer
|
||||
if (cityPlaced.some((q) => Math.hypot(q.cx - cx, q.cy - cy) < MIN_PX)) continue;
|
||||
// ★ THE PEOPLE-GROUP PINS WIN, ALWAYS. This layer orients; that layer IS the
|
||||
// poster. Measured before this guard: 7 of 25 city labels landed within 14px
|
||||
// of a numbered pin and Vientiane sat 6px away, so the orientation layer was
|
||||
// crowding the one thing a reader is meant to find. Choose the side of the
|
||||
// dot that is furthest from the nearest pin, and if even the better side is
|
||||
// cramped, drop the city entirely — a missing city costs nothing, an obscured
|
||||
// people group costs the poster its purpose.
|
||||
// ⚠ Test the BOX THE TEXT OCCUPIES, not its anchor point. Measuring distance
|
||||
// from the anchor is wrong for a right-aligned label: with text-anchor="end"
|
||||
// the glyphs run LEFTWARD from the anchor, so an anchor comfortably clear of a
|
||||
// pin can still have its text sitting on top of one. Measured with the naive
|
||||
// anchor test, Saravan landed 2px from a pin while the test reported it clear.
|
||||
const fs = p.k === 0 ? 10.5 : 9.5;
|
||||
const wPx = esc(p.n).length * fs * 0.55; // ~0.55em average advance
|
||||
const boxClear = (x0) => { // x0 = left edge of the glyphs
|
||||
const x1 = x0 + wPx, yTop = cy - fs * 0.5, yBot = cy + fs * 0.5;
|
||||
let best = Infinity;
|
||||
// ⚠ Use the LAID-OUT positions (laid[].px/py), not proj(pin.lng,pin.lat).
|
||||
// The de-overlap relaxation MOVES badges apart after projection, so the raw
|
||||
// projected point is not where the badge is drawn. Measuring against the
|
||||
// source coordinate let Saravan's label land 1.4px from a badge while the
|
||||
// guard believed it was clear. This block therefore runs AFTER layoutLabels.
|
||||
for (const pin of laid) {
|
||||
const px = pin.px, py = pin.py;
|
||||
const dx = px < x0 ? x0 - px : (px > x1 ? px - x1 : 0);
|
||||
const dy = py < yTop ? yTop - py : (py > yBot ? py - yBot : 0);
|
||||
best = Math.min(best, Math.hypot(dx, dy));
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const RIGHT = { dx: 5.5, anchor: 'start', clear: boxClear(cx + 5.5) };
|
||||
const LEFT = { dx: -5.5, anchor: 'end', clear: boxClear(cx - 5.5 - wPx) };
|
||||
const side = RIGHT.clear >= LEFT.clear ? RIGHT : LEFT;
|
||||
if (side.clear < 7) continue; // cramped on both sides — drop it
|
||||
cityPlaced.push({ cx, cy, ...p, ...side });
|
||||
}
|
||||
for (const c of cityPlaced) {
|
||||
// A capital is a ringed dot (the cartographic convention); a city is a plain
|
||||
// small dot. Shape carries the rank so colour does not have to.
|
||||
cityDots += c.k === 0
|
||||
? `<circle cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="3.6" class="cap-o"/>`
|
||||
+ `<circle cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="1.5" class="cap-i"/>`
|
||||
: `<circle cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="${c.k === 1 ? 2.1 : 1.7}" class="cty-d"/>`;
|
||||
cityDots += `<text x="${(c.cx + c.dx).toFixed(1)}" y="${(c.cy + 3.4).toFixed(1)}" `
|
||||
+ `text-anchor="${c.anchor}" class="${c.k === 0 ? 'cap-t' : 'cty-t'}">${esc(c.n)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// LABEL_RES is the text column; LANE_* is the channel between text and map.
|
||||
// Widened the text column (names were clipping: "Jarkhandi Julaha" ran off the
|
||||
// sheet) AND tightened the lane band, so the names sit CLOSER to the map while
|
||||
|
|
@ -2212,7 +2333,7 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
<!-- On a CITY sheet the zone fill is the whole COUNTRY (its geometry selector),
|
||||
so painting it over the tiles would hide every street the basemap exists to
|
||||
show. City sheets therefore drop ctx/zone fills and let OSM be the map. -->
|
||||
<g clip-path="url(#mapclip)">${baseLayer}${(CITIES && BASEMAP) ? '' : ctx}${(CITIES && BASEMAP) ? '' : ctxAdm1}${(CITIES && BASEMAP) ? '' : zoneP}${seaNames}${provNames}${ctryNames}${mapCredits}</g>
|
||||
<g clip-path="url(#mapclip)">${baseLayer}${(CITIES && BASEMAP) ? '' : ctx}${(CITIES && BASEMAP) ? '' : ctxAdm1}${(CITIES && BASEMAP) ? '' : zoneP}${seaNames}${provNames}${ctryNames}${cityDots}${mapCredits}</g>
|
||||
<clipPath id="mapclip"><rect x="${FRAME_X}" y="${FRAME_Y}" width="${FRAME_W}" height="${FRAME_H}"/></clipPath>
|
||||
<g style="--zc:${color}">${leaders}${dots}${pinDots}${labels}</g>
|
||||
<!-- TITLE, in two parts. "Unengaged People Groups of North India + Nepal" set
|
||||
|
|
@ -2309,6 +2430,22 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
.prov-name.own{fill:#6B4A78;font-weight:600}
|
||||
.prov-name-halo{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:11.5px;
|
||||
fill:none;stroke:#F2EFE6;stroke-width:3;stroke-linejoin:round;letter-spacing:.02em}
|
||||
/* CAPITALS + CITIES sit at the BOTTOM of the label hierarchy — smaller and
|
||||
lighter than province names — because they orient the reader, they are not the
|
||||
subject. Reading order stays country > province > city > people group, and the
|
||||
people-group pins keep the only saturated colour on the sheet.
|
||||
A capital is a ringed dot (the cartographic convention) and its name is set in
|
||||
the darker ink; an ordinary city is a plain dot with a lighter name. Shape and
|
||||
value carry the rank, so no extra hue is spent on this layer. */
|
||||
.cap-o{fill:#FFFFFF;stroke:#5C5443;stroke-width:1.4}
|
||||
.cap-i{fill:#5C5443}
|
||||
.cty-d{fill:#8A8271}
|
||||
.cap-t{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:10.5px;
|
||||
fill:#5C5443;font-weight:700;letter-spacing:.02em;paint-order:stroke;
|
||||
stroke:#F2EFE6;stroke-width:3;stroke-linejoin:round}
|
||||
.cty-t{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:9.5px;
|
||||
fill:#8A8271;font-weight:500;letter-spacing:.02em;paint-order:stroke;
|
||||
stroke:#F2EFE6;stroke-width:2.6;stroke-linejoin:round}
|
||||
.ctry-name{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;
|
||||
letter-spacing:.1em;fill:#6E6553;font-weight:700}
|
||||
.ctry-name-halo{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;
|
||||
|
|
|
|||
161
library/scripts/posters/packet-strings.json
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
{
|
||||
"_meta": {
|
||||
"purpose": "EVERY human-readable string in the UPG-100 prayer packet, in one file, so a new language is a new block here and NOT a code change. packet.mjs reads LANG (default 'en') and falls back to 'en' key-by-key, so a partial translation renders the translated keys and leaves the rest in English rather than blanking.",
|
||||
"how_to_translate": "Copy the \"en\" block, change the key to the ISO code, translate the values. Keep {placeholders} exactly as they are — they are substituted at render time. Do not translate scripture REFERENCES (book names are resolved by the reader's own Bible); DO translate the prayer text.",
|
||||
"note_on_scripture": "point4_passage is the ONLY full scripture text in the packet (Mark 5:1-20, printed in full at the Driver's instruction). A translation must supply its own rendering of that passage in a public-domain or properly-licensed version for that language."
|
||||
},
|
||||
"en": {
|
||||
"map_page_kicker": "UNENGAGED PEOPLE GROUPS",
|
||||
"map_page_sub": "A prayer list of {count} unreached people groups",
|
||||
"list_header": "{from}–{to} of {count} · pray for them by name",
|
||||
"people_suffix": "people",
|
||||
"no_photo": "no photo available",
|
||||
"points_title": "12 Prayer Points",
|
||||
"points_intro": "One hour with God, five minutes each. Pray these twelve for the people groups in this packet.",
|
||||
"points_attrib": "Prayer rhythms inspired by Metacamp · metacamp.org",
|
||||
"wheel_title": "Prayer Wheel",
|
||||
"wheel_intro": "Spend an hour with God? It is easier than you think. Break it into 5-minute blocks and focus on one of these during each segment.",
|
||||
"wheel_minutes": "minutes",
|
||||
"wheel_attrib": "Prayer Wheel · Metacamp Module 1 · metacamp.org",
|
||||
"privacy_note": "Marker positions are approximate · people-group locations are generalised at the source for privacy",
|
||||
"rhythms": {
|
||||
"praise": "PRAISE",
|
||||
"wait": "WAIT",
|
||||
"confess": "CONFESS",
|
||||
"read": "READ THE WORD",
|
||||
"ask": "ASK",
|
||||
"intercede": "INTERCESSION",
|
||||
"pray_word": "PRAY THE WORD",
|
||||
"thank": "THANK",
|
||||
"sing": "SING",
|
||||
"meditate": "MEDITATE",
|
||||
"listen": "LISTEN",
|
||||
"praise_end": "PRAISE"
|
||||
},
|
||||
"points": [
|
||||
{
|
||||
"n": 1,
|
||||
"rhythm": "praise",
|
||||
"sub": "Two-Cross Gospel",
|
||||
"text": "That they accept Christ as King, forgiven by His cross — and take up their own cross, willing to suffer in a persecution zone. May the Lamb receive the reward of His suffering, starting with me.",
|
||||
"refs": [
|
||||
"Matthew 27:27",
|
||||
"Matthew 16:24–25"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"rhythm": "wait",
|
||||
"sub": "Holy Spirit & deliverance",
|
||||
"text": "That they are filled with the Spirit and made bold witnesses; not isolated; baptized in water and Spirit; every evil grip broken. That believers in neighbouring peoples are mobilised as laborers.",
|
||||
"refs": [
|
||||
"Acts 1:8",
|
||||
"John 14:26"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 3,
|
||||
"rhythm": "confess",
|
||||
"sub": "",
|
||||
"text": "We confess unbelief that this is too big for God. That He revives our love and faith for the global unreached.",
|
||||
"refs": [
|
||||
"John 3:16",
|
||||
"1 John 1:9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 4,
|
||||
"rhythm": "read",
|
||||
"sub": "the Father's love",
|
||||
"text": "That they would be SAVED and KNOW their heavenly Father loves them — and that through them He would bring salvation to their family, friends, neighbours, coworkers, classmates, everyone they know personally.",
|
||||
"refs": [
|
||||
"Mark 5:1–20",
|
||||
"Acts 16:31"
|
||||
],
|
||||
"print_passage": true
|
||||
},
|
||||
{
|
||||
"n": 5,
|
||||
"rhythm": "ask",
|
||||
"sub": "baptisms",
|
||||
"text": "New baptisms among the locals, and every baptized one trained to baptize others, multiplying.",
|
||||
"refs": [
|
||||
"Acts 16",
|
||||
"Acts 10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 6,
|
||||
"rhythm": "intercede",
|
||||
"sub": "laborers & shepherds",
|
||||
"text": "Laborers sent to deeply love a people they have never met; good shepherds raised up from within.",
|
||||
"refs": [
|
||||
"Luke 10:2",
|
||||
"John 10:11–15"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 7,
|
||||
"rhythm": "pray_word",
|
||||
"sub": "Persons & Houses of Peace",
|
||||
"text": "That laborers and local leaders find the receptive person and the house of peace, and disciple them well.",
|
||||
"refs": [
|
||||
"Luke 10:5–7",
|
||||
"Matthew 10:5–14"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 8,
|
||||
"rhythm": "thank",
|
||||
"sub": "rest",
|
||||
"text": "Thank God for the rest He has given; that they enter that rest in Christ, and that their spiritual family is a safe place.",
|
||||
"refs": [
|
||||
"Mark 4:26–29",
|
||||
"Hebrews 4:9–11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 9,
|
||||
"rhythm": "sing",
|
||||
"sub": "",
|
||||
"text": "That the people group is flooded with worshippers — worship everywhere, in freedom and in chains.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 10,
|
||||
"rhythm": "meditate",
|
||||
"sub": "communion",
|
||||
"text": "That they take communion together and grasp the weight of His death, burial and resurrection.",
|
||||
"refs": [
|
||||
"Matthew 26:26–30",
|
||||
"1 Corinthians 11:23–26"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 11,
|
||||
"rhythm": "listen",
|
||||
"sub": "accountability",
|
||||
"text": "Not only that they would listen, but that they would have people in their life who help them read the Word, pray, commit to community, and respond to suffering well — resisting the adversary who comes after believers across the whole world.",
|
||||
"refs": [
|
||||
"1 Peter 5:6–11",
|
||||
"Hebrews 10:24–25"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 12,
|
||||
"rhythm": "praise_end",
|
||||
"sub": "great · greater · greatest",
|
||||
"text": "Praise in advance — for salvations; GREATER, for spiritual families multiplying; GREATEST, for lives yielded and local church planters raised up who multiply healthy churches across the whole people group, no matter the cost.",
|
||||
"refs": [
|
||||
"Acts 2:42–47",
|
||||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
],
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
411
library/scripts/posters/packet.mjs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// packet.mjs — the UPG-100 PRAYER PACKET generator.
|
||||
//
|
||||
// One printable packet per zone. Structure is the Driver's, from the ideation
|
||||
// (_qa-cycle/01-ideation-research/prayer-points.md):
|
||||
//
|
||||
// page 1 the MAP — a lightweight vertical one-pager, NOT the poster format
|
||||
// pages 2..n the PEOPLE GROUPS — 20 per page, photo + name + brief detail
|
||||
// page n+1 the 12 PRAYER POINTS
|
||||
// page n+2 the PRAYER WHEEL — the rhythm these twelve points follow
|
||||
//
|
||||
// Printed front-and-back: Laos (95 groups) is 8 pages = 4 sheets.
|
||||
//
|
||||
// ── WHY A SEPARATE SCRIPT, not a FORMAT inside generate.mjs ──────────────────
|
||||
// generate.mjs is a 2,600-line POSTER renderer. The packet is a different artefact
|
||||
// with different pages; forking it would duplicate the design. Instead this reads
|
||||
// the SAME zone resolution (clusters.json + the selector evaluator) so a packet can
|
||||
// never disagree with its poster about which groups belong to a zone.
|
||||
//
|
||||
// ── EVERY STRING IS IN packet-strings.json ──────────────────────────────────
|
||||
// Driver: "it all needs to be generative so that I can generate it in multiple
|
||||
// languages." So no human-readable text is written in this file. LANG=xx picks a
|
||||
// block; missing keys fall back to English individually, so a partial translation
|
||||
// renders what it has rather than blanking the page.
|
||||
//
|
||||
// ── PHOTOS ──────────────────────────────────────────────────────────────────
|
||||
// MEASURED: 1,072 of 2,107 groups have one (51%); they average ~244KB on Joshua
|
||||
// Project's CDN, ~0.8s each. Fetched in parallel at packet time and inlined as
|
||||
// data URIs so the finished HTML is ONE self-contained file that prints anywhere.
|
||||
// Groups without a photo get a typographic card, never a broken image box.
|
||||
//
|
||||
// Usage:
|
||||
// node packet.mjs # every zone, English
|
||||
// ONLY=laos node packet.mjs # one zone
|
||||
// LANG=es ONLY=laos node packet.mjs # one zone, Spanish
|
||||
// NOPHOTOS=1 ONLY=laos node packet.mjs # skip the fetch (fast layout iteration)
|
||||
//
|
||||
// Output: out/packets/<zoneKey>[.<lang>].html
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = join(__dirname, '../../../plugin-designs/my-upg-100-list/data');
|
||||
const OUT = join(__dirname, 'out', 'packets');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// ⚠ NOT process.env.LANG — that is the SHELL's POSIX locale ("en_US.UTF-8"), which
|
||||
// is not a valid BCP-47 tag and throws in localeCompare/toLocaleString. Measured:
|
||||
// it crashed the first run. PACKET_LANG is the only source.
|
||||
const LANG = process.env.PACKET_LANG || 'en';
|
||||
const ONLY = process.env.ONLY || '';
|
||||
const NOPHOTOS = process.env.NOPHOTOS === '1';
|
||||
const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
|
||||
if (!API) {
|
||||
console.error('[packet] No API base — set VITE_API_BASE_URL. Failing closed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── STRINGS: pick the language, fall back per-key to English ────────────────
|
||||
const STRINGS = JSON.parse(readFileSync(join(__dirname, 'packet-strings.json'), 'utf8'));
|
||||
const EN = STRINGS.en;
|
||||
const L = STRINGS[LANG] || {};
|
||||
// Per-KEY fallback (not whole-block): a half-finished translation should render
|
||||
// its finished keys, not fall back wholesale to English.
|
||||
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
|
||||
const tr = (k) => ((L.rhythms || {})[k] !== undefined ? L.rhythms[k] : EN.rhythms[k]);
|
||||
const POINTS = L.points || EN.points;
|
||||
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? ''));
|
||||
|
||||
// ── SHEET GEOMETRY — the letter-portrait sheet, same as the poster format ───
|
||||
const W = 1500, H = 1941; // CSS px; 8.5x11 at width 1500
|
||||
const PAGE_W_IN = 8.5;
|
||||
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
|
||||
const M = 60, HEAD = 90; // margins + running header
|
||||
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
|
||||
const GUT = 14;
|
||||
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
|
||||
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
|
||||
const PHOTO = Math.min(CH - 8, CW * 0.34);
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
// The API returns some fields as { value, label } dicts; the card wants the label.
|
||||
const flat = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''));
|
||||
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(LANG) : '—');
|
||||
|
||||
// ── DATA ────────────────────────────────────────────────────────────────────
|
||||
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
|
||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
|
||||
|
||||
async function loadRecords() {
|
||||
const cache = join(__dirname, 'out', '_records.json');
|
||||
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
|
||||
const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude';
|
||||
const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`,
|
||||
{ headers: { accept: 'application/json' } });
|
||||
if (!r.ok) throw new Error(`PG API ${r.status}`);
|
||||
const recs = normalizeRecords(extractRecords(await r.json()));
|
||||
try { writeFileSync(cache, JSON.stringify(recs)); } catch { /* best effort */ }
|
||||
return recs;
|
||||
}
|
||||
|
||||
function allZones() {
|
||||
const out = [];
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
||||
for (const z of clusters[k] || []) out.push({ ...z, zoneKey: z.id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────
|
||||
// Bounded concurrency: 16 was measured at 20 photos in 5.9s. Unbounded would open
|
||||
// ~118 sockets against someone else's CDN, which is rude and slower in practice.
|
||||
async function fetchPhotos(groups, limit = 16) {
|
||||
if (NOPHOTOS) return new Map();
|
||||
const out = new Map();
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
while (i < groups.length) {
|
||||
const g = groups[i++];
|
||||
if (!g.image_url) continue;
|
||||
try {
|
||||
const r = await fetch(g.image_url, { signal: AbortSignal.timeout(20000) });
|
||||
if (!r.ok) continue;
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
const mime = r.headers.get('content-type') || 'image/jpeg';
|
||||
out.set(g.slug, `data:${mime};base64,${buf.toString('base64')}`);
|
||||
} catch { /* a missing photo degrades to the typographic card */ }
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, groups.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── PAGE SHELL ──────────────────────────────────────────────────────────────
|
||||
const sheet = (inner, cls = '') =>
|
||||
`<div class="sheet ${cls}">${inner}</div>`;
|
||||
|
||||
function css() {
|
||||
return `
|
||||
@page{size:letter portrait;margin:0}
|
||||
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;
|
||||
page-break-after:always;break-after:page}
|
||||
.sheet:last-child{page-break-after:auto;break-after:auto}
|
||||
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
|
||||
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
|
||||
.card{position:absolute;display:flex;gap:12px;align-items:center;
|
||||
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
|
||||
.card img,.card .nophoto{width:${PHOTO.toFixed(0)}px;height:${PHOTO.toFixed(0)}px;flex:0 0 auto;border-radius:3px}
|
||||
.card img{object-fit:cover}
|
||||
.card .nophoto{border:1px solid #d6d0c0;background:#f2efe6;display:flex;align-items:center;
|
||||
justify-content:center;font:italic 13px Georgia,serif;color:#a89f8c;text-align:center;padding:6px}
|
||||
.nm{font:700 25px Georgia,serif;color:#1b1a17;line-height:1.1}
|
||||
.mt{font:400 17px Georgia,serif;color:#6d766e}
|
||||
/* FINE PRINT — Driver: "attribution minimal, fine print small". */
|
||||
.fine{position:absolute;left:0;right:0;bottom:22px;text-align:center;
|
||||
font:400 13px Georgia,serif;color:#a89f8c}
|
||||
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
|
||||
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
|
||||
|
||||
⚠ Use zoom, NOT transform:scale(). They look identical on screen and behave
|
||||
completely differently under pagination:
|
||||
transform is paint-time — the element still RESERVES its full ${H}px layout
|
||||
box, so the paginator walks ${H}px of content and breaks it across
|
||||
two sheets, inking only the top of the first. Measured: 8 sheets
|
||||
came out as 16 PDF pages. Negative margins do NOT fix this — they
|
||||
collapse the outer space, not the fragmentation box.
|
||||
zoom is layout-time — the box itself becomes ${(PAGE_W_IN*96).toFixed(0)}px
|
||||
wide, so pagination sees the scaled size. Measured: 1 page/sheet.
|
||||
Verified by _probe-pagecount.mjs (transform 2, transform+margins 2, zoom 1). */
|
||||
@media print{
|
||||
html,body{width:${(PAGE_W_IN * 96).toFixed(0)}px;margin:0;padding:0}
|
||||
.sheet{width:${W}px;height:${H}px;zoom:${SCALE};
|
||||
page-break-after:always;break-after:page}
|
||||
.sheet:last-child{page-break-after:auto;break-after:auto}
|
||||
}`;
|
||||
}
|
||||
|
||||
// Lift the poster's stylesheet so the inlined map keeps its palette and map rules,
|
||||
// dropping only the rules that would fight the packet's own page shell.
|
||||
//
|
||||
// Comments are stripped FIRST and deliberately: generate.mjs's CSS comments are long
|
||||
// prose that contains braces and selector-like text, so any brace-counting or
|
||||
// selector regex run over the raw source mis-parses them as rules. Strip, then split.
|
||||
function mapCss(posterHtml) {
|
||||
const block = posterHtml.match(/<style>([\s\S]*?)<\/style>/);
|
||||
if (!block) return '';
|
||||
const css = block[1].replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// Drop @media (the poster's own print-scaling rules, which do not apply here).
|
||||
// Depth-aware because @media bodies nest a further level of braces.
|
||||
let out = '', depth = 0, i = 0;
|
||||
while (i < css.length) {
|
||||
if (css.startsWith('@media', i)) {
|
||||
let d = 0, started = false;
|
||||
while (i < css.length) {
|
||||
if (css[i] === '{') { d++; started = true; }
|
||||
else if (css[i] === '}') { d--; if (started && d === 0) { i++; break; } }
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out += css[i++];
|
||||
}
|
||||
|
||||
// Drop document-level rules: these style the POSTER page, not the map, and would
|
||||
// override the packet's background, box-sizing and stage geometry.
|
||||
const DOC = /(^|,)\s*(html|body|\*|\.stage)\s*$/;
|
||||
return out.split('}')
|
||||
.map((r) => {
|
||||
const at = r.indexOf('{');
|
||||
if (at < 0) return '';
|
||||
const sel = r.slice(0, at).trim();
|
||||
if (!sel) return '';
|
||||
// :root must be KEPT — it carries the palette the map's var()s resolve against.
|
||||
if (sel !== ':root' && sel.split(',').some((s) => DOC.test(s.trim()) || /^(html|body|\*|\.stage)$/.test(s.trim()))) return '';
|
||||
return `${sel}{${r.slice(at + 1).trim()}}`;
|
||||
})
|
||||
.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// ── PAGE 1: the map one-pager ───────────────────────────────────────────────
|
||||
// Lightweight by design — the Driver asked for "a lightweight one pager that shows
|
||||
// the geography with all the pins", explicitly NOT the poster. The poster remains
|
||||
// its own artefact (generate.mjs); this is the packet's cover.
|
||||
function mapPage(zone, groups, mapSvg) {
|
||||
const count = groups.length;
|
||||
return sheet(`
|
||||
<div style="position:absolute;left:${M}px;top:${M}px;right:${M}px;text-align:center">
|
||||
<div style="font:600 21px Georgia,serif;letter-spacing:3px;color:#8c8170">${esc(t('map_page_kicker'))}</div>
|
||||
<div style="font:700 56px Georgia,serif;color:#1b1a17;margin-top:8px">${esc(zone.label || zone.id)}</div>
|
||||
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
|
||||
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
|
||||
</div>
|
||||
${mapSvg}
|
||||
<div class="fine">${esc(t('privacy_note'))}</div>`);
|
||||
}
|
||||
|
||||
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
|
||||
function listPages(zone, groups, photos) {
|
||||
const pages = [];
|
||||
for (let start = 0; start < groups.length; start += PER_PAGE) {
|
||||
const slice = groups.slice(start, start + PER_PAGE);
|
||||
const cards = slice.map((g, k) => {
|
||||
const i = start + k;
|
||||
const c = k % COLS, r = Math.floor(k / COLS);
|
||||
const left = M + c * (CW + GUT), top = M + HEAD + r * (CH + GUT);
|
||||
const src = photos.get(g.slug);
|
||||
const img = src
|
||||
? `<img src="${src}" alt="">`
|
||||
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
|
||||
return `<div class="card" style="left:${left.toFixed(0)}px;top:${top.toFixed(0)}px;
|
||||
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
|
||||
<div style="min-width:0">
|
||||
<div class="nm">${i + 1}. ${esc(g.name)}</div>
|
||||
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
|
||||
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
|
||||
</div></div>`;
|
||||
}).join('');
|
||||
pages.push(sheet(`
|
||||
<div class="hd">${esc(zone.label || zone.id)}</div>
|
||||
<div class="hd-r">${esc(fill(t('list_header'),
|
||||
{ from: num(start + 1), to: num(Math.min(start + PER_PAGE, groups.length)), count: num(groups.length) }))}</div>
|
||||
${cards}`));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
|
||||
function pointsPage() {
|
||||
const rows = POINTS.map((p) => {
|
||||
const refs = (p.refs || []).map((r) => esc(r)).join(' · ');
|
||||
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
|
||||
const passage = p.print_passage
|
||||
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full
|
||||
// scripture, because prayer point number four is actually read the word."
|
||||
? `<div style="font:400 14px Georgia,serif;color:#4a4a45;line-height:1.5;margin-top:5px;
|
||||
padding-left:10px;border-left:2px solid #d9d3c4">${esc(t('point4_passage'))}
|
||||
<div style="font:italic 12px Georgia,serif;color:#a89f8c;margin-top:3px">
|
||||
${esc(t('point4_passage_credit'))}</div></div>`
|
||||
: '';
|
||||
return `<div style="margin-bottom:${p.print_passage ? 10 : 13}px">
|
||||
<div style="font:700 19px Georgia,serif;color:#1b1a17">
|
||||
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
|
||||
<div style="font:400 17px Georgia,serif;color:#3b3226;line-height:1.35;margin-top:2px">${esc(p.text)}</div>
|
||||
<div style="font:600 14px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
|
||||
${passage}
|
||||
</div>`;
|
||||
}).join('');
|
||||
return sheet(`
|
||||
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
|
||||
<div style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
|
||||
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
|
||||
</div>
|
||||
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px;bottom:52px">${rows}</div>
|
||||
<div class="fine">${esc(t('points_attrib'))}</div>`);
|
||||
}
|
||||
|
||||
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
|
||||
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
|
||||
// be translated, and this packet must generate in any language. Twelve equal
|
||||
// segments, the rhythm name set along each, 5 minutes each = one hour.
|
||||
function wheelPage() {
|
||||
const cx = W / 2, cy = 1090, R = 470;
|
||||
const order = ['praise', 'wait', 'confess', 'read', 'ask', 'intercede',
|
||||
'pray_word', 'thank', 'sing', 'meditate', 'listen', 'praise_end'];
|
||||
// Hues sweep the wheel so neighbouring rhythms stay distinguishable in print.
|
||||
const seg = order.map((key, i) => {
|
||||
const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
|
||||
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
|
||||
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
|
||||
const hue = Math.round(i * 30);
|
||||
const mid = (a0 + a1) / 2;
|
||||
const tx = cx + R * 0.62 * Math.cos(mid), ty = cy + R * 0.62 * Math.sin(mid);
|
||||
let deg = mid * 180 / Math.PI;
|
||||
if (deg > 90 || deg < -90) deg += 180; // keep every label upright
|
||||
return `<path d="M ${cx} ${cy} L ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R} ${R} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z"
|
||||
fill="hsl(${hue} 70% 72%)" stroke="#fff" stroke-width="3"/>
|
||||
<text x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" transform="rotate(${deg.toFixed(1)} ${tx.toFixed(1)} ${ty.toFixed(1)})"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="Georgia,serif" font-weight="700" font-size="21" fill="#2b2a26">${esc(tr(key))}</text>`;
|
||||
}).join('');
|
||||
const pips = order.map((_, i) => {
|
||||
const a = ((i + 0.5) * 30 - 90) * Math.PI / 180;
|
||||
const x = cx + (R + 46) * Math.cos(a), y = cy + (R + 46) * Math.sin(a);
|
||||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="21" fill="#26251f"/>
|
||||
<text x="${x.toFixed(1)}" y="${(y + 1).toFixed(1)}" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="Georgia,serif" font-weight="700" font-size="20" fill="#fff">5</text>`;
|
||||
}).join('');
|
||||
return sheet(`
|
||||
<div style="position:absolute;left:${M}px;top:48px;right:${M}px;text-align:center">
|
||||
<div style="font:700 42px Georgia,serif;color:#1b1a17">${esc(t('wheel_title'))}</div>
|
||||
<div style="font:italic 19px Georgia,serif;color:#6d766e;margin-top:10px;
|
||||
max-width:1000px;margin-left:auto;margin-right:auto">${esc(t('wheel_intro'))}</div>
|
||||
</div>
|
||||
<svg width="${W}" height="${H}" style="position:absolute;left:0;top:0" xmlns="http://www.w3.org/2000/svg">
|
||||
${seg}${pips}
|
||||
<text x="${cx}" y="${cy + R + 130}" text-anchor="middle"
|
||||
font-family="Georgia,serif" font-size="20" fill="#8c8170">5 ${esc(t('wheel_minutes'))} × 12</text>
|
||||
</svg>
|
||||
<div class="fine">${esc(t('wheel_attrib'))}</div>`);
|
||||
}
|
||||
|
||||
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
|
||||
async function buildPacket(zone, groups, mapSvg) {
|
||||
const photos = await fetchPhotos(groups);
|
||||
const pages = [
|
||||
mapPage(zone, groups, mapSvg),
|
||||
...listPages(zone, groups, photos),
|
||||
pointsPage(),
|
||||
wheelPage(),
|
||||
];
|
||||
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
|
||||
const html = `<!DOCTYPE html><html lang="${esc(LANG)}"><head><meta charset="utf-8">
|
||||
<title>${esc(zone.label || zone.id)}</title><style>${css()}</style></head>
|
||||
<body>${pages.join('\n')}</body></html>`;
|
||||
return { html, pageCount: pages.length, withPhoto };
|
||||
}
|
||||
|
||||
// ── MAIN ────────────────────────────────────────────────────────────────────
|
||||
const records = await loadRecords();
|
||||
const bySlug = new Map(records.map((r) => [r.slug, r]));
|
||||
const zones = allZones().filter((z) => !ONLY || z.id === ONLY);
|
||||
if (!zones.length) { console.error(`[packet] no zone matches ONLY=${ONLY}`); process.exit(1); }
|
||||
|
||||
let made = 0;
|
||||
for (const z of zones) {
|
||||
let slugs = [];
|
||||
try {
|
||||
// evaluate() returns a SET, not an array — spread it rather than assuming.
|
||||
slugs = z.selector?.type === 'pg_slugs'
|
||||
? (z.selector.values || [])
|
||||
: (z.selector ? [...evaluate(z.selector, records)] : []);
|
||||
} catch (e) { console.warn(`[packet] selector failed for ${z.id}: ${e.message}`); continue; }
|
||||
const groups = slugs.map((s) => bySlug.get(s)).filter(Boolean)
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name), LANG));
|
||||
if (!groups.length) { console.warn(`[packet] ${z.id}: no groups resolved — skipped`); continue; }
|
||||
|
||||
// The map one-pager reuses the poster's SVG when it has been generated; without
|
||||
// it the packet still builds (cover page without the map) rather than failing.
|
||||
const posterPath = join(__dirname, 'out', 'posters', `${z.id}.html`);
|
||||
let mapSvg = '';
|
||||
if (existsSync(posterPath)) {
|
||||
const poster = readFileSync(posterPath, 'utf8');
|
||||
const m = poster.match(/<svg[\s\S]*?<\/svg>/);
|
||||
// ⚠ The map SVG carries almost NO presentation attributes — generate.mjs styles
|
||||
// it entirely from the poster document's <style> block (.ctry, .lbl, .lead, the
|
||||
// :root palette...). Lifting the <svg> alone leaves those rules behind, and SVG
|
||||
// `fill` then falls back to its initial value, which is BLACK. Measured: 349
|
||||
// <text>, 203 <path class="ctry"> and 95 <circle> painted solid black, with no
|
||||
// error and no broken image — the map was a black rectangle.
|
||||
// So carry the poster's stylesheet across too, MINUS its document-level rules
|
||||
// (html/body/.stage/@media print) which would fight the packet's own shell.
|
||||
// Taking the stylesheet rather than duplicating hex codes means a palette or
|
||||
// map-style change in generate.mjs follows the packet automatically.
|
||||
if (m) mapSvg = `<style>${mapCss(poster)}</style>
|
||||
<div style="position:absolute;left:0;top:250px;width:${W}px;
|
||||
transform:scale(0.62);transform-origin:top center">${m[0]}</div>`;
|
||||
}
|
||||
|
||||
const { html, pageCount, withPhoto } = await buildPacket(z, groups, mapSvg);
|
||||
const name = LANG === 'en' ? `${z.id}.html` : `${z.id}.${LANG}.html`;
|
||||
writeFileSync(join(OUT, name), html);
|
||||
made++;
|
||||
console.log(` ✓ ${z.id.padEnd(34)} ${String(groups.length).padStart(4)} groups `
|
||||
+ `${pageCount} pages ${withPhoto} photos ${(html.length / 1024 / 1024).toFixed(1)} MB`);
|
||||
}
|
||||
console.log(`\n${made} packet(s) → ${OUT}${ONLY ? ` (ONLY=${ONLY})` : ''}${LANG !== 'en' ? ` [${LANG}]` : ''}`);
|
||||
|
|
@ -55,6 +55,7 @@
|
|||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"check:packet-render": "node internal/check-packet-render.mjs",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
"verify:map-paint": "node internal/verify-map-paint.mjs",
|
||||
"check:sri": "node internal/sri-consistency-check.mjs",
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@
|
|||
* config/countries-meta.ts. Copied rather than imported for the same reason stated there — and
|
||||
* kept field-for-field identical so both tabs roll up the same way.
|
||||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES } from '@map/composables/useDocumentFont.js'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import UupgsList from './src/UupgsList.vue'
|
||||
import RegionCountryMap from './src/RegionCountryMap.vue'
|
||||
|
|
@ -62,6 +63,19 @@ const instanceId = computed(() => cfg.value.instanceId || '')
|
|||
// already forwards it in INHERITED_PAGE_KEYS; this reads it instead of trusting the default.
|
||||
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
|
||||
|
||||
// ★ REGISTER BEBAS NEUE AT DOCUMENT LEVEL, not in the shadow root: @font-face is
|
||||
// document-scoped and a face declared inside a shadow root is ignored. Their headings ARE
|
||||
// this font (tokens/semantic/_typography.scss:5) and it could not be loaded from their
|
||||
// origin — no CORS headers — so it is vendored under public/assets/fonts/bebas-neue (OFL,
|
||||
// provenance in LICENSE.txt beside it). Loading it deleted an uppercase imitation and two
|
||||
// stepped-down font-sizes that only existed because the fallback face was wider.
|
||||
useDocumentFont('Bebas Neue', BEBAS_NEUE_FACES, assetsBase.value)
|
||||
// Poppins is their --font-family-base — the face of every line of BODY text on these
|
||||
// pages. It was the larger of the two typography gaps and completely invisible to a
|
||||
// computed-style diff, which read 68/68 while our body text was a system fallback 5.5%
|
||||
// wider per glyph. Registered the same way and for the same reason as Bebas.
|
||||
useDocumentFont('Poppins', POPPINS_FACES, assetsBase.value)
|
||||
|
||||
// ── state ───────────────────────────────────────────────────────────────────
|
||||
const regions = ref([])
|
||||
// The rollup aggregates records away, and the map needs them one by one. Kept flat and
|
||||
|
|
@ -391,6 +405,79 @@ const mapNote = computed(() => {
|
|||
// of ours. It fetches and filters the people groups itself, exactly as theirs does, so there is
|
||||
// no countryTable computed here any more.
|
||||
|
||||
// ── URL <-> SELECTION: what makes this a standalone app rather than a widget ─
|
||||
// MEASURED before this existed (_qa-cycle/02-builder-cycle/_probe-standalone-app.mjs): 4 of 10
|
||||
// app properties OK. Five of the six failures were ONE missing thing — the URL never reflected
|
||||
// the selection, so nothing was linkable, a reload lost the subject, Back unmounted the element,
|
||||
// a deep link did nothing, and Share shared the index. DX-life gets all of that free from Nuxt
|
||||
// routing (/regions/oceania IS the state); a custom element has to do it deliberately.
|
||||
//
|
||||
// THE HASH, NOT THE PATH. A leaf page is a static file at a fixed URL and there is no server to
|
||||
// route /regions/oceania, so the subject lives in the hash — and the hash is also the only part
|
||||
// an EMBEDDED element may touch without fighting its host page. Slugs are DX-life's own
|
||||
// (#oceania, #cameroon), so a link reads the way theirs does.
|
||||
// replaceState on the first paint, pushState on a user choice: that is what makes Back mean
|
||||
// "the previous subject" instead of "leave the page".
|
||||
const URL_SYNC = typeof window !== 'undefined' && typeof history !== 'undefined'
|
||||
|
||||
function slugFromHash() {
|
||||
if (!URL_SYNC) return ''
|
||||
return decodeURIComponent(String(window.location.hash || '').replace(/^#/, '')).trim()
|
||||
}
|
||||
|
||||
// A slug may name a region OR a country — DX-life's [slug].vue matches region slugs first, then
|
||||
// country slugs, and 404s on neither. Same order here; "neither" lands on the chooser rather
|
||||
// than an error, because the chooser is a legitimate state for this element.
|
||||
function selectionForSlug(slug) {
|
||||
if (!slug) return { regionSlug: '', countrySlug: '' }
|
||||
const r = regions.value.find((x) => x.slug === slug)
|
||||
if (r) return { regionSlug: r.slug, countrySlug: '' }
|
||||
for (const reg of regions.value) {
|
||||
const c = reg.countries.find((x) => x.slug === slug)
|
||||
if (c) return { regionSlug: reg.slug, countrySlug: c.slug }
|
||||
}
|
||||
return { regionSlug: '', countrySlug: '' }
|
||||
}
|
||||
|
||||
function currentSlug() {
|
||||
return sel.value.countrySlug || sel.value.regionSlug || ''
|
||||
}
|
||||
|
||||
// Write the subject into the URL. push:true for a user choice (Back should undo it), false for
|
||||
// the initial read (Back should leave the page, not replay the boot).
|
||||
function writeUrl(push) {
|
||||
if (!URL_SYNC) return
|
||||
const slug = currentSlug()
|
||||
const url = window.location.pathname + window.location.search + (slug ? '#' + encodeURIComponent(slug) : '')
|
||||
try { push ? history.pushState({ slug }, '', url) : history.replaceState({ slug }, '', url) } catch (_) {}
|
||||
syncTitle()
|
||||
}
|
||||
|
||||
// Their page sets the document title per subject (useSeoMeta in [slug].vue: `${title} — Doxa`).
|
||||
// A standalone app should too: it is what a bookmark, a tab strip and a shared link all show.
|
||||
function syncTitle() {
|
||||
if (typeof document === 'undefined') return
|
||||
const t = subject.value?.title
|
||||
try { document.title = t && t !== 'All Regions' ? t + ' — Regions' : 'Regions' } catch (_) {}
|
||||
}
|
||||
|
||||
// popstate WRITES the selection, so the watcher below must not turn around and push a new
|
||||
// entry for it — that is the classic history loop where Back appears to do nothing.
|
||||
let applyingHistory = false
|
||||
function onPopState() {
|
||||
applyingHistory = true
|
||||
sel.value = selectionForSlug(slugFromHash())
|
||||
syncTitle()
|
||||
applyingHistory = false
|
||||
}
|
||||
|
||||
// One watcher rather than a writeUrl() call at each of the five places a selection is made
|
||||
// (tab-1 event, table row, region dropdown, country dropdown, back-link) — those would drift.
|
||||
watch(() => [sel.value.regionSlug, sel.value.countrySlug], () => {
|
||||
if (applyingHistory) return
|
||||
writeUrl(true)
|
||||
}, { flush: 'post' })
|
||||
|
||||
// ── in-dashboard navigation ─────────────────────────────────────────────────
|
||||
// Their back-link (RegionDetail.vue:48) goes to /regions. We have no router, so it drops the
|
||||
// selection, which lands on the ALL REGIONS subject -- the closest thing this element has to
|
||||
|
|
@ -445,13 +532,20 @@ async function loadRegions() {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
window.addEventListener(SELECT_EVENT, onSelect)
|
||||
loadRegions()
|
||||
if (URL_SYNC) window.addEventListener('popstate', onPopState)
|
||||
// The hash can only be resolved to a subject once the regions are known, so it is applied
|
||||
// after the fetch rather than on mount — otherwise a deep link silently lands on the chooser.
|
||||
await loadRegions()
|
||||
const slug = slugFromHash()
|
||||
if (slug) sel.value = selectionForSlug(slug)
|
||||
writeUrl(false)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(SELECT_EVENT, onSelect)
|
||||
if (URL_SYNC) window.removeEventListener('popstate', onPopState)
|
||||
})
|
||||
|
||||
// Palette is DX-life material light, matching regions-list.vue tab-for-tab so swapping between
|
||||
|
|
@ -504,8 +598,8 @@ useShadowStyles(
|
|||
// h1: _globals.scss clamp(--font-size-3xl, 7vw, --font-size-5xl) = clamp(47.6837px,_,74.5058px),
|
||||
// heading family, line-height 1. cqw not vw -- see tab 1 for why.
|
||||
'.rd-title{ margin:0; font-size:clamp(47.6837px, 7cqw, 74.5058px); line-height:1;' +
|
||||
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; font-stretch:condensed; text-transform:uppercase; }' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; }' +
|
||||
// their .color-brand-lighter summary under the h1
|
||||
'.rd-summary{ margin:0; color:var(--dx-muted); font-size:19.5312px; }' +
|
||||
'.rd-prompt{ margin:0; color:var(--dx-muted); font-size:15.625px; }' +
|
||||
|
|
@ -527,8 +621,8 @@ useShadowStyles(
|
|||
'.rd-stat__value[data-len="long"]{ font-size:38.147px; }' +
|
||||
'.rd-stat__value[data-len="xlong"]{ font-size:30.5176px; }' +
|
||||
'.rd-stat__value{ margin:0; font-size:47.6837px; line-height:1;' +
|
||||
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; font-stretch:condensed; }' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; }' +
|
||||
'.rd-stat__label{ margin:7.5188px 0 0; font-size:15.625px; color:var(--dx-muted); }' +
|
||||
|
||||
'.rd-chart{ padding:23.5264px clamp(17.689px, 4cqw, 31.2901px); }' +
|
||||
|
|
@ -564,14 +658,13 @@ useShadowStyles(
|
|||
// substituted face can render in their card. Pinning it there keeps THEIR LAYOUT (three
|
||||
// cards across, heading on two lines) which is what the clone is actually for. The table
|
||||
// heading keeps the full clamp -- it has 1200px and no such constraint.
|
||||
'.rd-goal-card .rd-h2{ font-size:38.147px; }' +
|
||||
// .stack--lg (23.5264px) inside each card, centred; .region-goal pins to the bottom
|
||||
// .stack--lg (23.5264px) inside each card, centred; .region-goal pins to the bottom
|
||||
// (margin-top:auto) so three cards of different heights still align their goal lines.
|
||||
'.rd-goal-card{ display:flex; flex-direction:column; gap:23.5264px; text-align:center; }' +
|
||||
// h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl) = clamp(38.147px,_,59.6046px)
|
||||
'.rd-h2{ margin:0; font-size:clamp(38.147px, 6cqw, 59.6046px); line-height:1;' +
|
||||
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; font-stretch:condensed; }' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; }' +
|
||||
'.rd-h2--start{ text-align:start; }' +
|
||||
// .font-size-4xl .font-weight-medium / .font-size-lg
|
||||
// Their .font-size-4xl (59.6046px) verified live. Same substitution consequence as the h2:
|
||||
|
|
@ -580,7 +673,7 @@ useShadowStyles(
|
|||
// THEIR scale) and pinned nowrap so a figure can never be split across lines. The stat-card
|
||||
// values keep the full 47.6837px -- those cards are wider and were measured to fit.
|
||||
'.rd-big{ margin:0; font-size:59.6046px; font-weight:500; line-height:1.1; }' +
|
||||
'.rd-goal-card .rd-big{ font-size:47.6837px; white-space:nowrap; }' +
|
||||
'.rd-goal-card .rd-big{ white-space:nowrap; }' +
|
||||
'.rd-big-label{ margin:0; font-size:24.4141px; }' +
|
||||
'.rd-stack-xs{ display:flex; flex-direction:column; gap:10px; }' +
|
||||
'.rd-goal{ margin:auto 0 0; font-size:15.625px; }' +
|
||||
|
|
@ -625,7 +718,9 @@ useShadowStyles(
|
|||
|
||||
<template>
|
||||
<div class="rd-root">
|
||||
<div class="rd-scroll">
|
||||
<!-- a landmark, so a screen reader can jump to the dashboard body; the app probe
|
||||
measured none on the page and it costs nothing to name the region. -->
|
||||
<main class="rd-scroll" aria-label="Region dashboard">
|
||||
<div v-if="loading" class="rd-status">Loading region dashboards…</div>
|
||||
<div v-else-if="loadFailed" class="rd-status rd-failed" role="status">
|
||||
Region data could not be loaded. This is a connection problem, not a filter.
|
||||
|
|
@ -791,6 +886,6 @@ useShadowStyles(
|
|||
:language-code="languageCode"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
*/
|
||||
import { inject, ref, computed, onMounted, getCurrentInstance } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES } from '@map/composables/useDocumentFont.js'
|
||||
|
||||
const props = defineProps({
|
||||
profileConfig: { type: String, default: '' },
|
||||
|
|
@ -61,6 +62,21 @@ const cfg = computed(() => {
|
|||
})
|
||||
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
|
||||
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
|
||||
// Where the shared asset tree is served from: the generator bakes it per page depth and the
|
||||
// composite forwards it via INHERITED_PAGE_KEYS. Needed for the vendored @font-face path below.
|
||||
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
|
||||
|
||||
// ★ REGISTER BEBAS NEUE AT DOCUMENT LEVEL, not in the shadow root: @font-face is document-scoped
|
||||
// and a face declared inside a shadow root is ignored. This profile registers it too, and not
|
||||
// only tab 2, because its own leaf page (regions-list/index.html) is a standalone surface where
|
||||
// tab 2 is not present — relying on a sibling would leave THIS page on the fallback face.
|
||||
// Idempotent per document, so the composite still registers exactly one.
|
||||
useDocumentFont('Bebas Neue', BEBAS_NEUE_FACES, assetsBase.value)
|
||||
// Poppins is their --font-family-base — the face of every line of BODY text on these
|
||||
// pages. It was the larger of the two typography gaps and completely invisible to a
|
||||
// computed-style diff, which read 68/68 while our body text was a system fallback 5.5%
|
||||
// wider per glyph. Registered the same way and for the same reason as Bebas.
|
||||
useDocumentFont('Poppins', POPPINS_FACES, assetsBase.value)
|
||||
|
||||
// ── state ───────────────────────────────────────────────────────────────────
|
||||
const regions = ref([])
|
||||
|
|
@ -288,8 +304,8 @@ useShadowStyles(
|
|||
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
|
||||
// exactly rather than compounding extra "boldness" the source doesn't have.
|
||||
'.region-card__title{ margin:0; font-size:clamp(30.5176px, 4cqw, 47.6837px); line-height:1;' +
|
||||
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; font-stretch:condensed; letter-spacing:normal; }' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; letter-spacing:normal; }' +
|
||||
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:left;' +
|
||||
' cursor:pointer; color:inherit; font:inherit; display:inline-flex; align-items:baseline; gap:10px; }' +
|
||||
'.region-card__link:hover{ color:#92b195; }' +
|
||||
|
|
|
|||
24
public/assets/fonts/bebas-neue/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
Bebas Neue — SIL Open Font License, Version 1.1
|
||||
Designer: Ryoichi Tsunekawa (Dharma Type). Distributed via Google Fonts.
|
||||
Full text: https://openfontlicense.org/ (OFL-1.1)
|
||||
|
||||
WHY THIS FILE IS IN THIS REPO
|
||||
DX-life's /regions pages set --font-family-heading to 'Bebas Neue'. Their own site does not
|
||||
ship the file either — @nuxt/fonts downloads it at build and self-hosts it, which is why their
|
||||
computed font-family reads "Bebas Neue", "Bebas Neue Fallback: ...". We cannot @font-face from
|
||||
their origin: their font file is served without CORS headers, so a cross-origin face silently
|
||||
fails and every heading fell back to a condensed system stack. That substitution was the ONLY
|
||||
remaining difference between our regions dashboard and theirs (7 computed properties, all of
|
||||
them this one font and its knock-on sizes).
|
||||
So the font is vendored here, from Google Fonts, under the OFL that permits redistribution —
|
||||
self-hosted, never a CDN, the same rule public/assets/vendor/tabulator/ follows.
|
||||
Subsets and unicode-ranges are Google's own (css2 API, v16), reproduced verbatim in the
|
||||
@font-face declared by library/composables/useDocumentFont.js.
|
||||
|
||||
NOTE ON THIS FILENAME — it is deliberately extensionless.
|
||||
check:public-hygiene refuses *.md / *.markdown / *.txt anywhere in the served tree ("no docs in a
|
||||
served folder", Driver rule 2026-09-16), and it is right to: prose does not belong in a public
|
||||
mirror. But the OFL requires this licence to accompany the font files wherever they are
|
||||
redistributed, and the font files DO ship — they are fetched by the @font-face at runtime. So the
|
||||
licence travels with them under the conventional extensionless name, which satisfies the rule
|
||||
without weakening it. Do not rename this to LICENSE.txt.
|
||||
BIN
public/assets/fonts/bebas-neue/bebas-neue-latin-ext.woff2
vendored
Normal file
BIN
public/assets/fonts/bebas-neue/bebas-neue-latin.woff2
vendored
Normal file
29
public/assets/fonts/poppins/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
Poppins — SIL Open Font License, Version 1.1
|
||||
Designers: Indian Type Foundry, Jonny Pinhorn. Distributed via Google Fonts.
|
||||
Full text: https://openfontlicense.org/ (OFL-1.1)
|
||||
|
||||
WHY THIS FILE IS IN THIS REPO
|
||||
Poppins is DX-life's --font-family-base: the body face of every line of text on the pages the
|
||||
regions plugin clones. Their own site self-hosts it via @nuxt/fonts; we could not use theirs, so
|
||||
it is vendored here from Google Fonts under the OFL, which permits redistribution.
|
||||
|
||||
WHY IT WAS NEEDED — and why no CSS check caught it.
|
||||
A computed-style diff of our region dashboard against live doxa.life reported 68 of 68 properties
|
||||
IDENTICAL while our body text was rendering in a system fallback, because BOTH sides merely NAME
|
||||
"Poppins" first in font-family and a first-family comparison therefore matched. The difference only
|
||||
became visible against a glyph-width oracle: the same string at the same size measured 334.58px
|
||||
here against 317px on their page — 5.5% wider, i.e. a different face.
|
||||
document.fonts.check() is NOT a usable oracle for this: it returned true for families never
|
||||
vendored (Oswald, Brandon Grotesque) and false for Bebas Neue on DX-life's own page.
|
||||
See _qa-cycle/02-builder-cycle/_probe-glyph-width.mjs.
|
||||
|
||||
WHAT IS HERE, AND WHY ONLY THIS
|
||||
Weights 400/500/600/700 only — the four the regions surface actually asks for (measured across
|
||||
their _globals, _cards, _buttons and _filters SCSS: medium x10, regular x4, bold x2, plus the
|
||||
600 in the prayer-coverage legend). Subsets latin + latin-ext only. 44 KB in total, with Google's
|
||||
own unicode-ranges reproduced in the @font-face so the subset split behaves as it does on their
|
||||
site. Extra weights and scripts are deliberately NOT vendored — bloat is a repo rule here.
|
||||
|
||||
FILENAME: extensionless on purpose. check:public-hygiene refuses *.txt in a served folder and is
|
||||
right to, while the OFL requires this licence to accompany redistributed font files. Do not
|
||||
rename this to LICENSE.txt.
|
||||
BIN
public/assets/fonts/poppins/poppins-400-latin-ext.woff2
vendored
Normal file
BIN
public/assets/fonts/poppins/poppins-400-latin.woff2
vendored
Normal file
BIN
public/assets/fonts/poppins/poppins-500-latin-ext.woff2
vendored
Normal file
BIN
public/assets/fonts/poppins/poppins-500-latin.woff2
vendored
Normal file
BIN
public/assets/fonts/poppins/poppins-600-latin-ext.woff2
vendored
Normal file
BIN
public/assets/fonts/poppins/poppins-600-latin.woff2
vendored
Normal file
BIN
public/assets/fonts/poppins/poppins-700-latin-ext.woff2
vendored
Normal file
BIN
public/assets/fonts/poppins/poppins-700-latin.woff2
vendored
Normal file
24
public/doxa-maps/assets/fonts/bebas-neue/LICENSE
generated
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
Bebas Neue — SIL Open Font License, Version 1.1
|
||||
Designer: Ryoichi Tsunekawa (Dharma Type). Distributed via Google Fonts.
|
||||
Full text: https://openfontlicense.org/ (OFL-1.1)
|
||||
|
||||
WHY THIS FILE IS IN THIS REPO
|
||||
DX-life's /regions pages set --font-family-heading to 'Bebas Neue'. Their own site does not
|
||||
ship the file either — @nuxt/fonts downloads it at build and self-hosts it, which is why their
|
||||
computed font-family reads "Bebas Neue", "Bebas Neue Fallback: ...". We cannot @font-face from
|
||||
their origin: their font file is served without CORS headers, so a cross-origin face silently
|
||||
fails and every heading fell back to a condensed system stack. That substitution was the ONLY
|
||||
remaining difference between our regions dashboard and theirs (7 computed properties, all of
|
||||
them this one font and its knock-on sizes).
|
||||
So the font is vendored here, from Google Fonts, under the OFL that permits redistribution —
|
||||
self-hosted, never a CDN, the same rule public/assets/vendor/tabulator/ follows.
|
||||
Subsets and unicode-ranges are Google's own (css2 API, v16), reproduced verbatim in the
|
||||
@font-face declared by library/composables/useDocumentFont.js.
|
||||
|
||||
NOTE ON THIS FILENAME — it is deliberately extensionless.
|
||||
check:public-hygiene refuses *.md / *.markdown / *.txt anywhere in the served tree ("no docs in a
|
||||
served folder", Driver rule 2026-09-16), and it is right to: prose does not belong in a public
|
||||
mirror. But the OFL requires this licence to accompany the font files wherever they are
|
||||
redistributed, and the font files DO ship — they are fetched by the @font-face at runtime. So the
|
||||
licence travels with them under the conventional extensionless name, which satisfies the rule
|
||||
without weakening it. Do not rename this to LICENSE.txt.
|
||||
BIN
public/doxa-maps/assets/fonts/bebas-neue/bebas-neue-latin-ext.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/bebas-neue/bebas-neue-latin.woff2
generated
Normal file
29
public/doxa-maps/assets/fonts/poppins/LICENSE
generated
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
Poppins — SIL Open Font License, Version 1.1
|
||||
Designers: Indian Type Foundry, Jonny Pinhorn. Distributed via Google Fonts.
|
||||
Full text: https://openfontlicense.org/ (OFL-1.1)
|
||||
|
||||
WHY THIS FILE IS IN THIS REPO
|
||||
Poppins is DX-life's --font-family-base: the body face of every line of text on the pages the
|
||||
regions plugin clones. Their own site self-hosts it via @nuxt/fonts; we could not use theirs, so
|
||||
it is vendored here from Google Fonts under the OFL, which permits redistribution.
|
||||
|
||||
WHY IT WAS NEEDED — and why no CSS check caught it.
|
||||
A computed-style diff of our region dashboard against live doxa.life reported 68 of 68 properties
|
||||
IDENTICAL while our body text was rendering in a system fallback, because BOTH sides merely NAME
|
||||
"Poppins" first in font-family and a first-family comparison therefore matched. The difference only
|
||||
became visible against a glyph-width oracle: the same string at the same size measured 334.58px
|
||||
here against 317px on their page — 5.5% wider, i.e. a different face.
|
||||
document.fonts.check() is NOT a usable oracle for this: it returned true for families never
|
||||
vendored (Oswald, Brandon Grotesque) and false for Bebas Neue on DX-life's own page.
|
||||
See _qa-cycle/02-builder-cycle/_probe-glyph-width.mjs.
|
||||
|
||||
WHAT IS HERE, AND WHY ONLY THIS
|
||||
Weights 400/500/600/700 only — the four the regions surface actually asks for (measured across
|
||||
their _globals, _cards, _buttons and _filters SCSS: medium x10, regular x4, bold x2, plus the
|
||||
600 in the prayer-coverage legend). Subsets latin + latin-ext only. 44 KB in total, with Google's
|
||||
own unicode-ranges reproduced in the @font-face so the subset split behaves as it does on their
|
||||
site. Extra weights and scripts are deliberately NOT vendored — bloat is a repo rule here.
|
||||
|
||||
FILENAME: extensionless on purpose. check:public-hygiene refuses *.txt in a served folder and is
|
||||
right to, while the OFL requires this licence to accompany redistributed font files. Do not
|
||||
rename this to LICENSE.txt.
|
||||
BIN
public/doxa-maps/assets/fonts/poppins/poppins-400-latin-ext.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/poppins/poppins-400-latin.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/poppins/poppins-500-latin-ext.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/poppins/poppins-500-latin.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/poppins/poppins-600-latin-ext.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/poppins/poppins-600-latin.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/poppins/poppins-700-latin-ext.woff2
generated
Normal file
BIN
public/doxa-maps/assets/fonts/poppins/poppins-700-latin.woff2
generated
Normal file
2
public/doxa-maps/index.html
generated
8
public/doxa-maps/manifest.json
generated
|
|
@ -838,7 +838,7 @@
|
|||
"bundle": "regions-dashboard",
|
||||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
|
||||
"integrity": "sha384-i4nwGKn5E2cEC5nbLTrIjro7ojM0JfCz51+/dtgQmM7k7G13gyHV/K1WOFYUFbYr",
|
||||
"integrity": "sha384-DCmfPJdlsSwflCxB7aJlhn12a8oSKWbRDEBj71DiqSxRFWGkMG11LgV73ksxur/i",
|
||||
"elements": [
|
||||
"regions-dashboard"
|
||||
],
|
||||
|
|
@ -858,7 +858,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-i4nwGKn5E2cEC5nbLTrIjro7ojM0JfCz51+/dtgQmM7k7G13gyHV/K1WOFYUFbYr\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-DCmfPJdlsSwflCxB7aJlhn12a8oSKWbRDEBj71DiqSxRFWGkMG11LgV73ksxur/i\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -872,7 +872,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-i4nwGKn5E2cEC5nbLTrIjro7ojM0JfCz51+/dtgQmM7k7G13gyHV/K1WOFYUFbYr\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-DCmfPJdlsSwflCxB7aJlhn12a8oSKWbRDEBj71DiqSxRFWGkMG11LgV73ksxur/i\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -886,7 +886,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-i4nwGKn5E2cEC5nbLTrIjro7ojM0JfCz51+/dtgQmM7k7G13gyHV/K1WOFYUFbYr\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-DCmfPJdlsSwflCxB7aJlhn12a8oSKWbRDEBj71DiqSxRFWGkMG11LgV73ksxur/i\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-i4nwGKn5E2cEC5nbLTrIjro7ojM0JfCz51+/dtgQmM7k7G13gyHV/K1WOFYUFbYr" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-DCmfPJdlsSwflCxB7aJlhn12a8oSKWbRDEBj71DiqSxRFWGkMG11LgV73ksxur/i" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-i4nwGKn5E2cEC5nbLTrIjro7ojM0JfCz51+/dtgQmM7k7G13gyHV/K1WOFYUFbYr" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-DCmfPJdlsSwflCxB7aJlhn12a8oSKWbRDEBj71DiqSxRFWGkMG11LgV73ksxur/i" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-i4nwGKn5E2cEC5nbLTrIjro7ojM0JfCz51+/dtgQmM7k7G13gyHV/K1WOFYUFbYr" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-DCmfPJdlsSwflCxB7aJlhn12a8oSKWbRDEBj71DiqSxRFWGkMG11LgV73ksxur/i" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||