Commit graph

2529 commits

Author SHA1 Message Date
Doxa Maps
c99efff8a6 check:describe-group renders from the real catalogue too, and runs in gates:pre
Catalogue arm by the UPG-100 owner: one row per GSEC level through describeGroup against the
shipping packet-strings.json, in en and ar, failing on any empty sentence. The existing arms test
the function on fixture strings; this one tests that the catalogue can feed it. It lands with the
ten keys, so it arrives green.
2026-09-28 15:01:19 +00:00
Doxa Maps
9fddcb9901 min-height clamps height, so the pane could not hold the plugin it was pinning
The Driver's least favourite bug: on DX Life's research page a capability-4 plugin
with tabs at the top had a second, element-level scrollbar, and scrolling it scrolled
the TAB HEADER out of view.

doxa-maps-spa pins each fixed-viewport plugin with position:absolute;inset:0;height:
100% and its own comment states the invariant: "fills the pane exactly, so
scrollHeight===clientHeight and overflow:auto paints NO scrollbar." CSS never granted
that. A used height is CLAMPED between min-height and max-height, so a child's own
min-height BEATS the height:100% the shell sets -- it is a floor, not a preference.
Wherever the pane was shorter than that floor the pinned element grew taller than the
pane it was meant to fill, .dms-slot grew with it, and .dms-pane's overflow-y:auto
became a real scroll container wrapping the WHOLE plugin, tab strip included. That is
the second scrollbar: not the plugin's content scroller, the PANE scrolling the plugin
bodily. The invariant was written as a description and was only ever a hope.

The shell now pins with min-height:0. An inline style outranks a child's :host rule,
so that one property restores the invariant for every fixed-viewport plugin at once
without editing any of them. It is NOT added to the scroll-stack branch, where
natural height is the design.

A floor set INSIDE a child's shadow root is out of reach from the shell, and two of
mine had one. research-dashboard's .rd-shell and big-picture's .bp-root now say
min-height:inherit, deferring to :host -- which the gallery pins to 0, and which a
naive page leaves at its own floor, so the collapse guard those floors exist for is
kept rather than deleted. big-picture's 480px MOVED UP to :host rather than being
dropped: :host had no floor there, so inheriting would have quietly resolved to 0
everywhere and thrown away the black-screen guard its own comment calls load-bearing.

check-one-scroll-container asserts both halves, because either alone rots: the shell
still pins with min-height:0, and no plugin grows a NEW floor inside its shadow root.
The second half is what matters later -- a fixed shell plus a plugin that adds
min-height:700px next month is this bug again and nobody would connect it.

The rule is a CONTRADICTION IN ONE DECLARATION, not a large min-height by itself: an
element saying height:100% ("I fill whatever holds me") while refusing to be shorter
than N. My first version flagged any big min-height and caught gospel-resources'
.gd-map, a map deliberately sized height:80svh that scrolls inside its root's own
content scroller -- the one scrollbar a plugin is allowed. Flagging it would have sent
its owner after a non-bug, which is worse than not having the gate.

Negative-tested: removing min-height:0 from the shell, and reverting either .rd-shell
or .bp-root to a constant, each add a failure; restored, only the known one remains.

RED ON PURPOSE, and deliberately NOT in gates:pre. gospel-internet's .gb still carries
min-height:640px and that fix is its owner's. Wiring a red gate into gates:pre would
block the fleet on someone else's file, so the gates:pre entry belongs in the same
landing that fixes .gb -- until then this gate is the work item, afterwards the guard.

Source only: doxa-maps-spa, research-dashboard and big-picture need rebuilding, which
is the build owner's to do.
2026-09-28 15:01:19 +00:00
Doxa Maps
1061e9c5c4 The packet's generated profile sentence has its catalogue: ten keys in eleven locales
describeGroup returned an empty string against the shipping packet-strings.json because none of
its keys existed, while its check passed on its own fixture strings. desc_where_* says where a
group lives and what it speaks, dropping whichever clause has no data; desc_gsec_0..6 says how
evangelical it is. Each locale's under-2% sentences reuse that locale's funnel_unreached wording,
so a packet does not define the threshold two ways on two pages. English from the UPG-100 owner.
2026-09-28 15:01:19 +00:00
Doxa Maps
93c3cd6aa1 Move the pinned gap, and assert the filing that closed it
upg100 filed asia-india-madhya-pradesh and this check went red, which is the arm
working. They reported it instead of deleting my expectation to make their own
change green, and they were right that doing so is how a guard gets hollowed
out. dx-shepherd ruled the two land together, which is the only ordering where
neither of us is red in between.

THIS COMMIT IS RED ON MAIN BY ITSELF. It expects three unfiled zones and today's
data has four. It goes green with upg100's data change and not before, so the
two must land in the same landing.

The gap pin drops to guizhou, xinjiang and bihar. Filing is the good direction
but it is exactly as revertible as any other data edit, so the three newly filed
India zones now get an arm of their own: drop a key from zone-regions and those
groups quietly leave the region tree again, with the bucket the only thing still
carrying them. Two of the three were never visible to the gap arms at all, so
without this they could be unfiled with nothing to say so.

Also recorded what the gap number actually counts. It is admin-to-list's values,
which is not every printable zone, so it is a lower bound rather than a census.
upg100 proved that costs something: checking the property instead of the four
names they were handed, they found four more unfiled zones, none of them an
admin-to-list value, so none of them ever appeared in this number. A count that
is quietly a lower bound reads exactly like a complete one.

And the fixture arm no longer hardcodes the number four. The gap changes; a
literal there turns every legitimate data fix into a confusing red in an arm
that is not even about the data. It counts the pin instead.
2026-09-28 15:01:19 +00:00
Doxa Maps
88818589e4 UPG-100: file three India zones into the asia region
zone-regions.json was missing madhya-pradesh, uttarakhand and west-bengal. All
three are in clusters.json AND groupings.json — printable, grouped zones with no
region — so their groups reached a reader only through the vertical legend's
unassigned bucket, which is honest but wrong for a list picker.

★ THE FOUR NAMES IN THE REPORT WERE NOT ZONE IDS, and following them literally
would have created rows that match nothing. The report said guizhou, xinjiang,
bihar, madhya-pradesh; the canonical ids are fully qualified
(asia-china-guizhou-province, asia-india-madhya-pradesh, …), and the bare
`bihar` that exists is an admin-to-list.json KEY whose value IS the zone id.
Only ONE of the four (madhya-pradesh) was a zone at all; the other three are
absent from clusters.json and go to the zone-universe card as a product
question, not a data edit.

★ AND THE REPORTED SET WAS INCOMPLETE. Checking the PROPERTY — which printable
zones lack a region — rather than the four names found uttarakhand and
west-bengal by the identical two-file test. Second time today an enumeration
handed over was short: the wagf fetch was 6 reported / 8 real. regions-plugin's
gap number could never have contained these, because it is admin-to-list VALUES
minus zone-regions KEYS and none of them is an admin-to-list value — their
output now states it is a lower bound.

deaf-old-world and deaf-new-world-asia-pacific stay unfiled by design: the
"deaf": "exempt" umbrella key covers them (dx-shepherd).

Lands ATOMICALLY with regions' f3c050ae, which updates the pinned gap to
[guizhou, xinjiang, bihar] and asserts these three STAY filed — filing is as
revertible as any data edit. Verified the pair before sending: their gate
against this data exits 0.
2026-09-28 15:01:19 +00:00
Doxa Maps
fe9fe34530 Set an alarm on the zone/region id collision, which data currently hides
gospel found a latent ambiguity in the emit contract while wiring, and checked
it instead of building past it. selectedId emits one bare string with no kind,
and region ids and zone ids share a namespace. Re-measured here rather than
taken on report: 33 zone ids, 7 region ids, and the intersection is exactly
europe, whose region is itself. So the string "europe" cannot tell a consumer
whether the Europe region row was clicked, meaning every European zone, or the
Europe zone leaf, meaning that one.

It is not live today, and only for a reason that is a coincidence rather than a
design: region europe contains exactly one zone, so both readings give the same
set. It goes live the day a second European zone is filed, and then it fails the
quiet way this whole file exists to catch — a smaller result, no error, no empty
state.

So this is an alarm, not a fix. The consumer builds the tree, so only the
consumer knows which rows are regions, and gospel is namespacing theirs. Two
arms: the colliding set is pinned and says re-derive if it changes, and each
colliding id's region must still contain exactly one zone. The second fails with
the instruction rather than the symptom, naming the zones and telling whoever
hits it to namespace their region rows.

Both falsified before trusting them: filing a second European zone fires the
ambiguity arm with both zones named, and adding a second colliding id fires the
drift arm with both ids listed. Data restored, clean diff.

The same note is on selectedId, because the next consumer to build a tree from
this zone data inherits the collision, and the doc as written invited it.
2026-09-28 15:01:19 +00:00
Doxa Maps
4b7f65204f Collect the gospel set-chart layout check in gates:pre
The check itself shipped with the plugin modules it covers, but the line that
runs it existed only in a message, which is not a place code lives. So it sat in
internal/ and would never have run — the same failure it was written to prevent,
one layer up.

This is the whole registration in one commit: the file, the script entry and the
word in the gates:pre list. It imports boundedSet.js and centeredSet.js, so it
must land after the plugin commits that add them; landing it first fails
collection and takes gates:pre red for everyone.
2026-09-28 15:01:19 +00:00
Doxa Maps
6b6c745dbe Make the zone-join assertions travel, and record what each guard buys
Both joins were verified while they were written, and that verification lived in
a terminal. A probe proves something once; only a registered check keeps
proving it. Collected by gates:pre, where it runs as check:upg-zone-joins.

Both defects it guards are silent. Neither throws, neither renders an error, and
both return a well-formed answer that is merely smaller than the truth: admin
names whose diacritics are not folded join 514 of 1009 rows instead of 1009, and
records passed in the pre-normalize { value, label } shape match nothing at all,
so every zone reports as having no groups.

Each arm is checked in both directions, and the negative arms are the point. The
check records that the fold is worth 495 rows and that the object shape resolves
zero zones, so a later reader asking whether a guard is still needed gets a
number rather than a green tick. It also fails if the unfolded path ever stops
losing rows, because at that moment the assertion would no longer be able to
fail and would stop being evidence.
2026-09-28 15:01:19 +00:00
Doxa Maps
04bb5b264c Regenerate the library index and the capability exemplars
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Both are derived files that had drifted: an import count for utils/html.js, and
regions-dashboard's exemplar is region-dashboards now, not regions-list.
2026-09-28 14:53:59 +00:00
Doxa Maps
bd37adacdd check:manifest-covers knows a desktop-only bundle is not in the web tree
rudder-voice targets desktop and widget, so generate-tree skips it through isWebBuilt(); this
check did not, and reported the deliberate absence as a missing plugin. It reads the same rule now.
2026-09-28 14:53:59 +00:00
Doxa Maps
f45624915f check:frozen-labels holds a composed bundle to the stage of the page that shows it
Written by the UPG-100 plugin owner. It now takes the stricter of a file's own profile stage and
its bundle's effective stage (internal/lib/effective-stage.mjs), so an experimental bundle that a
production page composes is checked as production, and the output says why.
2026-09-28 14:53:59 +00:00
Doxa Maps
76fe9ddf28 Test the cycle safety the header only claimed
The module said a cycle terminates rather than hanging. That was prose. The
manifest has six self-composing edges today, through the bundle/profile form,
and upg100 measured that a naive recursive walk hangs on it -- so the claim was
load-bearing and untested, which is the same gap as an untested fallback.

The fixture now carries a self-composing bundle and a mutual two-cycle. If the
closure ever regresses to recursion this test does not fail, it hangs, which is
a louder signal than a red assertion.
2026-09-28 14:53:59 +00:00
Doxa Maps
4b0751998d Hold composed bundles to the bar their readers are actually on
A gate scoped by a bundle's own declared stage misses what a production reader
sees. Two production leaves compose three bundles that declare themselves
experimental, so the code on those pages was held to the experimental bar --
not by anyone's decision, but because the scope was computed from the wrong end
of the composition.

Effective stage: production if it declares production, or if anything
production-for-gating composes it, transitively. The graph's edges come in two
shapes, a bare bundle name and a bundle/profile pair, and reading only one
silently halves it.

It lives in its own module because check:frozen-labels needs the same rule.
Two gates with their own idea of "production" would disagree by passing a file
the other fails, with neither obviously wrong.

The scan goes from seven plugins to ten, nineteen files to twenty-nine, and
from 178 to 274. The 96 are not new: they are English on production pages that
no gate was measuring. The report names which plugins are in scope only by
composition, and why, since that is the first question a reader will have.
2026-09-28 14:53:58 +00:00
Doxa Maps
2250ed4fe4 Put the collapse guard on the host, where the rest of the fleet keeps it
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Dropping the floor outright removed a guard. :host here was only display:block,
so an embed that gives the element no height leaves .gb{height:100%} resolving
against an auto-height parent and renders nothing — the collapse the floor was
there to prevent. The standalone leaf hides this, because it sizes the element
with position:fixed and inset:0; an arbitrary embed does not.

The floor belongs on :host, which is where every other plugin keeps it —
research-map at 320px, people-groups at 420px, big-picture at 480px. This plugin
was the one that put it on the inner root instead, and that is the whole bug: on
the host it guards a collapse while a shell that sizes its own pane can override
it from outside, whereas on .gb it could only push the element past its pane and
hand the host a second scrollbar across the tab strip.

The value is carried over unchanged rather than tuned while it moves, though
640px is the largest floor in the fleet and worth a second look on its own.
2026-09-28 14:49:10 +00:00
Doxa Maps
ac21afea21 Rudder's robot voice keeps a sentence whole across a breath, and splits only real run-ons
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
First live use: one sentence came out as two.
- A pause is not a sentence end: the engine finalises after 0.8 s of silence, a breath mid-sentence
  included, and the robot spoke that fragment alone. A final that doesn't end in . ! or ? is now held and
  joined to what's said next; spoken as it is after 2.5 s of silence or on Stop.
- The comma break for run-ons now needs ≥20 words waiting (was 14) and keeps ≥10 in the piece, so ordinary
  long sentences are no longer cut at their comma.
- Checked by running the page's own functions on scripted streams: a sentence paused mid-way → one spoken
  sentence; a 30-word run-on → two clause-sized pieces; a 16-word sentence with a comma → not split; an
  unfinished thought then Stop → still spoken.
2026-09-28 14:45:56 +00:00
Doxa Maps
971162a4b6 Rudder's robot voice keeps up with non-stop talk, skips fillers, and defaults to Male voice 5
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
- Speaks each sentence as soon as it's complete in the stream, not only after a pause. MEASURED: with no
  pause between sentences the model joins them with a comma, not a period, so there may be no sentence end
  for 20+ s; a long stretch (≥14 words) now also breaks at the last comma (≥8 words), and ≥35 words with no
  punctuation speaks the first 25. Short fragments (<3 words) ride along with the next chunk. On 27 s of
  speech with no pause the robot spoke at 8.4 s, 16.5 s and 24.1 s instead of silent until 21.7 s; every
  word is spoken exactly once, in order.
- Cleans what the ROBOT says (the box keeps exactly what was said): drops um/uh/erm/hmm/ah with their
  commas, collapses stuttered repeats ("the the"), tidies spacing, capitalises. Text rules only.
- Default voice: Male voice 5 (speaker 375) — the clearest by ear; the Robot voice itself stays off until
  ticked.
2026-09-28 14:42:09 +00:00
Doxa Maps
45c2ce9bcc Stop the board overflowing a short pane and taking the tabs with it
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The root carried min-height:640px alongside height:100%. In a pane shorter than
that the element is taller than its host, so the host scrolls the whole plugin —
tab strip included — and the reader gets a second scrollbar that hides the tabs
it is meant to reach. That is the scrollbar on DX Life's research page.

The floor becomes min-height:0, which is what every inner grid area already
carries for exactly this reason; the root was the one that did not. Nothing
collapses without it: the standalone leaf gives the element a definite height
with position:fixed and inset:0, and the pixel floor was unique to this plugin
rather than a house pattern.
2026-09-28 14:36:11 +00:00
Doxa Maps
43d290312d Draw the centered-set target from the ring columns
The cross at the middle, each unreached group on the ring its closeness to a
reached neighbour puts it on, arrows pointing at the group that places it. The
opposite claim from the bounded set: there the only fact is which side of a line
a group is on, here there is no line and the whole content is distance.

The rings are read, not recomputed. This plugin could derive them — it has the
columns — but the rule encodes a contested judgement about what "dialect" means,
and that belongs to the data owner. Two implementations would not error, they
would quietly disagree, and the symptom would be two charts on one dashboard
showing different centres. ringNb is the reached row that sets the ring, so the
radius and the arrow come from one field and cannot point at different groups.

Four outcomes, reported as four: placed, already reached and so at the centre,
no relation found, and not in the neighbour table at all. The last is counted at
the join, because a table cannot carry a row it does not have. Folding any of
them together would turn a gap in our knowledge into a claim about a people.

Both panels now share one loader. They read the same file and it is the largest
thing either of them fetches, so a fetch each would double it for nothing.

Measured against the shipped table, standing the province rows in for the
dashboard's: 1009 in, 15 unmatched at the join — the same Indian name variants
the zone join already documents — 990 placed, 4 at the centre, none unplaced,
and every placed group has a named neighbour. The four counts add back to the
input exactly.
2026-09-28 14:36:11 +00:00
Doxa Maps
416cc06eac Rudder speaks and fills the box sentence by sentence; Robot voice in a Settings menu, male by default
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
- Engine: start the speech server with --asr.endpointing.enable=true. End-of-sentence detection is OFF in
  NeMo-Speech.cpp by default, and session.update's endpointing_ms only sets the pause length, so every
  recording came back as ONE final when Stop was pressed (measured: a minute of speech → one 437-character
  final; the robot voice then spoke a minute late). Now each sentence is final ~1 s after it ends: in a
  Firefox run the robot spoke the first sentence 13 s before Stop.
- Settings menu in the full layout too (⚙, closed by default, works at phone width): Language, Microphone
  and Robot voice live in it; Record and the level meter stay visible.
- Robot voice: exactly ten presets, five male then five female, chosen by measured pitch (men ~92–108 Hz,
  women ~206–237 Hz); Male voice 1 is the default. The browser's own voices appear only as a fallback when
  the offline voices are missing, at most ten. A blocked or failed playback is shown instead of silence.
2026-09-28 14:30:56 +00:00
Doxa Maps
a3a7c4b5b6 Rudder: Robot voice — speak each sentence in a synthetic voice, offline, for videos without your real voice
Settings → Robot voice: a checkbox and a voice dropdown. When on, every finished sentence is spoken back
(sentences, not words: TTS needs context for its intonation). speak(text) is the single entry point, so a
future conversational mode can reuse the same voice.

- Providers, both on this computer only: Piper (offline neural TTS run by the engine on the CPU; six
  LibriTTS-R speakers offered as "Offline voice A–F") and the browser's own voices, restricted to
  localService === true (some browsers synthesise "online" voices on a server — never offered).
- backend/rudder-engine/tts.go: POST /api/rudder/tts (text → audio/wav, 1–2000 characters, one synthesis
  at a time), GET /api/rudder/tts/voices, POST /api/rudder/duck. The spoken text is never logged, only
  its length. Piper 2023.11.14-2 (MIT) bundles ONNX Runtime 1.14.1: no telemetry client; a synthesis
  traced with strace made 0 network calls. ~150 MB RAM; a 10.7 s sentence rendered in 2.2 s on CPU.
- While the robot speaks through loudspeakers, the engine feeds the transcriber silence, so it never
  transcribes itself (and that silence is not mistaken for a dead mic). With a Bluetooth headset or
  headphones it keeps listening — pausing would only lose the person's words.
- setup-runtime.sh: Piper and the en_US-libritts_r-medium voice (CC BY 4.0), pinned by SHA-256.
- Verified in Firefox against a dev server: dropdown lists A–F, each finished sentence is synthesised
  (200 audio/wav) between duck on/off, zero requests to any other host.
2026-09-28 14:30:56 +00:00
Doxa Maps
fb1938e732 Rebuild the served tree after rebasing onto main
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Generated output, so it is rebuilt from the rebased source rather than merged:
the bundle first, then the hashes.
2026-09-28 14:25:44 +00:00
Doxa Maps
05728da7aa Keep the three ring states apart: placed, the centre, and no relation found
The ring column used to spell "reached" and "no relation found" with the same 0.
The data owner has split them, so this follows: ring 0 is REACHED and belongs at
the centre; ring -1 is unreached with no relation found and is genuinely
unplaced; 1-10 are placed. A fourth case never reaches this module at all — a
group with no row in the source, which the consumer counts at the join.

Treating ring 0 as unusable would have reported the best outcome in the set as
missing data. That is the same mistake as folding an unmatched group into
"unreached", and as letting ring 10 double as a bin for groups with no ring: the
outer category and the absent category keep wearing the same value, and they are
opposite facts.

The existing assertion caught the change rather than being updated after it. Its
fixture used ring 0 as an example of an invalid ring, so the check went red the
moment the meaning moved, which is what a fixture pinned to a real encoding is
for. Reached is now asserted to reach no ring at all.
2026-09-28 14:25:44 +00:00
Doxa Maps
66ffae8435 Follow the ring data to its flattened shape, and prefer the owner's join key
The ring arrived as a separate map and is now plain columns on every row: ring,
ringNb and rop3. The layout module did not need to change — it takes a ring and a
neighbour id and does not care where they were read — but its header described
the old shape, and a header that describes a shape nobody ships is worse than no
header. The note records the change, because the difference matters to anyone
decoding the table by hand.

ringNb is the reached row that SET the ring, so the arrow and the radius come
from one field by construction and the older links ordering cannot disagree
with them.

The bounded set now tries rop3 + iso3 before falling back to name and country.
rop3 is the data owner's key and the reliable one, but it reaches these rows only
if the API document declares it — normalizeRows copies whatever meta.columns
lists rather than a fixed set — and that cannot be confirmed away from the live
endpoint. The fallback was measured at 994 of 1009 against the province bridge.
Either way an unmatched row stays UNKNOWN and is counted where it can be seen, so
a key that turns out to be absent surfaces as a visible unknown count rather than
as a chart full of groups that merely failed to join.
2026-09-28 14:25:44 +00:00
Doxa Maps
afc343a112 Lay out the centered-set target, without re-deriving its rings
The opposite claim from the bounded set: there the only fact is which side of a
line a group is on, here there is no line and the whole content is distance. The
two must not look alike.

Ring membership is data, not a derivation. It is computed by the upg-neighbor
build and written into near-neighbors.json, and this module reads it rather than
working it out again. That is deliberate: the ring rule encodes a contested
judgement — whether "dialect" means an ISO code or an Ethnologue-strict level —
which belongs to the data owner. A second implementation here would be a second
answer, and the first symptom would be two charts quietly disagreeing.

The arrow and the radius are taken from the same field for the same reason. The
ring records the reached group that set it; the older links list records the
nearest neighbour on the E-scale ordering, sorted by believers and distance.
Those can differ, and reading the radius from one and the arrow from the other
draws a group on ring 3 with an arrow pointing at its ring-8 neighbour, with
nothing on screen to say so.

A group with no ring is unplaced and keeps a reason, never defaulted onto the
outer ring: ring 10 is a real answer, so it must not double as a bin for missing
data. A group with no recorded neighbour gets no arrow rather than a guessed one.

The check covers the properties where a wrong answer still looks like a chart —
a dot on the boundary, an unknown folded into unreached, an arrow overshooting
the cross it points at. These are pure functions, so their inputs are their
domain and the assertions are about the modules themselves. The binding to the
data file is not checked here; it belongs where the data is read.
2026-09-28 14:25:44 +00:00
Doxa Maps
3f73cf6d9a Add the bounded set as a centre panel, and stop unknown panels becoming the map
The Driver's order for the centre is Language tree, Bounded set, Centered set,
then Map. Tree and Bounded set are here; the centered-set target is not, because
it needs the ring data that is still on another branch. A tab whose panel cannot
be drawn is worse than a tab that is not there yet, so it arrives with its data
rather than ahead of it.

setCentre had to change first, and it was hiding a trap. It read

    centre.value = mode === 'tree' ? 'tree' : 'map'

so every id that was not 'tree' resolved to the map. Adding a tab to the strip
without touching the setter would have drawn the map under the new tab's label:
no error, no empty state, just the wrong panel wearing the right name. The panels
are now a list, and an unknown id warns once and falls back to the first — the
same way an unknown people-group list is already handled.

The chart claims one thing, which side of the line a group is on, so position
carries no other meaning: no gradient, no ranking, no distance. The layout is a
pure module so it can be tested without mounting anything, and it is SVG rather
than the map engine because there is no zoom, no camera and no labels here; if it
is ever pointed at the full table the answer is to aggregate, not to reach for
the GPU.

Reached-ness is not in these rows — they describe internet and language access —
so it joins near-neighbors on name and country. A group that does not join is
reported as unknown rather than drawn outside the circle. "We could not match it"
and "it is unreached" are different facts and only one of them is about the
group. If the table itself fails to load the panel says so, because a bounded set
drawn from nothing is a circle with everything outside it, which reads as a claim
rather than an error.
2026-09-28 14:25:44 +00:00
Doxa Maps
2e62192c27 Reach the non-India zones through their selectors, not a second bridge
The province bridge only reaches India and China, because that is all
gospel-by-province-groups holds. Every other UPG-100 zone already has a path:
each zone in clusters.json carries a selector, and selector-evaluator.js turns a
selector plus people-group records into the slugs in that zone. So this is an
adapter, not a new join.

Which path a zone needs is a property of the zone rather than a fallback chain:
a pg_slugs selector IS the membership and is matched on slug, the evaluator
rejecting that type on purpose; country selectors go through evaluate(); the
India and China zones use the province fold.

Records must be passed FLAT. The evaluator reads country_code as a string, while
the file header describes the source shape as { value, label } — that is the
shape before normalize.js flattens it. Passing the object form matches nothing
and returns an empty set for every zone, which reads as "this zone has no
groups" rather than as a mistake. It cost a run here, so the check pins the
empty-set arm as well as the working one.

Zones whose selector needs a field the records do not carry return an empty set;
zones the evaluator rejects are reported separately. An empty zone and an
unanswerable zone are different facts and a legend that draws them the same way
is lying about one of them.
2026-09-28 14:25:44 +00:00
Doxa Maps
d6253b34d0 Join a people group to its UPG-100 zone, with admin names folded
The UPG-100 lists are defined by admin region and the people-group tables are
keyed by group, so the join runs through the province assignment. The two files
disagree on spelling: the zone table writes `gujarat`, the province table writes
`Gujarāt`, and the same for Karnātaka, Tamil Nādu, Uttarākhand, Jammu and
Kashmīr and Haryāna.

Matching on toLowerCase alone joins 514 of 1009 rows across 9 zones. Folding the
combining marks joins 1009 of 1009 across 12 zones.

The 50.9% is the number worth naming: a legend built on it would have shown half
the groups and three fewer zones, and nothing on screen would have said so —
every zone still looks populated, only smaller. The fold lives at the lookup
rather than as extra spellings in the zone table, so a future source spelling
cannot reopen it.
2026-09-28 14:25:44 +00:00
Doxa Maps
dece2df47e Rebuild upg-neighbor, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 14:21:45 +00:00
Doxa Maps
6c346e603a UPG Neighbor: an unplaced group reads ring -1, so it can never pass for a reached one 2026-09-28 14:21:34 +00:00
Doxa Maps
140a25e4d7 Serve the rebuilt near-neighbors table 2026-09-28 14:21:34 +00:00
Doxa Maps
266ddef381 UPG Neighbor: the ring is a column on every row, with rop3 so other plugins can join their groups to it 2026-09-28 14:21:34 +00:00
Doxa Maps
d679430a5c UPG Neighbor: every unreached group's centered-set ring and the reached group that sets it 2026-09-28 14:21:34 +00:00
Doxa Maps
15e0e02408 UPG-100: bind the last three strings (studio L58 keys)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Completes the i18n card. L58 landed the four keys the first spec was missing.

poster.region_india / region_other — all SEVEN poster-wall headings now
translate. The first key spec had five because check:hardcoded-english reported
five: its header states it cannot see SINGLE-WORD strings, and 'India' and
'Other' are single words. That is the gate's own documented blind spot, not an
oversight in the spec, and it is why a green run is not "no English".

messages.bandInRange / bandOutOfRange — the band ternary was
`'✓ 50–100 band' : 'band 50–100'`. TWO keys rather than a ternary over one,
because these are two STATES of one label: a locale may not phrase "in range"
and "out of range" as a tick plus the same words. The ✓ lives in the catalogue
string so a locale can drop it.

check:hardcoded-english on my two files now reports only REGION_OF's values,
which are grouping IDENTITY by design — regionLabel() translates them at render
while the map keeps English keys, so the grouping cannot re-key per locale.
2026-09-28 14:17:14 +00:00
Doxa Maps
53d3862627 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: big-picture
cities-internet
country-posters
dashboard-plus-grid
doxa-research-map
doxa-simple-map
key-cities
my-upg-100-list
regions-dashboard
research-dashboard. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 14:10:09 +00:00
Doxa Maps
54c4d9be48 A prayer packet printed from the browser includes its evangelical-share page
buildPacket takes the flock-wide evangelical share as its sixth argument. The Node generator
passed it; the browser print path did not, so the page returned an empty string and a user's
packet had one sheet fewer, with nothing to say so. It now loads the same
data/people-groups/christian-share.json (about 1 KB, lazily, beside the strings), and a host
may supply its own figure through opts.christian like opts.strings.
2026-09-28 13:57:23 +00:00
Doxa Maps
29c9551d30 check:selector-fields in gates:pre: a packet fetch must carry every field its selectors read
Written by the UPG-100 plugin owner. It reads each selector's fields from the evaluator itself,
so it cannot encode a wrong list of zones. It fails on the fetch before cdc788fbf, which left
eight zones' packets empty, and passes after it; both land in this push.
2026-09-28 13:56:53 +00:00
Doxa Maps
88fe7509d0 The packet was praying over the numbers the page loaded with, not the live ones
rega RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend
row said 699. The legend row is right and the packet was wrong.

A pin's prayer count arrives TWICE. The GeoJSON property is whatever was true when
the page loaded; from then on polling writes the current count with setFeatureState,
and prayer-progress.js:94 colours from ['feature-state','peoplePraying'] once polling
is on. So the lit pins a reader counts are feature-state, and pin-selection.js had no
'feature-state' case at all -- it resolved to undefined, to-number defaulted it to 0,
and the comparison selected only the pins whose FROZEN property happened to be above
zero. 50 of them. The other 649 people groups had been prayed for and the packet left
them out, which is the one thing a prayer packet must not get wrong.

resolveExpr and matchesExpression now take the state alongside the properties, and
selectedPinIds reads it per pin off the MAP rather than off the feature -- the source
still holds the GeoJSON it loaded, setFeatureState never touches it. The source is
added with promoteId:'uniqueId', so the id feature-state is keyed by is the same
props[idKey] the selection already uses.

Degrading is deliberate in both directions: before the first poll there is no state
and property expressions still select, so an early packet is thinner and never empty;
getFeatureState throws on an unknown source, so it is guarded and a bad source loses
the selection rather than aborting the print.

ALSO, rega RQ3(a), a candidate and not yet a confirmed cause. prewarmRegionPaint
wrapped any non-feature-reading paint value in a case to make it data-driven,
including a zoom interpolate -- I argued for that explicitly. useMapLayers.js:319
already held the rule and I had not read it: zoom interpolation is kept by putting the
case in the interpolate OUTPUTS, and "the reverse (zoom nested inside a 'case') is what
breaks the layer, so don't do that." That reverse is the shape Mapbox classifies as
composite before reaching for an interpolation factor the expression cannot supply,
which is the class of the interpolationFactor page error. The wrap now skips zoom
expressions too. A missed pre-warm costs a frame; an illegal paint costs the layer.
None of the three properties it touches is a zoom interpolate at boot, so this is not
proof -- it removes the only writer that can build that shape from a runtime value.

check-pin-selection asserts the live count wins over the stale property, that the
stale property alone selects nothing, that a map with no feature-state still selects
on properties, and that a throwing getFeatureState degrades. Negative-tested:
removing the case, not reading the state, and dropping the throw-guard each turn it
red; restored, green.
2026-09-28 13:56:22 +00:00
Doxa Maps
ed40117b9a UPG-100: six zones printed an EMPTY packet — the fetch lacked its selector fields
MEASURED, not inferred. The packet fetch requested
slug,name,country_code,population,religion,image_url(+language,gsec) while SIX
of the 27 printable zones select on wagf fields it never asked for:

  west-africa, east-africa, central-africa   wagf_block
  europe                                     wagf_region
  southern-africa-islands                    union of two wagf_block
  oceania-indonesia                          union of wagf_region + country_code

Proved with the real evaluator: a wagf_block selector returns 0 members on
packet rows and 1 on map rows. So those packets resolved to nobody and printed
empty — while the SAME selectors resolved correctly on the map, whose fetch
(:1988) has carried wagf_region,wagf_block all along. One file, two fetches,
two different field sets, and only one of them agreed with the data.

★ oceania-indonesia IS THE DANGEROUS ONE. Its union still matched country_code
IDN, so it did not print empty — it printed a PLAUSIBLE packet silently missing
every Oceania group. An empty packet is noticed on the first page; a packet
that is quietly 40% short is not. Same shape as the 514-of-1009 join gospel
measured today: a result that returns HALF looks like a smaller dataset, while
one that returns NOTHING gets caught immediately.

Verified after the fix: west-africa resolves its wagf_block row, and
oceania-indonesia now finds BOTH branches rather than only IDN.

Found while answering dx-shepherd's question about 9 selectorless zones. Those
9 turned out not to be in clusters.json at all — they are grouping/colour keys
in zone-regions.json and groupings.json, so no packet can be asked to print
them. All 27 zones that ARE printable have selectors; the defect was never a
missing selector, it was a missing FIELD.
2026-09-28 13:56:21 +00:00
Doxa Maps
926cc1fe89 Measure the zone gap in the shipped data, not only in the fixture
Every assertion here ran against a fixture. That proves the module behaves and
proves the fixture models a gap; it does not prove the gap still exists in the
data we ship. So the negative control could have stayed green forever while the
bucket quietly became dead code — or while a fifth zone fell out of the region
tree and nobody heard. Evidence that lives somewhere other than the thing it
describes has already stopped being evidence, and looks identical.

Three arms now read the real files. The gap between the region tree's source and
the joinable set is measured, and it is exactly the four this check names:
guizhou, xinjiang, bihar, madhya-pradesh. xinjiang is asserted specifically —
joinable in admin-to-list, absent from zone-regions, so its hundred groups are
reachable only through the bucket.

Two of the arms fail ON PURPOSE rather than going quiet. If every joinable zone
becomes filed, the check refuses to pass and says to re-derive the bucket or
delete it with its component, because an arm guarding nothing is not a pass. If
the gap moves in either direction it prints both sets and names the zone that
changed: one added is groups that just became unreachable, one removed means the
fixture is stale.

Both were falsified before being trusted. Filing all four turns the inert guard
red; dropping gujarat from the region tree turns the drift arm red with gujarat
named. Data restored, clean diff.

This deliberately does not resolve the three disagreeing lists — 33 filed, 12
joinable, 14 grouped, disagreeing in both directions. That is a data-ownership
question, and deciding it here would be the silent join we have refused. The
check only records the difference so it cannot drift unannounced.
2026-09-28 13:56:06 +00:00
Doxa Maps
c86aa72bd2 check:describe-group runs on demand 2026-09-28 13:56:06 +00:00
Doxa Maps
98065a34ab The packet's generated description, and the language name it prints was index-ordered
describeGroup(group, locale, opts) -> one localized sentence pair for a people
group's profile row: where they live, what they speak, and how reached they are.
Pure, exported, no refs or fetch; strings and the country resolver are injected.

The level is phrased from GSEC, a number, never from GSECDescription, which is
English prose the API hands us -- interpolating it would leave the widest column
of every non-English packet in English.

THE LANGUAGE FIELD WAS BOTH WRONG FIELDS AT ONCE.

primary_language is in normalize.js's NESTED_FIELDS, so the API's { value, label }
is flattened to its VALUE: an ISO 639-3 code. Reading it prints "and speak abf".
The name is in primary_language_label, and it arrives INDEX-ORDERED -- "Arabic,
Shihhi" for what a reader calls "Shihhi Arabic". 725 of the 12,379 rows carry an
inverted label, one row in seventeen.

The un-inversion is not a second copy: useLanguageFamilyLegendData already built
one to make a lookup key, so the split moves to library/utils/languageLabel.js and
both call it. Doing that surfaced why the two uses are not the same use -- 13 real
names carry a comma INSIDE a parenthetical ("Boano (Indonesia, Central Sulawesi)"),
and a naive split reverses those into "Central Sulawesi) Boano (Indonesia". As a
lookup key that garbage simply misses and the next candidate is tried; printed in a
sentence there is no such safety net. The shared split is top-level-only, and a
trailing parenthetical is hoisted past the reversal so "Naga, Tangkhul (India)"
reads "Tangkhul Naga (India)".

The legend's own behaviour is unchanged, checked rather than assumed: old and new
lookup compared over all 3,498 labels in langFamilyByLanguage plus every
LanguageName in the dataset, 0 differences.

countryName.js is NOT imported. It resolves only through the Vite '@map/' alias and
imports two JSON files without an import attribute -- fine in a bundle, fatal to a
node gate. Importing it would make this module unloadable by the test that proves it
right in eleven languages, so the renderer passes it in, and a missing injection
degrades down countryName.js's own ladder (localized -> English label -> alpha-3)
rather than blanking the clause.

check-describe-group asserts every GSEC level, each missing field, both locales,
truncation, the inverted label, the parenthetical that must not be shredded, and
that a bare language code is dropped rather than printed. Negative-tested: reading
primary_language, skipping the un-inversion, a naive split, and dropping the
parenthetical hoist each turn it red; restored, it is green.

Not yet wired into the renderer and not yet in gates:pre -- register with
"check:describe-group": "node internal/check-describe-group.mjs".
2026-09-28 13:56:06 +00:00
Doxa Maps
a7bdb7b439 README: a bottom line up front under the scripture
Why dx-maps exists, how it was made, the dashboard where everything comes together and what
inspired it, and one line on what each map is for.
2026-09-28 13:56:06 +00:00
Doxa Maps
4a80af39b6 Pin what the vertical legend's bucket buys, and register the check
Six more assertions, and a package.json entry so gates:pre actually collects
this instead of it only ever passing when I run it by hand.

Two facts the legend contract left implicit, both of them the quiet-wrong-answer
shape rather than a crash:

Ticking every REGION does not select every ZONE. The four unfiled zones have no
region by definition, so a reader who selects all seven regions still does not
have xinjiang and its hundred groups — and nothing on screen says so, the total
is just smaller. The bucket makes those zones reachable; it does not make them
reachable by a region sweep. A select-all must therefore sweep zones, not
regions, and this fails if one ever sweeps regions.

A zone whose members do not resolve must render EMPTY, not disappear. Members
resolve for sixteen of thirty-three today; five await a field and nine have no
selector anywhere. A tree that hides what it cannot join tells the reader the
zone does not exist, when the truth is only that we cannot currently join it.
Presence is by id, never by member count.

Both arms were falsified before being trusted: filtering zero-member children,
and mislabelling the bucket as an ordinary region row, each turn four of these
green. The negative control still carries the rest — it asserts the unaugmented
tree really does drop xinjiang, so the bucket is demonstrably load-bearing
rather than decoration.
2026-09-28 13:56:05 +00:00
Doxa Maps
363aaeb11d Land the vertical legend's check with the component
The thirteen assertions were in a _qa-cycle probe, which this repo gitignores.
They ran green on my disk and would not have landed with the component, so the
code was about to ship untested while I reported the tests as evidence for it.
studio caught it while staging.

That is the failure this very file guards against: a green result measured
somewhere other than where it matters. A probe proves something once; a
registered check keeps proving it.

Same assertions, registered path, relative import corrected. It needs a
package.json entry to join gates:post, which is studio's to add.
2026-09-28 13:55:52 +00:00
Doxa Maps
9500375be8 Vertical legend emits a zone id, and cannot drop a zone
Reworked to the consuming dashboard's contract. It emits a zone id or null on
zone-change, not a selector: an id is serialisable into a query parameter, a
profile config and a deep link, which is the chain that dashboard already
resolves its list from, and a predicate is none of those. It also keeps the
component promotable -- a selector would have to know what it selects over, and
the consumer holds four different row shapes across its tabs. The join stays in
one place, where the diacritic folding and the province bridge already are.

A leaf emits its zone id; a region row emits the region's id and the consumer
expands it, because that is where the zone data lives.

And it cannot silently drop a zone. Four joinable zones are absent from the
region file today, one of them the consumer's largest at a hundred groups, so
grouping by region loses them with no error and no empty state -- just a shorter
tree. Given the full zone set, the legend renders the difference in an explicit
bucket, each one selectable. That makes the drop structurally impossible rather
than something to keep checking. Omit the set and the tree is untouched, so the
guard is additive.

Its check includes a negative control that the unaugmented tree really does omit
the largest zone, so the bucket is demonstrably not decoration.
2026-09-28 13:55:52 +00:00
Doxa Maps
ff7b2daf7a A vertical legend that selects a list, for any dashboard to filter by
The Gospel x Internet dashboard needs the Driver's vertical legend on the side
to select a people-group list and filter its groups by it. This is that legend,
wired as a selector.

It WRAPS the shared semantic tree legend rather than replacing it. That
component is already the vertical legend and already drives the 26-zone
tapestry, so every fix it receives -- theme-awareness, the progress bar, the
mobile sheet path, scroll-to-selected -- keeps applying. A second widget would
have to re-earn all of it.

It emits the SELECTION, not resolved group ids, and that is ownership rather
than taste. Resolving a zone to ids means joining zone membership against the
group rows, and that join is not trivial: the membership keys are ASCII while
the province data carries diacritics, so a naive join silently loses hundreds of
groups. A legend that resolved ids would hold a second copy of that join and
the copies would drift -- appearing as a wrong count, which nobody notices. The
consumer already holds the rows and the join.

A region row selects the union of its zones, and both a leaf and a parent return
arrays, so a single zone is not a special case the consumer will eventually get
wrong. A cleared selection is representable, because a dashboard must be able to
drop its filter.

The selection rule sits in its own module with its own check, including a
negative control that a parent does not select only itself -- which would filter
to nothing while looking like it had worked.

The tree is a prop. Its builder lives in the plugin that owns the zoning, and a
cross-plugin import is barred, so promoting that helper to the library is a
separate coordinated move.
2026-09-28 13:55:52 +00:00
Doxa Maps
9d13bef3fb Two labels resolved once and stayed in the boot language
upg100 measured both, and both were created by today's i18n fixes — mine and the batch's. A computed
only re-runs when a REACTIVE dependency changes; tStatic reads a plain window global. So these resolved
at first render and never moved again, while every label around them followed the switch.

  · big-picture.vue — heading depended on config.value.title, which does not change when the host
    changes language. It now reads hostLang, which is reactive and already drives setRuntimeLocale, and
    PASSES it to tStatic so the right catalogue is read. useI18n is not available here: this bundle
    installs no i18n, which is why the whole element reads through tStatic.
  · DashboardResearchFilters.vue — the live-search chip, same shape. research-dashboard DOES install
    i18n (index.js:83), so this takes vue-i18n's reactive locale ref, reads it to re-run and passes it
    for the lookup.

Both land on check-frozen-labels' documented clean shape — an explicit locale argument — rather than
hiding tStatic behind a helper that would satisfy the gate while leaving the label frozen.

The heading also moved BELOW hostLang, so the dependency it now relies on is declared above it rather
than four hundred lines later.

Verified: check:sfc-parse PASS; check-frozen-labels (studio/landing58) reports exactly these two sites
today, and the shape they move to is its own "pinned" self-test case.
2026-09-28 13:55:52 +00:00
Doxa Maps
b5178381d2 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: all-people-groups
big-picture
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-maps-spa
doxa-research-map
doxa-simple-map
gospel-internet
gospel-resources
key-cities
my-upg-100-list
people-groups-grid
plugin-studio
pplr-data-maps
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle
upg-neighbor
wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 13:48:36 +00:00
Doxa Maps
87c562d553 Report suppressed strings per island, largest first
The deliberate bucket was one flat number, and it is the one place where
over-claiming costs nothing and shows nothing: a marker covering seventy-odd
lines and a one-line span read identically in a total. gospel went looking for
an over-broad island on their own card, suspected it, and found twenty-seven
strings behind a single attribute -- including the prose that says the figures
mean "the pools searched" rather than "nothing exists".

Each suppressed string now names the island that suppressed it, and the report
groups by island, largest first, in strings and in lines. A large island is not
automatically wrong, but it is the shape an over-broad one has, and nobody
should have to suspect it first.

It finds the one gospel measured by hand, and a larger one they had not: eighty
-four lines and twenty strings behind another single attribute in the same file.

Inheritance down the subtree is unchanged; it is correct. What changes is that
the output can now be read.
2026-09-28 13:37:44 +00:00
Doxa Maps
6c3b0a6f7a plugin-designer skill links its new i18n reference 2026-09-28 13:37:44 +00:00