Keep the reason, drop the speaker, across all three plugins

The 36 baselined references in gospel-internet (23), gospel-resources (9) and
cities-internet (4) each wrapped a real engineering reason around an attribution.
The reason is what a reader needs; the speaker is process vocabulary that does not
belong in shipped text.

Every one is rewritten in place, keeping the measurement and the date where they
were there: "NUMERALS ARE HELD fleet-wide (2026-09-26)", "(2026-09-27: one more
level of zoom-out was needed)", "Reported from use: it overlapped the bottom of
the page". Where a quote carried the requirement, the requirement is now stated
directly rather than quoted.

Comment-only: 36 insertions against 36 deletions, one line for one line, and no
built text changes. Route test passes — 0 files under library/ or internal/, so
this self-lands rather than going through studio.

Two things I nearly shipped by accident, both caught before committing:
`git add -A` swept in a 1,961-line vite.config timestamp artefact from my own
build, and editing the JSON meta through a parser reformatted "composes" from one
line to three. The artefact is deleted and the JSON is edited as a string, so the
diff is only what I meant to change.
This commit is contained in:
Doxa Maps 2026-09-29 08:22:03 +00:00
parent c8f9b38820
commit 6baf6dba65
25 changed files with 36 additions and 36 deletions

View file

@ -49,7 +49,7 @@ const profileConfig = inject('profileConfig', ref(null))
// The local fill() is gone with the guard: fillParams applies the same interpolation, except it leaves
// a placeholder for a NULL value where this fill() rendered the string "null".
const t = useOptionalT('connectivity')
// NUMERALS ARE HELD for the Driver (dx-shepherd, 2026-09-26): a locale-following format would switch
// NUMERALS ARE HELD fleet-wide (2026-09-26): a locale-following format would switch
// 'ar' to Arabic-Indic digits. Until that is decided, numbers keep the browser-default format they
// had before this pass; only the TEXT is translated.
const num = (n) => (Number(n) || 0).toLocaleString()

View file

@ -95,11 +95,11 @@ const adm1 = shallowRef(null) // { meta, byArea: { [adm1]: { name, wome
const adm1Sex = ref('women') // which half of the survey the fill shows; both are in the sheet
const selectedAdm1 = ref(null)
// ── People-group dots (Driver: "red dots on the map to show me where the people groups are")
// ── People-group dots — red dots showing where the people groups are
// The DOXA list from pray-tools, the same list Prayer Progress draws. It carries the slug the
// detail panel fetches by. The shared pin table (tables/people-group-pins.json) has no slug,
// so its dots could not open that panel.
// Dark slate with a white ring (Driver 2026-09-28: the red dots looked wrong on the mostly green
// Dark slate with a white ring (2026-09-28: the red dots looked wrong on the mostly green
// map). A neutral dark reads on every fill here, the green/amber/red/grey bands AND India's pale-to-deep
// blues, and it carries no "good/bad" meaning the way red does on a map whose red means under 25%.
// The white ring keeps it apart from the deepest blue and from the dark basemap.

View file

@ -44,7 +44,7 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
// The local fill() is gone with the guard: fillParams applies the same interpolation, except it leaves
// a placeholder for a NULL value where this fill() rendered the string "null".
const t = useOptionalT('connectivity')
// NUMERALS ARE HELD for the Driver (dx-shepherd, 2026-09-26): a locale-following format would switch
// NUMERALS ARE HELD fleet-wide (2026-09-26): a locale-following format would switch
// 'ar' to Arabic-Indic digits. Until that is decided, numbers keep the browser-default format they
// had before this pass; only the TEXT is translated.
const num = (n) => (Number(n) || 0).toLocaleString()

View file

@ -134,7 +134,7 @@ watch(() => board.centre.value, (c) => {
if (c === 'bounded') boundedEver.value = true
if (c === 'centered') centeredEver.value = true
}, { immediate: true })
// The Driver's order: Language tree, Bounded set, Centered set, then Map. The centered-set
// The tab order is Language tree, Bounded set, Centered set, then Map. The centered-set
// target waited for its data rather than shipping as an empty tab; ring and ringNb are
// columns on near-neighbors.json now, so it is here.
const CENTRE_TABS = computed(() => [
@ -187,7 +187,7 @@ useShadowStyles(
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,360px);' +
'grid-template-rows:auto auto auto minmax(0,1fr) var(--table-h,38%);' +
// The charts rail runs the full height beside the map AND the table: sized to the map row alone it was
// ~300px and clipped its four charts (rega, the Driver: "the charts don't look good").
// ~300px and clipped its four charts (rega: the charts did not look good).
'grid-template-areas:"top top top" "combos combos combos" "note note note" "facets map rail" "facets grid rail";' +
'font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;overflow:hidden}' +
'.gb-top{grid-area:top;display:flex;align-items:center;flex-wrap:wrap;gap:10px 16px;padding:10px 14px;background:#fff;border-bottom:1px solid #d1d4cd}' +

View file

@ -174,7 +174,7 @@ let ext = 1
for (const p of placed) ext = Math.max(ext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R)
// The tapestry spans ~34% of the Mercator square. The engines stop at zoom 0 (the whole square), so the
// smaller the drawing, the further a reader can zoom OUT from its opening view: at 68% there was about
// one level of room (Driver, 2026-09-27: "I need to be able to zoom out a bit more"); now there are two.
// one level of room (2026-09-27: one more level of zoom-out was needed); now there are two.
const K = 0.17 / ext
const toLngLat = (u, v) => {
const X = 0.5 + u * K, Y = 0.5 - v * K

View file

@ -84,7 +84,7 @@ try {
}
const en = await render('en')
// the Driver's default (2026-09-28): with no knob both legends start collapsed behind their "Legend" button
// the shipped default (2026-09-28): with no knob both legends start collapsed behind their "Legend" button
const shut = await render('en', {})
ok(!/data-tree-legend|class="gim-legend"/.test(shut.html) && /class="gtr-min"/.test(shut.html) && /class="gim-legend-min"/.test(shut.html),
'en: with no knob, the tree and map legends start collapsed')

View file

@ -62,7 +62,7 @@ const layout = computed(() => layoutBoundedSet(joined.value, { r: 120, spread: 2
const tally = computed(() => boundedSetTally(layout.value))
// formatCount, not toLocaleString(): a bare toLocaleString follows the READER'S BROWSER,
// so one page could print 2,074 here and 2.074 in the panel beside it. formatCount holds
// en-US in one place until the Driver rules on numerals.
// en-US in one place until the numerals question is ruled on.
// The `|| 0` stays: formatCount FORMATS and deliberately does not decide what empty means —
// Number(undefined) is NaN, so handing it a missing count would print "NaN" where this has
// always printed 0. The locale source changes; the empty policy does not.

View file

@ -116,7 +116,7 @@ onMounted(async () => {
// group, so it is filtered out by tier rather than by id.
onClick: (id, f) => { if (f?.properties?.tier === TIERS.GROUP && id) openAndFrame(id, f) },
}))
// The cross marks the centre the boundary is drawn around — the Driver reported this chart had
// The cross marks the centre the boundary is drawn around — this chart was reported as having
// none. Same geometry and the same call as the centered chart, so the two cannot drift apart.
addCrossLayer(map, project, 'gbsm-cross')
// The same ResizeObserver fit as the centered chart, and for the same reason: this panel is

View file

@ -121,7 +121,7 @@ const arrows = computed(() => layout.value.placed
.filter(Boolean).slice(0, 400))
// formatCount, not toLocaleString(): a bare toLocaleString follows the READER'S BROWSER,
// so one page could print 2,074 here and 2.074 in the panel beside it. formatCount holds
// en-US in one place until the Driver rules on numerals.
// en-US in one place until the numerals question is ruled on.
// The `|| 0` stays: formatCount FORMATS and deliberately does not decide what empty means —
// Number(undefined) is NaN, so handing it a missing count would print "NaN" where this has
// always printed 0. The locale source changes; the empty policy does not.
@ -189,7 +189,7 @@ useShadowStyles(
</p>
<template v-else-if="near">
<div class="gcs-fig">
<!-- The figure is the whiteboard map, not SVG: the Driver asked for pan, zoom and labels
<!-- The figure is the whiteboard map, not SVG: pan, zoom and labels are required
that appear as you zoom in, and the engine gives tiling and label collision that an
SVG version would have to reimplement. The PRINTED component stays SVG — a sheet has
no zoom and no hover, so the two media genuinely want different renderers. -->
@ -208,7 +208,7 @@ useShadowStyles(
</button>
<!-- ★ @fullscreen WAS NOT BOUND. The legend declares emits ['select','fullscreen'] and
renders a labelled, focusable full-screen button; only select was wired, so the
button rendered, looked right and did nothing. It is a Driver ask, and it is the
button rendered, looked right and did nothing. It is a required behaviour, and it is the
half-wired-config shape: the visible half worked, so nothing looked broken.
The handler delegates to the SHARED fullscreen implementation rather than calling
requestFullscreen here. That is not ceremony: iOS Safari does not implement

View file

@ -161,7 +161,7 @@ onMounted(async () => {
handle.value = markRaw(addTieredPointLayers(map, {
data: data.value,
sourceId: 'gcsm',
// The Driver's staging: bands read zoomed out, rings in the middle, groups in close.
// The staging is: bands read zoomed out, rings in the middle, groups in close.
tiers: [
{ tier: TIERS.BAND, minzoom: 0, weight: 'bold', offset: 1.1 },
{ tier: TIERS.RING, minzoom: 3.2, weight: 'regular', offset: 0.8, muted: true },
@ -172,7 +172,7 @@ onMounted(async () => {
opacityExpr: opacityExpr.value,
font: labelFont,
// A click on a GROUP opens that people group and frames it; a click anywhere else on a ring
// selects the ring, as before. The Driver asked for "exactly like the Language tree", so
// selects the ring, as before. The requirement is "exactly like the Language tree", so
// this is GiTree's path rather than an approximation of it: openGroup() is the same call,
// and the camera respects reduced motion the same way.
onClick: (id, f) => {

View file

@ -1,7 +1,7 @@
<script setup>
/**
* GiCharts.vue — the right rail: FOUR charts, each answering one plain question about the groups shown,
* chosen to show a newcomer the GAPS they would not otherwise see (Driver, 2026-09-27: "a reader can't
* chosen to show a newcomer the GAPS they would not otherwise see (2026-09-27: a reader can't
* tell what they're saying"; "showcase the gaps ... the biggest, most helpful, clearest data"). Each
* carries its question as the title, says what one bar is and what it counts, and ends in a one-line
* takeaway computed from the same rows.

View file

@ -52,7 +52,7 @@ const ready = ref(false)
// Province shading: 'zero' = groups with nothing found in their own language (from the rows in scope),
// 'women' / 'men' = NFHS-5 ever used the internet (India only; China has no verified provincial source).
const provinceMode = ref('zero')
// the Driver: legends start collapsed, one tap on "Legend" opens them; the embed knob legend:"open" opens them
// Legends start collapsed, one tap on "Legend" opens them; the embed knob legend:"open" opens them
const legendOpen = ref(board.profileConfig.value?.legend === 'open')
let unmounted = false
let adm1Table = null // { [shapeName]: { women, men } } India, NFHS-5

View file

@ -37,7 +37,7 @@ const emit = defineEmits(['select'])
const T = useGiT()
// formatCount, not toLocaleString(): a bare toLocaleString follows the READER'S BROWSER,
// so one page could print 2,074 here and 2.074 in the panel beside it. formatCount holds
// en-US in one place until the Driver rules on numerals.
// en-US in one place until the numerals question is ruled on.
// The `|| 0` stays: formatCount FORMATS and deliberately does not decide what empty means —
// Number(undefined) is NaN, so handing it a missing count would print "NaN" where this has
// always printed 0. The locale source changes; the empty policy does not.

View file

@ -10,7 +10,7 @@ function setGlobal(name, value) {
* GiTree.vue — the LANGUAGE TREE, on a whiteboard: every family our people groups belong to, as a radial
* tree (family -> branches -> languages -> dialects), the families packed like cells in a dish.
*
* WHY THE MAP ENGINE AND NOT D3/SVG (Driver, 2026-09-27: "snappy, lightweight, lower definition when
* WHY THE MAP ENGINE AND NOT D3/SVG (2026-09-27: snappy, lightweight, lower definition when
* zoomed out"): the same MapLibre / Mapbox the board already loads draws the tree on the GPU. It tiles the
* GeoJSON per zoom level, so zoomed out it draws LESS; its label placement never lets two labels overlap
* and shows them in priority order as you zoom (families, then languages, then branches, then dialects).
@ -51,7 +51,7 @@ const mapRef = shallowRef(null)
const err = ref('')
const ready = ref(false)
const hoverId = ref('')
// the Driver: legends start collapsed, one tap on "Legend" opens them; the embed knob legend:"open" opens them
// Legends start collapsed, one tap on "Legend" opens them; the embed knob legend:"open" opens them
const legendOpen = ref(board.profileConfig.value?.legend === 'open')
let unmounted = false
let ro = null

View file

@ -14,7 +14,7 @@
* the charts and the CSV then show.
*/
// ── the access ladder (the Driver's three words, then the sub-ladder) ───────────────────────────────
// ── the access ladder (the three headline words, then the sub-ladder) ───────────────────────────────
export const ACCESS_LEVELS = ['own', 'bridge', 'zero']
export const ACCESS_DETAILS = ['read_watch', 'read', 'hear', 'stories', 'none']
export const BANDS = ['yes', 'partial', 'no', 'nodata']
@ -110,7 +110,7 @@ export function parseQuery(search) {
const codes = [...list('codes'), ...list('code')].filter((c) => /^[a-z]{3}$/.test(c))
if (codes.length && !filters.language) filters.language = codes.length === 1 ? codes[0] : codes
const groups = list('groups').filter((s) => /^[a-z0-9-]+$/.test(s))
// '' = not in the URL: the board opens on its default (the language tree, the Driver 2026-09-28)
// '' = not in the URL: the board opens on its default (the language tree, 2026-09-28)
const panel = ['tree', 'map'].includes(q.get('panel')) ? q.get('panel') : hasMapState(q) ? 'map' : ''
const tree = /^[a-z0-9-]{3,40}$/.test(q.get('tree') || '') ? q.get('tree') : ''
// A tree node narrows by LANGUAGE (its ISO codes); the URL carries the node, not the code list.

View file

@ -60,7 +60,7 @@ export function loadNearRows(cfg = {}) {
*
* ⚠ THE COUNTRY KEY IS `iso3`, NOT `country`. This read `g.country` and shipped: on these rows
* `country` is the country's NAME ("Malaysia") while the table keys on ISO 3166 alpha-3 ("MYS"),
* so the pair never matched and the rop3 path never fired either — the Driver saw
* so the pair never matched and the rop3 path never fired either — the failure seen was
* "not drawn: 2,104" on both set charts.
*
* It survived my own check because I measured the join with the PROVINCE table standing in for

View file

@ -1,7 +1,7 @@
/**
* treePoster.js — the language tree as a PRINTABLE POSTER: one vector SVG, 36 x 36 inches by default,
* every family, branch, language and dialect in its colour, and every label placed so that NO TWO
* LABELS OVERLAP (Driver, 2026-09-27: a 3 ft x 3 ft poster "that had no overlaps").
* LABELS OVERLAP (2026-09-27: a 3 ft x 3 ft poster with no overlaps).
*
* VECTOR, NOT PIXELS: an SVG prints sharp at any size, so a 3-foot sheet needs no giant canvas (a WebGL
* or 2D canvas that large exceeds what many browsers allow). The map engine's own label placement is

View file

@ -37,7 +37,7 @@ export function useBoard() {
const sort = ref({ key: 'population', dir: -1 })
const openSlug = ref('') // the group in the detail drawer ('' = closed)
// ── the centre panel: the geographic map or the language tree (loaded on first use) ───────────────
const centre = ref('tree') // the Driver: people come for the language tree; the map is one tap away
const centre = ref('tree') // people come for the language tree; the map is one tap away
const tree = shallowRef(null) // indexTree(doc) + { meta }
const resources = shallowRef(null) // { meta, rows } — gospel links for the board's languages
const treeNode = ref('') // the tree node the board is narrowed to ('' = none)
@ -108,7 +108,7 @@ export function useBoard() {
}
return treeLoading
}
// The centre panels, in the order the Driver asked for them. This is a LIST rather than
// The centre panels, in the required order. This is a LIST rather than
// a pair because the previous form — `mode === 'tree' ? 'tree' : 'map'` — silently
// resolved every unknown id to the map. Adding a panel to the tab strip without adding
// it here would have rendered the map under the new tab's label: no error, no empty

View file

@ -11,7 +11,7 @@
*
* ⚠ THE TREE IS READY; THE FILTER IS NOT. Mapping a GROUP to a zone still runs through
* admin-to-list.json, which exp measured as an older partition — 69 groups land in the wrong
* printable zone and 436 in Unassigned. That is the Driver's row 87. So this builds and counts
* printable zone and 436 in Unassigned. That is row 87. So this builds and counts
* the tree, and the caller must not filter rows on it until the fold lands.
*/
import { resolveAssetsBase } from '@map/utils/embedBase.js'

View file

@ -61,7 +61,7 @@ const gapHero = computed(() => {
})
const TABS = [
// ★ THE DOXA TABLE LEADS, because it is the question the Driver actually asked: which of
// ★ THE DOXA TABLE LEADS, because it is the question actually asked: which of
// THEIR people groups have gospel resources and which do not. Every other tab here is
// scoped to the research corpus — 12,379 groups, 4,231 languages — which is the right
// scope for finding what exists and the wrong one for working a list of 2,107. A

View file

@ -1,6 +1,6 @@
{
"stage": "production",
"_comment": "Capability-4 composite that BAKES this bundle's own map and tables into ONE element, stacked to scroll. Both children are imported directly and compiled into the single gospel-resources bundle — no runtime injection, no second script, and no cross-bundle version skew: the tables you scroll to are built from the same pin index the map above them draws. This is the 'gospel resources dashboard' the Driver asked for.",
"_comment": "Capability-4 composite that BAKES this bundle's own map and tables into ONE element, stacked to scroll. Both children are imported directly and compiled into the single gospel-resources bundle — no runtime injection, no second script, and no cross-bundle version skew: the tables you scroll to are built from the same pin index the map above them draws. This is the 'gospel resources dashboard' that was asked for.",
"type": "nested-elements-mfe",
"composes": ["resources-map", "data-tables"],
"label": "Gospel Dashboard (map + tables baked into one element, stacked to scroll)"

View file

@ -12,7 +12,7 @@
* HTML is what gets loaded, and only findable on a built tree.
* @description The gospel map and its tables as one scrolling element: 12,379 people-group pins with the real Scripture, film and Bible-story links for each group's own language, above every resource with its URL and the 1,613 groups where nothing was found, exportable as CSV.
*
* WHY A COMPOSITE AND NOT A THIRD DATASET. The Driver asked for "a gospel resources
* WHY A COMPOSITE AND NOT A THIRD DATASET. The ask was for "a gospel resources
* dashboard if possible", and the two halves already existed as separate profiles that a
* reader had to know to visit separately: the map answers "where", the tables answer
* "what exactly, and give me the list". Neither is improved by being rebuilt; they are

View file

@ -197,7 +197,7 @@ const activeBands = computed(() => (mode.value === 'gap' ? BANDS : COVER_BANDS))
* The claim above — "all three sit apart from the clay ramp" — was false for the one that
* matters. Tier 0 was #7d3b30 and the darkest country fill is #7d3b30: THE SAME COLOUR. So in
* exactly the countries with 40+ groups having nothing, the pins that matter most dissolved
* into their own country. Reported by the Driver; confirmed by reading the two tables.
* into their own country. Reported from use; confirmed by reading the two tables.
*
* Hue alone cannot fix this, because a choropleth and its pins compete for the same range at
* every zoom. So the states differ in FORM, which survives any background:
@ -1957,7 +1957,7 @@ useShadowStyles(
// KEEP THIS BYTE-IDENTICAL to cities-internet/connectivity-map.vue: they are
// clones and the whole point of fixing them together is that they not diverge.
/* ═══ THE LEGEND MUST NEVER RUN OFF THE MAP ═══════════════════════════════════════════
Reported by the Driver: it overlapped the bottom of the page. It is absolutely
Reported from use: it overlapped the bottom of the page. It is absolutely
positioned inside the map, so its height was whatever its content happened to be — on a
short viewport that is taller than the map itself, and the overflow simply escaped.
Capped to the map's own height and scrolled inside. The cap is the CONTAINER's height,
@ -1970,7 +1970,7 @@ useShadowStyles(
'.gm-linklist span{color:#6d766e}' +
'.is-dark .gm-linklist{background:rgba(59,70,61,.6);border-color:#6d766e;color:#f3f3f1}' +
'.is-dark .gm-linklist span{color:#d1d4cd}' +
/* ⚠ LOGICAL, NOT PHYSICAL (Driver-found, rega measured on main 52eeb0db). Under ar the five
/* ⚠ LOGICAL, NOT PHYSICAL (found in use, rega measured on main 52eeb0db). Under ar the five
map controls mirror to the LEFT while `left:8px` kept the legend physically left, so the
legend sat underneath all five of them. cities-internet shares this class name and passed
for one reason: it anchors inline. Every physical left/right in this stylesheet that has a

View file

@ -417,7 +417,7 @@ const NOT_SCANNED = 'api_bible, dbl, progress_bible, youversion'
// "Akhvakh (ашвалӏи мицӏцІи)" to a code by NAME — the exact guess this
// build removed when Doxa's own API made it unnecessary, and the guess
// that would have handed Akha in Laos one of three sub-varieties.
// WHAT IT IS WORTH ANYWAY: the Driver asked for the people who do the
// WHAT IT IS WORTH ANYWAY: the ask was for the people who do the
// translation work, so that a group with nothing can be handed to a team
// that already has the linguistic work and the relationships. For the
// Caucasus cluster, IBT is that team, by name. That belongs in a leads
@ -435,7 +435,7 @@ const NOT_SCANNED = 'api_bible, dbl, progress_bible, youversion'
// returns 2 versions for eng and 3 for deu, against the dozens each
// really has. Shipping it would UNDERSTATE coverage while looking
// authoritative, which is worse than leaving it out.
// The honest route is a YouVersion API key, which is a Driver task and
// The honest route is a YouVersion API key, which is an owner task and
// not something an agent should work around. Until then this pool is
// named in the unchecked list, never in the checked one.
// GRN was the opposite case and is worth the contrast: also name-shy at first glance, but

View file

@ -2,7 +2,7 @@
* fetch-doxa-people-groups.mjs — the 2,107 people groups DOXA actually prays for.
*
* ═══ WHY THIS EXISTS ════════════════════════════════════════════════════════════════════
* This map was built on the full 12,379-pin people-groups dataset, and the Driver's reading
* This map was built on the full 12,379-pin people-groups dataset, and the reading
* of it was blunt and correct: it is MISLEADING for the job it is meant to do. Doxa does not
* work 12,379 groups. They work the ~2,100 on pray.doxa.life, and a map that shows every
* group on earth buries the ones they can actually reach — and paints countries by a gap