Merge remote-tracking branch 'origin/development' into regions/country-sheet-onedef

# Conflicts:
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/regions-dashboard/region-dashboards/index.html
#	public/dx-maps/regions-dashboard/regions-dashboard.js
#	public/dx-maps/regions-dashboard/regions-list/index.html
#	public/dx-maps/regions-dashboard/regions-mfe/index.html
This commit is contained in:
Doxa Maps 2026-09-29 22:59:27 +00:00
commit baa60ba647
232 changed files with 1376 additions and 1043 deletions

View file

@ -62,7 +62,7 @@ capabilities, three outputs, one shared library, one build.
| **One app nests every map** — and each stays embeddable on its own | [capability 5](#capability-5--app-shell-the-research-gallery) · `bun run check:composite-reg` |
| **5 capabilities, every one shippable in every output** | `bun run verify:5-caps` |
| **Shared library, private plugins** — reuse without breaking each other | `bun run check:library-index` |
| **One agent per plugin, one shepherd, one working `main`** | [agent swarm](#build-it-with-an-agent-swarm) |
| **One agent per plugin, one shepherd, one working `development`** | [agent swarm](#build-it-with-an-agent-swarm) |
## For vibe coders: build, open the Studio, embed a map
@ -76,17 +76,21 @@ bun run dev # open the address it prints: that page is the P
**2. Build.** The built maps are already in the repo. After a change, rebuild just that map, or all of them:
```bash
BUNDLE=<map> bun x vite build # one map (seconds)
BUILD_ALLOW=1 bun run build # every map (~4 min); the output is public/dx-maps/
BUNDLE=<map> bun x vite build && bun run tree && bun run fix:sri # one map, then its pages and hashes
BUILD_ALLOW=1 bun run build # every map (~4 min); the output is public/dx-maps/
```
When the DX Life site's repo sits next to this one (`../doxa-life`), the full build also copies the maps into
After a one-map build, `bun run tree` rewrites the map's pages and the manifest (a changed `.meta.json` reaches
them only this way), and `fix:sri` refreshes their integrity hashes. Skip them and the map's own page and any pasted
snippet keep the old settings and refuse the new file. When the DX Life site's repo sits next to this one (`../doxa-life`), the full build also copies the maps into
its `public/`. That is the one path set in `vite.doxa-maps-build-config.json` (`dropin.dest`); nothing else on
your computer is searched. No site there: the step says so and skips. `DOXA_DROPIN=off` turns it off, and
`bun run dropin` copies on demand.
**3. Embed a map.** Every map has its own embed script: its `embed.script` in `public/dx-maps/manifest.json` (an
`embed.iframe` too). A snippet is two parts, the map's script and its tag:
`embed.iframe` too). Paste it whole. It preloads the map engine, points the map at its data
(`window.MAP_APP_API_URL`), loads the map's script with its integrity hash, and places the map's tag. The two
lines at its core:
```html
<script src="/dx-maps/doxa-simple-map/doxa-simple-map.js" integrity="sha384-…" crossorigin="anonymous"></script>
@ -104,10 +108,10 @@ people groups of the world. When you work here, the goal is one of these:
| Goal | The production maps that serve it |
|---|---|
| **Reach a city** | Cities with Internet (`cities-internet`); Regions (`regions-dashboard`) and the country map (`country-map`) |
| **Mobilize for people groups** | Big Picture (`dashboard-plus-grid`); Research Map + Grid (`research-map-plus-grid`) |
| **Plan outreach** | Gospel Resources (`gospel-resources`): what exists in each language; Regions, by country |
| **Pray: packets and prayer resources** | UPG-100 (`my-upg-100-list`): prayer packets; Prayer Progress (`doxa-simple-map`) |
| **Reach a city** | Regions (`regions-dashboard`) and the country map (`country-map`): the people groups of each country. The production maps work by country and region; city-level planning (Prayer Walk Planner) is still experimental |
| **Mobilize for people groups** | Big Picture (`dashboard-plus-grid`); Simple Research (`research-map-plus-grid`) |
| **Plan outreach** | Gospel Resources (`gospel-resources`): what exists in each language; Cities with Internet (`cities-internet`): whether a group can be reached online |
| **Pray: packets and prayer resources** | UPG 100 List (`my-upg-100-list`), Regions and Simple Research: posters and prayer packets; Prayer Progress (`doxa-simple-map`) |
- **Start with the skill:** [`skills/plugin-designer`](./skills/plugin-designer) teaches the patterns, the shared
library and the checks.
@ -115,7 +119,9 @@ people groups of the world. When you work here, the goal is one of these:
every other plugin.
- **Land on `development`**, one change at a time through the lock, with `bun run gates:pre` passing. `main` is
production and moves only on a publish.
- These 8 maps are in production. The experimental maps are frozen: leave them as they are.
- These 8 maps are in production: the 7 in the gallery, plus the country map for DX Life's country pages. The
other maps are experimental: leave them as they are unless your task is one
of them.
*Click any ▸ to read more. Everything below is folded so you can scroll the pictures first.*
@ -124,17 +130,18 @@ people groups of the world. When you work here, the goal is one of these:
## The 7 production maps
**Seven maps DX uses today** — each one a plugin you can embed on its own, and all seven together in
the Research Gallery.
the Research Gallery, which DX Life publishes as **Deep Research**. An eighth production map, the country map
(`country-map`), draws DX Life's country pages; it isn't in the gallery.
<table>
<tr>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-big-picture.png" alt="Big Picture"><br><b>Big Picture</b><br><sub>the whole research dashboard: filters, map, charts, people groups</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-simple-research.png" alt="Simple Research"><br><b>Simple Research</b><br><sub>the research map with the people-groups grid, made for phones</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-regions.png" alt="Regions"><br><b>Regions</b><br><sub>every region and its countries, then a dashboard per region</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG-100 List"><br><b>UPG-100 List</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/cap2-prayer.png" alt="Prayer Progress"><br><b>Prayer Progress</b><br><sub>where prayer is happening, and where it is still needed</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><img src="documentation/readme-images/cap2-prayer.png" alt="Prayer Progress"><br><b>Prayer Progress</b><br><sub>where prayer is happening, and where it is still needed</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG-100 List"><br><b>UPG-100 List</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet.png" alt="Cities with Internet"><br><b>Cities with Internet</b><br><sub>internet access where unreached people groups live</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-gospel-resources.png" alt="Gospel Resources"><br><b>Gospel Resources</b><br><sub>which people groups have scripture, film or audio — and which have nothing</sub></td>
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all seven in one app — capability 5</sub></td>
@ -147,8 +154,8 @@ the Research Gallery.
<tr>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-simple-research-phone.png" alt="Simple Research on a phone"><br><b>Simple Research</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-regions-phone.png" alt="Regions on a phone"><br><b>Regions</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG-100 List on a phone"><br><b>UPG-100 List</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-prayer-phone.png" alt="Prayer Progress on a phone"><br><b>Prayer Progress</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG-100 List on a phone"><br><b>UPG-100 List</b></td>
</tr>
<tr>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet-phone.png" alt="Cities with Internet on a phone"><br><b>Cities with Internet</b></td>
@ -443,8 +450,9 @@ snippets in `manifest.json` stay relative (`./…`) and other sites can't paste
the manifests in this repo's `public/`; `git restore public` puts them back after the copy.
This route is proven on DX Life: real pages rendering maps, one server, every production map passing
its embed checks, copy-paste snippets included. Its drop-in branch copies the folder into `public/` and
fills `/research` with the Research Gallery.
its embed checks, copy-paste snippets included. Its drop-in branch copies the folder into `public/dx-maps`. Its
`/research` page shows the research map by itself, with a link to `/deep-research`, a page not in the menu that
holds the whole gallery as **Deep Research**.
**Embeds are pinned.** A host pastes a map's `embed.script` from `manifest.json`; its `integrity` hash
makes the browser refuse the bundle if it changed by even one byte, so copy the snippet again after each
@ -504,19 +512,22 @@ Every plugin's exact snippet is `embed.iframe` in `public/dx-maps/manifest.json`
</details>
##### JavaScript embed — the Research Gallery on dx-life ✅ production
##### JavaScript embed — Deep Research for DX Life ✅ works today
**Two lines of code put a whole app inside your own page** — here, the Research Gallery on the
dx-life research page.
**Two lines of code put a whole app inside your own page** — here, the Research Gallery, which DX Life
publishes as **Deep Research** on a page of its own (`/deep-research`).
![JavaScript embed — the Research Gallery on the dx-life research page](documentation/readme-images/deploy3-js-embed-dxlife.png)
![JavaScript embed — the Research Gallery inside a dx-life page, in an earlier build](documentation/readme-images/deploy3-js-embed-dxlife.png)
*An earlier build, when the gallery sat on DX Life's research page with its tabs in the old order. It now has a
page of its own.*
<details>
<summary><b>More about the JavaScript embed</b></summary>
Instead of a window onto another page, the map becomes **part of your page**, so your page can talk
to it. On dx-life, one embed of the Research Gallery replaces the separate research map, list and
regions dashboards. The exact snippet is `embed.script` in `manifest.json`; copy its `integrity`
to it. On dx-life, one embed of the gallery is the whole `/deep-research` page, and the research page keeps
the research map by itself. The exact snippet is `embed.script` in `manifest.json`; copy its `integrity`
hash from there, because it changes with every build:
```html
@ -677,8 +688,8 @@ your own page, set `"locale"` in its `profile-config`. Arabic reads right to lef
**Many AI agents can build many plugins at once without breaking each other's work.** Every plugin
has one agent building it, and one shepherd agent makes sure every agent does its job well. All the
plugins live in one project with one shared library, and changes are added to `main` — the one
official version — one at a time.
plugins live in one project with one shared library, and changes are added to `development`, the shared
working version, one at a time. `main` is the production version, and it moves only when a release is published.
![The delegation model: main at the centre guarded by a merge queue, inside the Plugin Studio; eight plugins around it; one agent per plugin; and one shepherd over all the agents](documentation/readme-images/delegation-model.png)
@ -697,7 +708,7 @@ So the project works like a busy kitchen with one head chef:
2. **Before adding its work, each agent catches up**: it pulls in everyone else's latest work and
fixes any clashes with its own. The agent fixes the clash, not the next person.
3. **Only one agent adds work at a time.** They wait in a line (the *merge queue*), the checks run,
and only then does the change go into `main`. `main` always works.
and only then does the change go into `development`. `development` always works.
4. **A shepherd agent watches everyone**: plugins built in the studio, shared parts really shared,
and every change going through the line.
@ -711,7 +722,8 @@ That's how many plugins get built at the same time, in one project, without the
| Word | In plain terms |
|---|---|
| **repo** | the project folder, with its full history |
| **main** | the one official version of the project that everyone builds from |
| **development** | the shared working version: every agent's change is added here, one at a time |
| **main** | the production version: it moves only when `development` is published |
| **worktree** | a private copy of the project for one agent to work in |
| **merge conflict** | two changes touched the same lines; someone has to decide how to combine them |
| **rebase** | catching your work up with everyone else's latest before adding it |

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View file

@ -62,7 +62,10 @@ const VALUE_FLAGS = new Set(['--from', '--tree', '--protect', '--only'])
const argOf = (k, d) => { const i = args.indexOf(k); const v = args[i + 1]; return i >= 0 && v !== undefined && !v.startsWith('--') ? v : d }
// Destination: positional <dest> · else DOXA_DROPIN_DEST · else "dropin.dest" in the build-config
// (the ONE place the host folder is declared; moving the drop-in = changing that line).
const configDest = (() => { try { const d = JSON.parse(readFileSync(CONFIG, 'utf8')).dropin?.dest; return typeof d === 'string' && d.trim() ? resolve(ROOT, d.trim()) : null } catch { return null } })()
// A config that EXISTS but does not parse is recorded, never swallowed: with --if-present a silent null would read
// as "no destination configured" and a green build would never refresh the site.
let configBroken = ''
const configDest = (() => { try { const d = JSON.parse(readFileSync(CONFIG, 'utf8')).dropin?.dest; return typeof d === 'string' && d.trim() ? resolve(ROOT, d.trim()) : null } catch (e) { if (existsSync(CONFIG)) configBroken = e.message; return null } })()
const DEST = args.find((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(args[i - 1])) || process.env.DOXA_DROPIN_DEST || configDest
const FROM = resolve(ROOT, argOf('--from', 'public'))
const DRY = args.includes('--dry-run')
@ -86,6 +89,11 @@ const excludedInternal = (f) => EXCLUDE.has(f) || isDoc(f) || isStaging(f)
const excluded = (f) => excludedInternal(f) || isNotShipped(f)
const usage = 'usage: node internal/sync-dropin.mjs [<dest>] [--from public] [--only a,b | --from-config] [--prune] [--protect a,b] [--dry-run] (<dest> defaults to $DOXA_DROPIN_DEST, then dropin.dest in vite.doxa-maps-build-config.json)'
if (configBroken && (args.includes('--if-present') || args.includes('--from-config'))) {
console.error(`[sync-dropin] build config unreadable: ${norm(relative(ROOT, CONFIG))}: ${configBroken}`)
process.exit(1)
}
if (!DEST && args.includes('--if-present')) { console.log('[sync-dropin] skipped: no drop-in destination configured (dropin.dest)'); process.exit(0) }
if (!DEST) { console.error(`[sync-dropin] ${usage}`); process.exit(1) }
const dest = resolve(DEST)
// ★ --prune INTO A SITE'S WEB-ROOT WOULD DELETE THE SITE. --prune removes every file in <dest> outside the

View file

@ -180,15 +180,23 @@ useShadowStyles(`
.mi-btn:hover{ background:#f3f5fa; }
.mi-btn:focus-visible{ outline:3px solid #2563eb; outline-offset:2px; }
.mi-btn svg{ width:22px; height:22px; }
.mi-at-right{ right:var(--map-info-side, 12px); }
.mi-at-left{ left:var(--map-info-side, 12px); }
/* LOGICAL SIDES, so an RTL page mirrors: "right" is the END of the line, which in Arabic is the LEFT.
With a physical right:12px the button and pointer covered the first (active) tab of an Arabic tab
bar, which starts at the right. */
.mi-at-right{ inset-inline-end:var(--map-info-side, 12px); }
.mi-at-left{ inset-inline-start:var(--map-info-side, 12px); }
.mi-pointer{ position:absolute; z-index:20; top:calc(var(--map-info-top, 12px) + 4px);
display:flex; align-items:center; gap:6px; pointer-events:none; white-space:nowrap;
background:#1f2a44; color:#fff; font-size:13px; font-weight:600; line-height:1; padding:8px 12px;
border-radius:999px; box-shadow:0 2px 10px rgba(0,0,0,.25); animation:mi-nudge 1.4s ease-in-out infinite; }
.mi-pointer.mi-at-right{ right:calc(var(--map-info-side, 12px) + 50px); }
.mi-pointer.mi-at-left{ left:calc(var(--map-info-side, 12px) + 50px); flex-direction:row-reverse;
.mi-pointer.mi-at-right{ inset-inline-end:calc(var(--map-info-side, 12px) + 50px); }
.mi-pointer.mi-at-left{ inset-inline-start:calc(var(--map-info-side, 12px) + 50px); flex-direction:row-reverse;
animation-name:mi-nudge-left; }
/* The flex order mirrors by itself under dir=rtl; the arrow glyph and the nudge do not, so flip both
so the pointer still points and moves TOWARD the button. */
.mi[dir="rtl"] .mi-pointer svg{ transform:scaleX(-1); }
.mi[dir="rtl"] .mi-pointer.mi-at-right{ animation-name:mi-nudge-left; }
.mi[dir="rtl"] .mi-pointer.mi-at-left{ animation-name:mi-nudge; }
.mi-pointer svg{ width:16px; height:16px; flex:none; }
@keyframes mi-nudge{ 0%,100%{ transform:translateX(0) } 50%{ transform:translateX(6px) } }
@keyframes mi-nudge-left{ 0%,100%{ transform:translateX(0) } 50%{ transform:translateX(-6px) } }
@ -218,7 +226,9 @@ useShadowStyles(`
.mi-hit.mi-open{ cursor:zoom-out; }
.mi-page{ max-width:420px; margin:0 auto; }
.mi-pages .mi-page + .mi-page{ margin-top:12px; }
.mi-stack{ position:relative; }
/* isolation: the shadow card behind the first page sits at z-index:-1, which without its own stacking
context is painted UNDER the dialog's white background, i.e. never seen. */
.mi-stack{ position:relative; isolation:isolate; }
.mi-stack::after{ content:''; position:absolute; inset:6px -6px -6px 6px; z-index:-1; border-radius:8px;
background:#e3e6ee; box-shadow:0 1px 4px rgba(0,0,0,.15); }
.mi-toggle{ display:inline-flex; align-items:center; min-height:44px; margin-top:10px; padding:0 16px;
@ -242,6 +252,41 @@ useShadowStyles(`
.mi-foot input{ width:20px; height:20px; }
/* A phone gets the whole screen: a floating card with margins leaves too little room for a page. */
/* ★ THE ONE-TIME NUDGE IS DESKTOP-ONLY, AND THE THRESHOLD IS MEASURED, NOT CHOSEN. The pointer
is anchored beside the button and extends ~213px INWARD, straight across whatever the tab puts
in its top strip. Measured on Deep Research, on the shared browser, worst overlap per width:
390 Regions "Region Dashboards" 1416px^2 UPG-100 1428px^2
768 Cities legend 6560 Gospel legend 6560 + input 6240 Regions selector 4200
900 Cities legend 2432 Gospel legend 2496 + input 2176 Regions selector 3696
1024 Cities CLEAN Gospel CLEAN Regions selector 2100
1200 CLEAN CLEAN CLEAN
1280 CLEAN CLEAN CLEAN
UPG-100 measures the same shape independently: 768 over "Prayer Resources" AND "Map", 1024 over
"Map", clear at 1200/1280/1440 -- in en AND ar.
So it is not a phone problem, which is how it was first reported and first fixed: it persists
to 1024 and only clears near 1200. A max-width:600px rule would have left 768, 900 and 1024
broken on four tabs -- found because gospel counted PANELS (.gm-legend/.gm-sheet) as well as
controls, which a control-only probe misses entirely.
ARABIC IS MEASURED, not argued: via DX Life's /ar/ route (dir=rtl confirmed), the pointer is
hidden and clean at 390, 768 and 1024, and at 1440 it mirrors to the start side (x=104) and is
clean there too.
THE RULE IS ITS OWN BLOCK, deliberately: the 600px block below also full-screens the dialog,
and this has nothing to do with the phone sheet. The .98 covers fractional viewport widths
under browser zoom.
Hiding it below the measured clear width is the only rule that holds everywhere: the button it
points at is untouched, so nothing becomes unreachable, and a hint that covers a map's own
legend, search box or view switcher is worse than no hint.
Two other shapes were measured and both were worse: stacking it below the button traded one
collision for five (Gospel's "Resources found" 2010px^2, plus the zoom +/- of three tabs), and
hiding only its text still left 675px^2 on "Region Dashboards" and 900px^2 on Gospel's input.
⚠ NOT A PLACEMENT CHANGE. The per-tab pos.narrow values in the shell's tabInfo are measured per
tab (the buttons sit at x=8, 12, 294 and 346 across the seven tabs at 390); an inset-inline-*
pinned here would win by specificity and silently flatten all of them. The button is
placement's business; this rule is only about the hint. */
@media (max-width:1199.98px){
.mi-pointer{ display:none; }
}
@media (max-width:600px){
.mi-scrim{ padding:0; align-items:stretch; }
.mi-dialog{ width:100%; max-height:none; height:100%; border-radius:0; }

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "كل الشعوب في عرض واحد. صفِّ حسب الصلاة أو الشعب أو المنطقة أو الدولة أو اللغة، فتضيق الخريطة والجدول والإجماليات معًا.",
"takeaway": "خذ معك الصفوف التي صفّيتها في جدول بيانات."
"takeaway": "الصفوف التي صفّيتها، في جدول بيانات."
},
"research-map-plus-grid": {
"lead": "الخريطة إلى جانب القائمة. اختر شعبًا على الخريطة فتتبعه القائمة، وضيِّق القائمة فتتبعها الخريطة.",
"takeaway": "خذ معك ملصقًا للخريطة كما تراها، جاهزًا للطباعة، وحزمة صلاة لتوزّعها.",
"takeaway": "ملصق للخريطة كما تراها، جاهز للطباعة، وحزمة صلاة لتوزّعها.",
"examplePacket": "مثال على حزمة الصلاة: قلب الشرق الأوسط"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "افتح دولة لرؤية مجموعاتها، ثم أنشئ ملصقًا وحزمة صلاة ومجموعة بطاقات للطباعة أو المشاركة.",
"note": "تُنشأ الملصقات والحزم والبطاقات في متصفحك لكل دولة ما عدا الهند والصين (قريبًا)."
}
},
"col": {
"peopleGroup": "الشعب",
"country": "الدولة",
"region": "المنطقة",
"population": "السكان"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "Alle Völker in einer Ansicht. Filtern Sie nach Gebet, Volk, Region, Land oder Sprache – Karte, Tabelle und Summen werden gemeinsam eingegrenzt.",
"takeaway": "Nehmen Sie die gefilterten Zeilen als Tabelle mit."
"takeaway": "Die gefilterten Zeilen als Tabelle."
},
"research-map-plus-grid": {
"lead": "Die Karte neben der Liste. Wählen Sie ein Volk auf der Karte, und die Liste folgt; grenzen Sie die Liste ein, und die Karte folgt.",
"takeaway": "Nehmen Sie ein Poster der Karte, wie Sie sie sehen, druckfertig, und ein Gebetspaket zum Verteilen mit.",
"takeaway": "Ein Poster der Karte, wie Sie sie sehen, druckfertig, und ein Gebetspaket zum Verteilen.",
"examplePacket": "Beispiel-Gebetsheft: Kerngebiet Naher Osten"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Öffnen Sie ein Land, um seine Volksgruppen zu sehen, und erzeugen Sie dann ein Poster, ein Gebetspaket und ein Kartenset zum Drucken oder Teilen.",
"note": "Poster, Pakete und Karten entstehen in Ihrem Browser für jedes Land außer Indien und China (in Kürze)."
}
},
"col": {
"peopleGroup": "Volk",
"country": "Land",
"region": "Region",
"population": "Bevölkerung"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "Every people group in one view. Filter by prayer, people, region, country or language, and the map, the table and the totals all narrow together.",
"takeaway": "Take away the rows you filtered to, as a spreadsheet."
"takeaway": "The rows you filtered to, as a spreadsheet."
},
"research-map-plus-grid": {
"lead": "The map beside the list. Pick a people group on the map and the list follows it; narrow the list and the map follows back.",
"takeaway": "Take away a poster of the map as you see it, ready to print, and a prayer packet to hand out.",
"takeaway": "A poster of the map as you see it, ready to print, and a prayer packet to hand out.",
"examplePacket": "Example packet: Middle East Core"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Open a country to see its groups, then generate a poster, a prayer packet and a card deck to print or share.",
"note": "Posters, packets and cards generate in your browser for each country except India and China (coming soon)."
}
},
"col": {
"peopleGroup": "People Group",
"country": "Country",
"region": "Region",
"population": "Population"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "Todos los pueblos en una sola vista. Filtre por oración, pueblo, región, país o idioma: el mapa, la tabla y los totales se reducen a la vez.",
"takeaway": "Llévese las filas que filtró, en una hoja de cálculo."
"takeaway": "Las filas que filtró, en una hoja de cálculo."
},
"research-map-plus-grid": {
"lead": "El mapa junto a la lista. Elija un pueblo en el mapa y la lista lo sigue; reduzca la lista y el mapa la sigue.",
"takeaway": "Llévese un póster del mapa tal como lo ve, listo para imprimir, y un paquete de oración para repartir.",
"takeaway": "Un póster del mapa tal como lo ve, listo para imprimir, y un paquete de oración para repartir.",
"examplePacket": "Paquete de oración de ejemplo: núcleo de Oriente Medio"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Abre un país para ver sus grupos y genera un póster, un paquete de oración y una baraja de tarjetas para imprimir o compartir.",
"note": "Los pósteres, paquetes y tarjetas se generan en tu navegador para cada país excepto India y China (próximamente)."
}
},
"col": {
"peopleGroup": "Pueblo",
"country": "País",
"region": "Región",
"population": "Población"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "Tous les peuples dans une seule vue. Filtrez par prière, peuple, région, pays ou langue : la carte, le tableau et les totaux se réduisent ensemble.",
"takeaway": "Emportez les lignes filtrées sous forme de tableur."
"takeaway": "Les lignes filtrées, sous forme de tableur."
},
"research-map-plus-grid": {
"lead": "La carte à côté de la liste. Choisissez un peuple sur la carte et la liste le suit ; réduisez la liste et la carte suit.",
"takeaway": "Emportez une affiche de la carte telle que vous la voyez, prête à imprimer, et un dossier de prière à distribuer.",
"takeaway": "Une affiche de la carte telle que vous la voyez, prête à imprimer, et un dossier de prière à distribuer.",
"examplePacket": "Livret de prière d’exemple : cœur du Moyen-Orient"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Ouvrez un pays pour voir ses groupes, puis générez une affiche, un dossier de prière et un jeu de cartes à imprimer ou à partager.",
"note": "Affiches, dossiers et cartes sont générés dans votre navigateur pour chaque pays sauf l'Inde et la Chine (bientôt disponible)."
}
},
"col": {
"peopleGroup": "Peuple",
"country": "Pays",
"region": "Région",
"population": "Population"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "सभी जनसमूह एक ही दृश्य में। प्रार्थना, जनसमूह, क्षेत्र, देश या भाषा से छाँटें — नक्शा, तालिका और कुल एक साथ सीमित होते हैं।",
"takeaway": "छाँटी गई पंक्तियाँ स्प्रेडशीट के रूप में साथ ले जाएँ।"
"takeaway": "छाँटी गई पंक्तियाँ, स्प्रेडशीट के रूप में।"
},
"research-map-plus-grid": {
"lead": "सूची के साथ नक्शा। नक्शे पर एक जनसमूह चुनें और सूची उसका अनुसरण करती है; सूची सीमित करें और नक्शा अनुसरण करता है।",
"takeaway": "नक्शे का पोस्टर जैसा आप देखते हैं, छपाई के लिए तैयार, और बाँटने के लिए प्रार्थना पैकेट साथ ले जाएँ।",
"takeaway": "नक्शे का पोस्टर, जैसा आप उसे देखते हैं, छपाई के लिए तैयार, और बाँटने के लिए प्रार्थना पैकेट।",
"examplePacket": "उदाहरण प्रार्थना पुस्तिका: मध्य पूर्व का केंद्र"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "किसी देश को खोलकर उसके जनसमूह देखें, फिर छापने या साझा करने के लिए पोस्टर, प्रार्थना पैकेट और कार्ड सेट बनाएँ।",
"note": "पोस्टर, पैकेट और कार्ड आपके ब्राउज़र में भारत और चीन को छोड़कर हर देश के लिए बनते हैं (शीघ्र आ रहा है)।"
}
},
"col": {
"peopleGroup": "जनसमूह",
"country": "देश",
"region": "क्षेत्र",
"population": "जनसंख्या"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "Tutti i popoli in un'unica vista. Filtra per preghiera, popolo, regione, paese o lingua: mappa, tabella e totali si restringono insieme.",
"takeaway": "Porta con te le righe filtrate, come foglio di calcolo."
"takeaway": "Le righe filtrate, come foglio di calcolo."
},
"research-map-plus-grid": {
"lead": "La mappa accanto all'elenco. Scegli un popolo sulla mappa e l'elenco lo segue; restringi l'elenco e la mappa lo segue.",
"takeaway": "Porta con te un poster della mappa così come la vedi, pronto da stampare, e un pacchetto di preghiera da distribuire.",
"takeaway": "Un poster della mappa così come la vedi, pronto da stampare, e un pacchetto di preghiera da distribuire.",
"examplePacket": "Libretto di preghiera di esempio: cuore del Medio Oriente"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Apri un Paese per vederne i gruppi, poi genera un poster, un pacchetto di preghiera e un mazzo di schede da stampare o condividere.",
"note": "Poster, pacchetti e schede vengono generati nel tuo browser per ogni Paese tranne India e Cina (prossimamente)."
}
},
"col": {
"peopleGroup": "Popolo",
"country": "Paese",
"region": "Regione",
"population": "Popolazione"
}
}

View file

@ -30,12 +30,12 @@
"takeaway": "Escritura, áudio, filme, vídeo bíblico, formação — e o que ainda falta. Clique num país para listar os seus povos, ou num povo para a sua ficha com ligações activas a cada recurso encontrado. «Nada encontrado» significa ausente dos acervos que esta versão consultou, nunca prova de que nada exista."
},
"dashboard-plus-grid": {
"lead": "Todos os povos numa só vista. Filtre por oração, povo, região, país ou idioma: o mapa, a tabela e os totais reduzem-se em conjunto.",
"takeaway": "Leve as linhas que filtrou, como folha de cálculo."
"lead": "Todos os povos em uma só vista. Filtre por oração, povo, região, país ou idioma, e o mapa, a tabela e os totais se restringem juntos.",
"takeaway": "As linhas que você filtrou, em uma planilha."
},
"research-map-plus-grid": {
"lead": "O mapa ao lado da lista. Escolha um povo no mapa e a lista segue-o; reduza a lista e o mapa segue.",
"takeaway": "Leve um pôster do mapa tal como o vê, pronto a imprimir, e um pacote de oração para distribuir.",
"lead": "O mapa ao lado da lista. Escolha um povo no mapa e a lista acompanha; restrinja a lista e o mapa acompanha também.",
"takeaway": "Um pôster do mapa como você o vê, pronto para imprimir, e um pacote de oração para distribuir.",
"examplePacket": "Pacote de oração de exemplo: núcleo do Oriente Médio"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Abra um país para ver os seus grupos e gere um cartaz, um pacote de oração e um baralho de cartões para imprimir ou partilhar.",
"note": "Cartazes, pacotes e cartões são gerados no seu navegador para cada país exceto a Índia e a China (em breve)."
}
},
"col": {
"peopleGroup": "Povo",
"country": "País",
"region": "Região",
"population": "População"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "Toate popoarele într-o singură vedere. Filtrează după rugăciune, popor, regiune, țară sau limbă — harta, tabelul și totalurile se restrâng împreună.",
"takeaway": "Ia cu tine rândurile filtrate, ca foaie de calcul."
"takeaway": "Rândurile filtrate, ca foaie de calcul."
},
"research-map-plus-grid": {
"lead": "Harta lângă listă. Alege un popor pe hartă și lista îl urmează; restrânge lista și harta o urmează.",
"takeaway": "Ia cu tine un poster al hărții așa cum o vezi, gata de tipărit, și un pachet de rugăciune de împărțit.",
"takeaway": "Un poster al hărții așa cum o vezi, gata de tipărit, și un pachet de rugăciune de împărțit.",
"examplePacket": "Pachet de rugăciune exemplu: nucleul Orientului Mijlociu"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Deschide o țară pentru a-i vedea grupurile, apoi generează un afiș, un pachet de rugăciune și un set de cartonașe pentru tipărire sau partajare.",
"note": "Afișele, pachetele și cartonașele se generează în browserul tău pentru fiecare țară în afară de India și China (în curând)."
}
},
"col": {
"peopleGroup": "Popor",
"country": "Țară",
"region": "Regiune",
"population": "Populație"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "Все народы в одном представлении. Фильтруйте по молитве, народу, региону, стране или языку — карта, таблица и итоги сужаются вместе.",
"takeaway": "Заберите отфильтрованные строки в виде таблицы."
"takeaway": "Отфильтрованные строки в виде таблицы."
},
"research-map-plus-grid": {
"lead": "Карта рядом со списком. Выберите народ на карте — список последует за ним; сузьте список — карта последует за ним.",
"takeaway": "Заберите плакат карты в том виде, как вы её видите, готовый к печати, и молитвенный пакет для раздачи.",
"takeaway": "Плакат карты в том виде, как вы её видите, готовый к печати, и молитвенный пакет для раздачи.",
"examplePacket": "Пример молитвенного буклета: центральная часть Ближнего Востока"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "Откройте страну, чтобы увидеть её народности, затем создайте плакат, молитвенный пакет и набор карточек для печати или отправки.",
"note": "Плакаты, пакеты и карточки создаются в вашем браузере для каждой страны, кроме Индии и Китая (скоро)."
}
},
"col": {
"peopleGroup": "Народ",
"country": "Страна",
"region": "Регион",
"population": "Население"
}
}

View file

@ -31,11 +31,11 @@
},
"dashboard-plus-grid": {
"lead": "在一个视图中查看所有民族。按祷告、民族、区域、国家或语言筛选,地图、表格和总计会一同收窄。",
"takeaway": "把筛选后的行作为电子表格带走。"
"takeaway": "筛选后的行,作为电子表格。"
},
"research-map-plus-grid": {
"lead": "地图与列表并排。在地图上选择一个民族,列表随之跟随;缩小列表范围,地图也随之跟随。",
"takeaway": "带走一张你所见地图的海报,可直接打印,以及一份可分发的祷告手册。",
"takeaway": "一张你所见地图的海报,可直接打印,以及一份可分发的祷告手册。",
"examplePacket": "示例祷告手册:中东核心地区"
},
"prayer-map": {
@ -47,5 +47,11 @@
"takeaway": "打开一个国家查看其族群,然后生成海报、祷告册和卡片集,可打印或分享。",
"note": "海报、祷告册和卡片在您的浏览器中生成,适用于除印度和中国以外的每个国家(即将推出)。"
}
},
"col": {
"peopleGroup": "民族",
"country": "国家",
"region": "区域",
"population": "人口"
}
}

View file

@ -143,13 +143,17 @@ export function createCardRenderer({ strings, lang = 'en', stock = 'poker',
? `left:10px;width:${COL - 20}px;top:${12 + PH + 6}px;bottom:10px;align-items:flex-start;justify-content:center;text-align:center;`
: `left:${12 + PW + 10}px;right:8px;top:0;height:${BAND}px;`}
font:700 ${SIDE ? 14 : 17}px Georgia,serif;color:#1b1a17;line-height:1.1;overflow:hidden;
overflow-wrap:anywhere;-webkit-hyphens:auto;hyphens:auto}
overflow-wrap:anywhere}
/* Steps by the LONGEST WORD as well as the length: the name sits in a narrow box beside the photo,
and one unbreakable word (the dataset has 16-letter ones, e.g. Pagativeshagallu) would overflow it
sideways. overflow-wrap:anywhere is the last resort, so a name is never cut off without a sign;
hyphens:auto (the deck's <html lang> names the language) puts a hyphen where it does break. */
sideways. overflow-wrap:anywhere is the last resort, so a name is never cut off without a sign. */
.nm.long{font-size:${SIDE ? 11.5 : 14}px}
.nm.xlong{font-size:${SIDE ? 10.5 : 11}px}
/* A HYPHEN ONLY WHERE A WORD CANNOT FIT. hyphens:auto on every name would let the browser split ANY
word at a line end ("Northern Conchu-/cos"), and a people group's name split mid-word is worse than
the problem it solves. So it applies only to names holding a 14+ letter word (12 of 2,107), and the
limit keeps short words whole: the only multi-word ones are "Deaf <X>", and Deaf is never split. */
.nm.hy{-webkit-hyphens:auto;hyphens:auto;hyphenate-limit-chars:10 4 4}
.body{position:absolute;right:14px;overflow:hidden;
left:${SIDE ? COL + 12 : 14}px;
@ -177,7 +181,8 @@ export function createCardRenderer({ strings, lang = 'en', stock = 'poker',
// The name's size step, from its length and its longest word (see .nm above).
function nameSize(name) {
const word = Math.max(0, ...String(name).split(/[\s,()/-]+/).map((w) => w.length));
if (word >= 14 || name.length > 28) return ' xlong';
if (word >= 14) return ' xlong hy';
if (name.length > 28) return ' xlong';
if (word >= 11 || name.length > 16) return ' long';
return '';
}

View file

@ -122,6 +122,10 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
const PRAY_CELL = 84;
const SET_PANEL = 660; // one square sets panel: two + a 40px gap fit 1380px
const SET_SOLO = 960; // one chart per page: a large square, centred
// ★ MEASURED FILL, 2026-09-29: at 960px the single-chart pages ended 60-69% of the way down the sheet
// (525-702px of blank paper below). The chart is an SVG, so it scales: the bounded set has nothing
// under it and takes nearly the full 1380px row; the centered set keeps room for its ring legend.
const SET_SOLO_W = { bounded: 1320, centered: 1240 };
const PRAY_W = PRAY_CELL * PRAY_COLS.length; // 252px
// What is left is the description — the widest column on the page, and the point of the table.
// For comparison an ENTIRE card in the old 2x10 grid was 683px wide; the description alone is 50%
@ -194,9 +198,8 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
.lc-wk{height:16px;margin-bottom:4px;font:400 10px Georgia,serif;color:#6f6a5c}
.lc-wk span{text-align:center;line-height:16px}
.lc-dec{grid-auto-rows:11px;row-gap:3px}
.lc-plain{width:auto}
.lc-plain .lc-all{display:grid;grid-template-columns:repeat(80,1fr);gap:3px}
.lc-plain .lc-c{width:auto;height:auto;aspect-ratio:1}
.lc-plain .lc-all{display:grid;grid-template-columns:repeat(65,14.5px);gap:3px}
.lc-plain .lc-c{width:14.5px;height:14.5px}
.lc-plain + .lc-legend{font-size:22px;gap:48px}
.lc-plain ~ .lc-per{font-size:20px}
.lc-dec+.lc-dec{margin-top:6px;padding-top:6px;border-top:1px solid #d8d3c4}
@ -261,6 +264,9 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
/* The legend names EVERY ring, numbered as the figure numbers them, so a reader can read the scale
from the same dialect outward to the language family — including rings this list leaves empty. */
.set-row-solo .set-panel{width:${SET_SOLO}px}
.set-solo-bounded .set-panel{width:${SET_SOLO_W.bounded}px}
.set-solo-centered .set-panel{width:${SET_SOLO_W.centered}px}
.set-solo-centered + .set-legend, .set-solo-centered ~ .set-legend{max-width:${SET_SOLO_W.centered}px}
.set-legend{margin:26px auto 0;max-width:${SET_SOLO}px}
.set-legend-h{font:700 17px Georgia,serif;color:#1b1a17;margin:0 0 8px}
.set-rings{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:1fr 1fr;column-gap:40px;
@ -889,8 +895,10 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
const head = axes ? `<div class="lc-wk">${WEEK_MARKS.map((w) => `<span style="grid-column:${w + 1}">${w}</span>`).join('')}</div>` : '';
// ★ WITHOUT AXES THE GRID REFLOWS TO FILL THE PAGE. 52 columns exist only because a year has 52
// weeks; a page whose box is a share of PEOPLE has no such constraint, and at 52 x 11px the grid
// used about half the sheet's width. The same 5,200 boxes are laid out 80 x 65 (both divide 5,200,
// so the rectangle is whole), the columns stretch to the frame, and the cells stay square.
// used about half the sheet's width. The same 5,200 boxes are laid out 65 x 80 (both divide 5,200,
// so the rectangle is whole) in 14.5px squares. MEASURED in a browser: 80 x 65 across the full width
// left 478px of blank sheet below the legend (72% of the height used), because a wide grid is short;
// 65 x 80 spends that height on bigger boxes and still leaves room for a longer lede in other languages.
if (!axes) {
const cells = []
for (let i = 0; i < TOTAL; i++) cells.push(`<i class="lc-c${i < n ? ' ' + shadeClass : ''}"></i>`)
@ -1342,7 +1350,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
<div class="pg-head pg-head-c"><div class="pg-kicker">${esc(t('sets_kicker'))}</div>
<h1 class="pg-h1">${esc(t('sets_title'))}</h1></div>
${i === 0 ? `<p class="pg-lede pg-lede-c">${esc(t('sets_lede'))}</p>` : ''}
<div class="set-row set-row-solo">${p.html}</div>
<div class="set-row set-row-solo set-solo-${p.kind}">${p.html}</div>
${p.kind === 'centered' ? legend : ''}
`, `sheet-sets sheet-flow sheet-sets-${p.kind}`)).join('');
}

View file

@ -105,17 +105,40 @@ const heading = computed(() => config.value.title
//
// Set on the HOST rather than on .bp-root, deliberately: `dir` takes effect through the inherited
// CSS `direction`, and inheritance crosses shadow boundaries — so one attribute covers this
// element's own chrome AND both nested elements beneath it. Only ever SET, never cleared: a host
// element's own chrome AND both nested elements beneath it. Set and CLEARED, but only ever ours: a host
// page that already declares its own dir owns that decision.
const textDir = computed(() => {
const l = hostLang.value || getRuntimeLocale() || detectLocale()
return RTL_LOCALES.has(l) ? 'rtl' : 'ltr'
})
const _self = getCurrentInstance()
// ★ WE CLEAR ONLY THE DIRECTION WE SET. The line below used to be a one-way
// `if (rtl) setAttribute('dir','rtl')` with no else, so a reader who switched from an RTL locale to
// an LTR one (ar -> zh) kept a right-to-left layout for the rest of the session: Chinese laid out
// backwards, which reads as a broken page rather than a stale setting.
// The original reason for never clearing is still honoured, and is why this remembers rather than
// just removing: a host page that declares its OWN dir owns that decision, so the value found before
// we first wrote is restored verbatim, and removed only when there was none.
// ★ CAPTURED AT MOUNT, NOT AT FIRST WRITE. The capture used to sit inside the rtl branch, so it
// recorded whatever `dir` happened to be on the host the first time WE wrote — and if anything else
// had already set rtl by then, that value was recorded as "the page's own" and faithfully restored
// forever after. MEASURED on the Big Picture leaf: ar left rtl, and zh put rtl straight back.
// Reading it on the FIRST call instead, before any write of ours, is the only moment the attribute
// is certainly the page's and not ours.
let _hostDir = null
let _weSetDir = false
function reflectDir() {
try {
const host = _self?.proxy?.$el?.getRootNode?.()?.host
if (host && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
if (!host || !host.setAttribute) return
if (_hostDir === null) _hostDir = host.getAttribute('dir') || ''
if (textDir.value === 'rtl') {
host.setAttribute('dir', 'rtl')
_weSetDir = true
} else if (_weSetDir) {
if (_hostDir) host.setAttribute('dir', _hostDir); else host.removeAttribute('dir')
_weSetDir = false
}
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
}
onMounted(reflectDir)

View file

@ -300,7 +300,7 @@ watchEffect(() => { if (hostLang.value) setRuntimeLocale(hostLang.value) })
// Set on the custom-element HOST rather than on the layout inside it, deliberately: `dir` takes
// effect through the inherited CSS `direction`, and inheritance crosses shadow boundaries — so one
// attribute on the host covers this composite's own chrome AND every nested element beneath it,
// which is the half the audit found missing. Only ever SET, never cleared: a host page that already
// which is the half the audit found missing. Set and CLEARED, but only ever ours: a host page that already
// declares its own dir owns that decision.
const textDir = computed(() => {
const l = hostLang.value || getRuntimeLocale() || detectLocale()
@ -316,10 +316,29 @@ const textDir = computed(() => {
// it is". Measured in this tree before, under Vue 3.5.42, when research-map.vue's emitFilterState
// died the same way; the fix is its _selfInstance, and this is the same fix.
const _selfInstance = getCurrentInstance()
// ★ WE CLEAR ONLY THE DIRECTION WE SET. This was a one-way `if (rtl) setAttribute` with no else,
// so ar -> zh kept a right-to-left layout for the rest of the session. The host page's own dir is
// still respected: the value found before we first wrote is restored verbatim.
// ★ CAPTURED AT MOUNT, NOT AT FIRST WRITE. The capture used to sit inside the rtl branch, so it
// recorded whatever `dir` happened to be on the host the first time WE wrote — and if anything else
// had already set rtl by then, that value was recorded as "the page's own" and faithfully restored
// forever after. MEASURED on the Big Picture leaf: ar left rtl, and zh put rtl straight back.
// Reading it on the FIRST call instead, before any write of ours, is the only moment the attribute
// is certainly the page's and not ours.
let _hostDir = null
let _weSetDir = false
function reflectDir() {
try {
const host = _selfInstance?.proxy?.$el?.getRootNode?.()?.host
if (host && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
if (!host || !host.setAttribute) return
if (_hostDir === null) _hostDir = host.getAttribute('dir') || ''
if (textDir.value === 'rtl') {
host.setAttribute('dir', 'rtl')
_weSetDir = true
} else if (_weSetDir) {
if (_hostDir) host.setAttribute('dir', _hostDir); else host.removeAttribute('dir')
_weSetDir = false
}
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
}
onMounted(reflectDir)

View file

@ -138,7 +138,7 @@
<div class="dms-slot" :ref="el => setSlot(i, el)"></div>
<!-- The tab's "what can this do for you?" button (library MapInfo), from src/tabInfo.js. It sits
over the pane, top-right; profile-config info:false removes it. -->
<MapInfo v-if="infoFor(t)" :key="'info-' + t.id" v-bind="infoFor(t)"
<MapInfo v-if="infoFor(t)" :key="'info-' + t.id" v-bind="infoProps(t)" :style="infoStyle(t)" :placement="infoPos(t).placement || 'top-right'"
:assets-base="assetsBase" :locale="effectiveLocale" />
<div v-if="t.state === 'loading'" class="dms-hint">{{ tr('loading', { label: tabLabel(t) }) }}</div>
<div v-else-if="t.state === 'error'" class="dms-hint err">
@ -1104,6 +1104,22 @@ export default defineComponent({
// A tab's button shows only when its lead text is in the bundled catalogue: a key that has not
// landed would otherwise open a popup of raw key names. English is the check because every
// locale falls back to it.
// The entry minus `pos`, which is placement for the button, not a MapInfo prop.
infoProps(t) { const { pos, ...props } = this.infoFor(t) || {}; return props },
// pos -> MapInfo's own offset variables, inherited by its button and pointer.
// The tab's placement at this width: pos, with pos.narrow laid over it on a phone.
infoPos(t) {
const pos = (this.infoFor(t) || {}).pos || {}
const { narrow, ...wide } = pos
return this.narrow && narrow ? { ...wide, ...narrow } : wide
},
infoStyle(t) {
const pos = this.infoPos(t)
const s = {}
if (pos.top) s['--map-info-top'] = pos.top
if (pos.side) s['--map-info-side'] = pos.side
return s
},
infoFor(t) {
const e = this.showInfo && t && TAB_INFO[t.id]
return e && (!e.leadKey || tStatic(e.leadKey, 'en')) ? e : null

View file

@ -11,6 +11,19 @@
* map's WHOLE catalogue, in eleven locales, into the gallery bundle. upg100 owns 'mapInfo' and merges
* every tab's translated strings into it. Name keys LITERALLY here: that is what the area scan reads.
*
* ★ pos PLACES THE BUTTON CLEAR OF THE MAP'S OWN CONTROLS (--map-info-top / --map-info-side, default
* 12px each). The button sits at the END of the line (right in English, left in Arabic, see MapInfo),
* which is also where most of these maps keep their zoom column; measured on rega's 1440 captures in
* en and ar (2026-09-29), 12px covered the zoom-in button on Prayer Progress, Cities and Gospel, Big
* Picture's header buttons and Simple Research's tab bar. side 56px sits beside a zoom column; top
* 46px / 56px clears Big Picture's 38px header bar / Simple Research's tab bar. Regions and UPG-100
* open on a view-switch strip whose END holds their Share button, which the default covered exactly
* (found by geometry, not visible in a screenshot: only Share's ring showed around the button).
* pos.narrow overrides on a phone (the shell's `narrow`, max-width:760px), where the chrome differs:
* Regions' and UPG-100's strip is full at its END (selector + Share) and empty at its START, so the
* button goes to the start (placement 'top-left', which MapInfo resolves to the logical start, so
* Arabic mirrors); Gospel's panel spans to 345px of 390, so its button takes the corner above the
* zoom column. Measured with the same probe at 390 in en and ar.
* ★ ADD AN EXAMPLE ONLY WHEN ITS FILE IS IN THE TREE. A missing file renders the popup's "not
* available" box in front of the audience; an example that is not there yet is simply left out.
* ★ KEYS MUST NOT CONTAIN A DOT beyond the area separator: the gallery has no vue-i18n, so MapInfo
@ -19,6 +32,7 @@
export const TAB_INFO = {
'upg-100': {
storageKey: 'upg-100',
pos: { side: '56px', narrow: { placement: 'top-left', side: '12px' } },
leadKey: 'mapInfo.tab.upg-100.lead',
takeawayKey: 'mapInfo.tab.upg-100.takeaway',
// The packet pages (12) and the card deck (70) join this list when their pre-generated shots land
@ -29,6 +43,7 @@ export const TAB_INFO = {
},
'regions-dashboard': {
storageKey: 'regions-dashboard',
pos: { side: '56px', narrow: { placement: 'top-left', side: '8px' } },
leadKey: 'mapInfo.tab.regions-dashboard.lead',
takeawayKey: 'mapInfo.tab.regions-dashboard.takeaway',
// "except India and China": India is genuinely unsupported -- no sheet, no geometry, and 855
@ -43,16 +58,19 @@ export const TAB_INFO = {
},
'dashboard-plus-grid': {
storageKey: 'dashboard-plus-grid',
pos: { top: '46px' },
leadKey: 'mapInfo.tab.dashboard-plus-grid.lead',
takeawayKey: 'mapInfo.tab.dashboard-plus-grid.takeaway',
// Big Picture's export is a spreadsheet, so its example is a real excerpt of one (sr): the six
// largest groups by population among those joinable locally, of the full list's 2,107. The column
// keys are the ones the live table resolves, so the popup header and the real one cannot drift;
// cell text stays untranslated because the live table's cells are not translated either.
// headers are the live table's own words, COPIED verbatim from dashboard.json into mapInfo.col (all
// eleven locales): naming dashboard.* here pulled the whole 'dashboard' area into this bundle
// (+50 KB raw). If the table's headers are reworded, copy them again. Cell text stays untranslated
// because the live table's cells are not translated either.
examples: [{
kind: 'table',
columns: [{ key: 'dashboard.people_group' }, { key: 'dashboard.country' },
{ key: 'dashboard.region' }, { key: 'dashboard.population' }],
columns: [{ key: 'mapInfo.col.peopleGroup' }, { key: 'mapInfo.col.country' },
{ key: 'mapInfo.col.region' }, { key: 'mapInfo.col.population' }],
rows: [
['Hararghe Oromo', 'Ethiopia', 'Africa', 6355000],
['Gaaliin', 'Sudan', 'Middle East', 4520000],
@ -66,24 +84,28 @@ export const TAB_INFO = {
},
'research-map-plus-grid': {
storageKey: 'research-map-plus-grid',
pos: { top: '56px', side: '56px' },
leadKey: 'mapInfo.tab.research-map-plus-grid.lead',
takeawayKey: 'mapInfo.tab.research-map-plus-grid.takeaway',
examples: [],
},
'prayer-map': {
storageKey: 'prayer-map',
pos: { side: '56px' },
leadKey: 'mapInfo.tab.prayer-map.lead',
takeawayKey: 'mapInfo.tab.prayer-map.takeaway',
examples: [],
},
'cities-internet': {
storageKey: 'cities-internet',
pos: { side: '56px' },
leadKey: 'mapInfo.tab.cities-internet.lead',
takeawayKey: 'mapInfo.tab.cities-internet.takeaway',
examples: [],
},
'gospel-resources': {
storageKey: 'gospel-resources',
pos: { side: '56px', narrow: { side: '4px' } },
leadKey: 'mapInfo.tab.gospel-resources.lead',
takeawayKey: 'mapInfo.tab.gospel-resources.takeaway',
examples: [],

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Some files were not shown because too many files have changed in this diff Show more