Commit graph

2542 commits

Author SHA1 Message Date
Doxa Maps
3c3bde71e4 Big Picture: the share button fits its 38px header instead of overflowing 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 Driver: the share button at the top right of the Big Picture dashboard is too large. Measured: the
research-dashboard topbar is 38px tall, but ShareButton is the map toolbar's 40px round button (36px under
768px) plus a 2px rainbow ring, so 44px, taller than the bar it sits in, and clipped top and bottom.

In this bar only (.rd-topbar), the button is 28px and its icon 15px: with the ring it is 32px, the height of the
bar's own search box, centred with 5px above and below. Measured after, at 1440 and 390, on the Big Picture
(dashboard-plus-grid) and research-dashboard leaves: the bar stays 38px; the title, search box and Align button
keep their sizes. Map toolbars keep their 40px share button. Rebuilt research-dashboard + dashboard-plus-grid,
tree regenerated.
2026-09-28 15:27:03 +00:00
Doxa Maps
246b3d46e1 Rebuild country-posters, 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 15:18:35 +00:00
Doxa Maps
736c418284 Country Posters: Export poster is a toolbar button every reader sees, not a drawer item behind a hidden opener 2026-09-28 15:18:17 +00:00
Doxa Maps
2b164954e4 Build the region-to-zone tree the legend renders, with region ids namespaced
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Pure, and it takes the zone tables as arguments rather than importing them: they
belong to another plugin and a relative import into its folder is refused. They
reach the browser as a published table instead, so the caller fetches and passes
them in. That also makes this testable without fixture files.

Region ids carry a prefix, and that is load-bearing rather than tidy. The legend
emits one bare string on one channel — a leaf emits its zone id, a region row
emits the region's — and the two id spaces overlap: of 33 zone ids and 7 region
ids the intersection is exactly "europe", whose region is itself. Un-namespaced,
that string cannot say whether it means the region or the leaf. It is invisible
only because region europe currently holds exactly one zone, which is a fact
about today's data rather than a property of the design; the day a second
European zone is filed, clicking the region would filter to one zone instead of
several with no error. The component needs no change — it emits whatever id the
tree gives it.

There is deliberately no "select all" case. Zones absent from the region table
have no region, so no expansion over regions can reach them; a sweep has to
enumerate zones, and offering a region-based all would quietly return a subset.
2026-09-28 15:13:45 +00:00
Doxa Maps
fe54c303e0 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
country-posters
dashboard-plus-grid
doxa-maps-spa
doxa-research-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 15:08:43 +00:00
Doxa Maps
0bf8b88ff0 Regenerate the library index: the three packet charts and the items that landed uncatalogued 2026-09-28 15:01:26 +00:00
Doxa Maps
91ae35195c check:describe-group's catalogue arm asserts each GSEC level's own sentence, not only a non-empty string
By the UPG-100 owner. A nested imb_gsec (NaN level) dropped the level sentence while the
where-clause still rendered, and a non-empty check passed that. Each level's output must now be
longer than the where-clause alone.
2026-09-28 15:01:20 +00:00
Doxa Maps
a764a89e3b packet fixtures carry the description's two fields
The four packet fixtures predate primary_language_label and imb_gsec, which the
packet fetch has carried since L59. describeGroup degrades by DROPPING CLAUSES
rather than blanking — sr's deliberate and correct design — so on fixture data
it renders "The X live in Y." with no language and no reachedness sentence, and
does so without error.

That makes the OFFLINE render (PACKET_RECORDS=fixtures/…, which is how rega
verified NEEDS-EYES row 83) diverge from the live browser packet in exactly the
new column: the verification path would pass on something the product does not
print. Same Node-vs-browser divergence as the %evangelical page, pointing the
other way.

Regenerated from the live API (2,107 rows), matched by slug: 21/21, 20/20, 1/1,
43/43. Row counts and order are unchanged, so the 20/21/1/43 edge cases keep
their meaning and check-packet-render's `ceil(cards / PER_PAGE)` arithmetic is
untouched.

★ STORED POST-normalizeRecords, NOT AS THE API RETURNS IT. Both fields arrive
nested — primary_language is { value:'abf', label:'Abai Sungai' } and imb_gsec
is { value:'1', label:'Less than 2% Evangelical…' }. A fixture holding the raw
shape would have faithfully reproduced the bug that 3b644893 fixes, so the
fixture would look right and the packet still print half a description.

HONEST LIMIT: the DOXA set is 225 at GSEC 0, 1,852 at 1, 23 at 2, 4 at 4, 1 at
6, 2 null — so fixtures drawn from real data exercise levels 0 and 1 only, and
levels 3 and 5 do not occur at all. Per-level coverage belongs in the unit test
with synthetic rows, which is where it already is; the fixtures' job is to match
what the fetch delivers.
2026-09-28 15:01:20 +00:00
Doxa Maps
628edf4457 The three packet charts, and two constraints that live in a prop
VisionFunnel, LifeCalendar and ShareCalendar, all props-driven, no hardcoded
list, print first: fixed px on the packet's 816x1056 canvas, nothing revealed by
hovering, and the packet's own ink rather than a new palette, so a reader cannot
tell which page came from the component and which from the renderer.

The funnel draws steps as stacked trapezoids and overlays as brackets BESIDE
them, never inside. Being inside a step is precisely the claim an overlay cannot
make: 362 of DOXA's 2,107 groups are not frontier at all. It asks
overlayPlacement whether an overlay is a slice rather than deciding, and prints
each overlay's parts as text, because a bracket nobody can hover over explains
nothing. Given steps that do not nest, or that mix publishers, it refuses and
prints why instead of drawing a plausible picture.

It is also not mirrored under RTL, which is worth saying because the obvious
reading of "mirror it" is wrong here: the triangle is vertically directional, so
a horizontal flip reverses nothing meaningful and inverts any text inside it.
What mirrors is the label and bracket side, so those use logical properties.

Two decisions now live in prop declarations, and a default is how either dies
quietly. ShareCalendar refuses to name its own metric: the shipped figure is the
evangelical share while the brief says "% Christian", and one default would
reintroduce the confusion this project already retracted a P0 over, with nothing
looking wrong. LifeCalendar's anchor defaults to null because the packet prints
for a reader we do not know and shading their life is a guess presented as fact.
Both are asserted through the SFC and JS parsers rather than a grep, since a
regex cannot tokenise JavaScript, and a sibling prop that does carry a default
is the negative control proving the arm is not vacuous. Adding
default: '% Christian' turns it red, which I checked rather than assumed.
2026-09-28 15:01:19 +00:00
Doxa Maps
4f73d83438 One scatter implementation, and the gate that could have caught the bar
packet-render's lifeGrid already held a carefully reasoned shading: deterministic
so a packet reprints identically, and scattered because a contiguous run of 32
fills most of row 1 of 52 and prints as a solid bar across the header, which is
the ordinary chart the life grid exists to avoid. The message is rarity, and the
eye has to hunt for the 32 among 5,200.

That logic is now in packetCharts.js and packet-render imports it, so the
printed packet and the reusable chart cannot drift into two answers. The move is
behaviour-preserving: I ran the extracted function against the pre-refactor code
over 49 seed and count combinations with zero differences. That probe was
one-off, so the durable half is a golden fingerprint here — the exact cells for
one seed. If the algorithm is ever tidied, those change and every packet already
printed stops being reproducible.

The assertion that matters most is the one that did not exist when the bar
shipped. The existing check asserts the shaded COUNT is between 0 and 5,200,
which is equally true of a bar and of a scatter: a count cannot distinguish a
distribution, so no amount of that gate passing was ever evidence. rega's eyes
caught it, and nothing else could have. Now the spread is asserted — 32 cells
landing on 27 different rows — with the contiguous block as the negative
control, so the arm can fail in exactly the shape it exists to reject.

The rounding flag upg100 asked to keep reachable stays on the return value, so
the packet can print the honest caption when a share is too small to fill one
cell rather than inking one and asserting "some".
2026-09-28 15:01:19 +00:00
Doxa Maps
fd7c65e591 Collect the packet-charts check, not merely register it
The previous commit added the script and its description but did not put it in
gates:pre, so it passed only when run by hand. That is the exact failure this
project has been naming all week, committed by the person who wrote the gate for
it. gates:pre now reads PASS 22 of 22 and names check:packet-charts at 781ms in
its own output.
2026-09-28 15:01:19 +00:00
Doxa Maps
8c84838c78 Packet chart arithmetic, and the three ways it was quietly wrong
These numbers print. A packet is handed to a reader on paper: no hover, no
tooltip, no correction, no redeploy. So the arithmetic lives in its own module
and is asserted against the shipped data rather than a fixture.

DOXA IS NOT A SLICE. Of its 2,107 groups, 2,082 are unreached and 1,745 are
frontier, so 25 are not unreached at all and 362 are not frontier. That is one
group in six. A chart drawing DOXA as a fourth band nested inside frontier
misstates 17.2% of the list with a clean shape and no error of any kind, which
is why steps and overlays are different props and an overlay reports whether it
is a slice rather than being assumed to be one. upg100 asked for the numbers to
reach the caption as text too, because a printed band nobody can hover over
explains nothing.

Three bugs I wrote, each caught by an assertion rather than by review:

The publisher warning sat in the wrong function. I put "Joshua Project counts
16,426 where IMB counts 12,379" into the nesting error, and the test proved the
claim false: 16,426 on top of 12,379 is perfectly monotonic. The two are not the
same kind of count, and no comparison of magnitudes can see that, so it needed a
separate check reading what the numbers are OF. An error message naming a cause
its own code cannot detect sends the next reader to look for the wrong thing.

A percent read as a fraction filled 3,189 cells of 5,200 instead of 32. My
heuristic was "above 1 means percent", and pctEvangelical is 0.6133, which is a
valid fraction and a valid percent with nothing in the value to separate them.
The refusal I added catches a percent of 1 or more and provably cannot catch
ours, and every share this packet prints is below 1% because the subject is
unreached peoples. So the guard is not the protection, the name is:
fractionFromPercent states the unit in the source line. Both halves are
asserted, including the half that does not work, because a guard believed to
cover more than it does is the worse failure.

And the rounding flags were untested until the test used values that could
reach them. 0.6133% of 5,200 is 31.9 cells; the reader sees 32 and cannot see
31.9, so the exact value goes in the caption and a share too small to fill one
cell is flagged rather than drawn as one.
2026-09-28 15:01:19 +00:00
Doxa Maps
0f0a01372d check:one-scroll-container joins gates:pre now that it is green
The gallery pins plugins with min-height:0 (sr) and gospel-internet's .gb moved its floor to :host
(on main), so no plugin asserts a floor a short pane could not hold. From here it guards against
the second scrollbar coming back.
2026-09-28 15:01:19 +00:00
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