Commit graph

1191 commits

Author SHA1 Message Date
Doxa Maps
aeb7f03abd UPG Neighbor: green dots for every reached group, and each side of a neighbor pair opens the other 2026-09-28 17:40:34 +00:00
Doxa Maps
3d7ad038a9 A relative share link works when you click it, which is why nobody caught it
DRV1650-SR-EMBED-URL-RUNTIME-ORIGIN. The Driver copied
`research-map-plus-grid/research-map-plus-grid/index.html` from a page at
http://127.0.0.1:5192/, a link that works on no other site.

resolveShareUrl's tier 1 returns its input VERBATIM, correctly: a host handing over a
whole url has already decided. So a composite that BUILDS a tier-1 url takes on tier 2's
job, and both of mine wrote the same thing:

    base ? base + '/' + leaf : leaf

On any host where the tree root cannot be derived from the script url — a dev server, an
ESM build, a tree served from a path that does not match `<name>/<name>.js` — base is ''
and the else branch hands tier 1 a bare relative path, which it then returns unresolved.

★ IT LOOKS RIGHT IN THE ONE PLACE ANYONE CHECKS. A relative share link WORKS WHEN
CLICKED, because the browser resolves it against the page you copied it from. It is the
EMBED that breaks — an <iframe src="…"> on a partner's page resolves against THEIRS — so
the failure is invisible exactly where it is tested and visible only where it ships.
research-map-plus-grid.vue:174 even documents the requirement ("It MUST be absolute,
which is the non-obvious part") four lines above the branch that violates it.

absoluteEmbedUrl() is tier 2's resolution made available to tier-1 builders: the
configured base when known, otherwise resolved against document.baseURI, which yields the
runtime origin. Never relative in a browser. The ladder is unchanged; only its last rung
is.

The gate has both arms, because either alone would rot: the RESOLVER must be absolute
with no base (the failing case, not the passing one), and no plugin may rebuild the
`: leaf` fallback. Negative-tested: removing the origin fallback, and re-introducing the
ternary in a plugin, each turn it red.

"Copy link didn't work" is most likely the same defect rather than a second one —
ShareButton's copy already falls back from navigator.clipboard to execCommand for
insecure contexts, so the copy almost certainly succeeded and produced a useless URL. If
it still fails after this, it is a separate bug and needs a browser to see.
2026-09-28 17:40:34 +00:00
Doxa Maps
5ab4af2e44 Five plugins were silently monolingual, because the bootstrap was copied sixteen times
Goal (c), and it turned into a goal (d) fix. Every profile element is
defineCustomElement(ProfileLoader, { configureApp }), and the configureApp has four
steps. The fourth, app.provide('appI18n', i18n), is the one that gets dropped.

ProfileLoader injects that instance (ProfileLoader.vue:176) to drive its locale from
profile-config. Without the provide the watcher holds null and the drive NEVER RUNS --
while setRuntimeLocale() on the next line still fires, so tStatic strings DO follow the
host. The element renders a convincing MIXTURE of the reader's language and English, and
nothing throws, because the t() shim falls back to its key. It reads as "a few strings
not translated yet".

SHIPPING THAT TODAY: country-posters, key-cities, gospel-resources, cities-internet and
my-upg-100-list. regions-plugin censused all 19 entry files independently and found the
same five and no sixth. The measurement is regions-dashboard's, from when they hit it:
a Spanish catalogue present, es set, and every t() string still English.

All sixteen entries now call library/elements/defineMapElement.js, so the five are fixed
by construction rather than by five separate edits. Behaviour is otherwise identical --
same options, same element names, same per-plugin extras. wall-of-people-groups needed
the helper to hand its pinia back, because it takes a store off its own instance and a
helper that hid pinia would have given it a second one: two stores and a silent state
split.

★ AND THE CENSUS THAT RECOMMENDED THIS SAW FOUR OF SIXTEEN. My detector hashed function
BODIES; eleven sites are not `function buildXElement()` at all, they are
`const El = defineCustomElement(...)`. I wrote "every number here is a lower bound" in
the census as a caveat and it was the headline within the hour -- a 4x undercount of the
item it recommended. So this gate greps for the SHAPE, not for a known list: a check
written around the four that matched would have passed the twelve that did not.

check-element-bootstrap has two arms. No entry may build a profile element by hand, and
the helper itself must provide appI18n -- PLANTED as a negative case, so the regression
cannot return by editing library instead of a plugin. It also fails if it finds fewer
than ten entries, because a detection that stops matching reports the same green as a
tree with no problems.

THE PLANT CAUGHT TWO BUGS IN THIS GATE BEFORE IT CAUGHT ANYTHING ELSE. The predicate
matched the helper's DOCSTRING, so it would have passed a helper that only mentions the
provide in prose -- comments are stripped now, because a check that reads comments is
reading the author's intentions rather than the program. And the plant itself used /m
instead of /gm, so it deleted the documentation and left the code, then reported that it
could not detect an absence that was not there.
2026-09-28 17:40:34 +00:00
Doxa Maps
f9b4fed2cf Rudder: plain streaming by default; sentence detection only while the robot voice is on
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Dictation was "doing better before" sentence detection was switched on for the robot voice: with
asr.endpointing.enable the server ends an utterance at every ~0.8 s pause and the next one starts fresh.

- Default (robot voice off): the speech server runs exactly as it shipped — plain streaming, no pause cutting.
- Robot voice on (High performance mode): the server is restarted with sentence detection, which speaking
  sentence by sentence needs; turning the robot off restarts it without. A switch made mid-recording waits
  until Stop, so a recording is never cut. A planned restart is not reported as a crash.
- Robot voice pipelined: the next sentence is synthesised while the current one plays, so the ~1 s gap
  between spoken sentences is gone (same work, overlapped).
- Verified against a stand-in speech server (go -race): startup without the flag; robot on → restart with
  it; robot off while recording → deferred; after Stop → restart without; 0 data races.
2026-09-28 17:27:13 +00:00
Doxa Maps
21ebb4f264 Stop the ring legend pushing the centered-set chart off the page
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The legend was stacked below the chart in a centred column. On its own it is
taller than the pane — a 360px card with a nine-cell grid and four footer lines —
so the figure was pushed out of view entirely and the panel overflowed its host:
the legend covered the row beneath it and the caption drew over the page tabs.
The chart was not visible at all at 1440x900.

It is now a row: figure left, legend right, which is also the side placement the
legend was asked for. Every flex child takes min-height and min-width 0, because
a flex item's default minimum is its content size — without that an item never
shrinks below its natural height and the container overflows however little room
it has. That default is what turned a tall card into an unbounded one. Under
900px it returns to a column and scrolls rather than clipping.

Both charts now clear the centre tab strip, which is absolutely positioned near
the top and was covering the top of the bounded set's dots and all of the
centered set's caption. The bounded set also takes overflow:hidden, so a tall
caption can never do to it what the legend did to its neighbour.

Nothing about the counts changes. The page reads 2,082 placed where a slug-only
join predicts 2,074: the difference is the name-and-country fallback recovering
eight of the sixteen groups the neighbour table carries no slug for, and both
readings still sum to the full 2,107.
2026-09-28 17:11:13 +00:00
Doxa Maps
00e505404c The gallery pane was ported from a working one, two lines out of three
rega's census refutes the min-height diagnosis I gave, and the arithmetic is what
settles it. Inside section.dms-pane the CONTENT measures exactly the viewport: 900 in a
794px pane at 1440x900, 844 in a 712px pane at 390x844. The overflow is not "about the
DX-life header", it IS the header -- 106 and 132 px -- and it happens on 12 of 16 tabs
INCLUDING plugins that assert no floor of their own, which is what proves the floor is
not theirs.

library/ProfileLoader.vue:336 floors EVERY profile at
`min-height: var(--profile-min-h, 100dvh)`. That measures against the VIEWPORT while the
pane measures against its HOST, so wherever anything sits above the gallery the profile
is taller than the pane by exactly that chrome, and .dms-pane's overflow-y:auto scrolls
the whole plugin -- tab strip included. ProfileLoader documents the trap and documents
the only lever: the floor is inside the child's SHADOW ROOT, where no host-page selector
reaches, so an inherited custom property is all a host has. `--profile-min-h: 0`.

IT IS TWO THIRDS OF A FIX, NOT A NEW DEFECT. .vtl-host and plugin-studio's .ps-pane
already set it. .dms-pane was copied from .vtl-host -- its own comment says "porting the
same proven shape" -- and took the first two lines: fill the host, own the overflow. The
third, release the child's viewport floor, is the one that makes the other two safe.

So the gate now asserts the THREE AS ONE SHAPE: a rule that is absolutely-filling AND
scrolling must release the floor. It also FAILS if it cannot find such a rule at all,
because a gate that silently stops finding its subject is the failure I spent this
morning removing from check:i18n-keys-exist -- a check that matches nothing reports the
same green as a check that matches everything.

Negative-tested: removing the opt-out from either pane fails it, and changing the pane's
shape so the gate cannot recognise it fails it too rather than passing blind.

MY DIAGNOSIS WAS SECOND-ORDER AND I SHOULD SAY SO PLAINLY. The min-height floors were
real and worth fixing, but they only bite when a plugin's own floor exceeds the pane.
This bites on every tab at every size. I reasoned from source to the first mechanism
that explained the one symptom I had been shown, and the tall-desktop arm -- the one I
asked rega to run precisely because it would refute me -- is what separated them.
2026-09-28 16:49:01 +00:00
Doxa Maps
c7edd82f9c Promote the set layouts to library, as one implementation
gospel's boundedSet and centeredSet move to library/components/charts, and every
importer follows in the same commit: gospel-internet's two components and the
gate. One implementation, no window in which someone edits the wrong copy.
gospel chose this over copying, for the reason the shared scatter already showed.

Two print-safe components come with them, adapted from gospel's rather than
written fresh: theirs fetch and hold board state, these take joined groups and
every label as props, so the packet and the board draw the same figure from the
same code. gospel's already inked their tallies and used no hover, so the
adaptation is the data path and the labels, not the drawing.

The labels had to change, and not for style: gospel's read T('gospelInternet.…'),
and a gospelInternet-namespaced key cannot resolve for another area's consumer,
so the packet would have rendered the English fallback in every locale. Labels
are props now.

Three invariants gospel gave me that the comments did not carry are pinned here:

No seed prop. Position is hashed from the group's own id, so a group lands in the
same place on every render, forever — two printings of one packet are identical
and a reader comparing sheets sees real movement rather than reshuffling. I had
specced a seed; it would have silently rescrambled every dot while the data was
unchanged, which on paper cannot be corrected. Asserted through the parser, and
adding one back turns it red.

Every ring is listed, empty ones included. Under a filter several of the nine go
to zero, and a decoder that omits its empty rows stops being a decoder: a reader
cannot tell "none here" from "this ring does not exist".

And an arrow is a claim about a relationship, so a group with no recorded
neighbour gets none rather than a default one toward the centre.

The unknown-is-counted rule is now in the module header where the next reader
meets it first, at gospel's request: a printed page has no hover, so a category
the reader cannot see is one they will assume is zero.
2026-09-28 16:49:01 +00:00
Doxa Maps
009d73b44e The share button had to look smaller; its tap target did not have to shrink with it
rega's 3c3bde71 fixed a real defect: ShareButton is the map toolbar's 40px round
control plus a 2px ring, so it measured 44px inside a 38px bar and overflowed it. At
28px + ring it matches the search box, which is what the Driver asked for.

The side effect is that the TAP TARGET went to 32px, and this repo states its own floor
twice -- DataTable.vue:294, "44px is the accessible tap target; anything smaller is a
miss on a phone", and LegendDesktop.vue:124 sizes rows to keep it. Nothing in the change
said otherwise; the target simply followed the drawing, which is what tap targets do
unless someone stops them.

A transparent box centred on the button restores it without moving a pixel: absolutely
positioned so it changes no layout, and part of the button so it takes the same clicks.
The visual stays exactly as the Driver approved.

44 WIDE, 38 TALL, AND THE 6px IS DELIBERATE. The bar is 38px, so a 44px-tall target
would hang 3px over the filters row and start taking taps meant for a filter -- one miss
traded for another. 44x38 is the largest target that costs nothing else. Closing the
last 6px needs a taller bar, and the Driver has just decided that axis in the other
direction, so it is asked rather than assumed.
2026-09-28 16:49:01 +00:00
Doxa Maps
379509c5c2 The factory stopped being a one-liner, so a whole bundle stopped being looked at
check:i18n-keys-exist reported PASS over two planted poison keys in gospel-internet,
three rounds running, and over 12 real keys its new panels name that exist in no
locale. Reproduced before changing anything: with both poisons on disk the gate found
1 missing key; it now finds 15 on that same tree — the 2 poisons plus the 12.

TWO CAUSES, EITHER ALONE ENOUGH TO HIDE EVERYTHING.

factoriesIn() demanded the exact shape `export function useX() { return useOptionalT('ns') }`.
useGiT is no longer that. It became a WRAPPER -- `const t = useOptionalT()` and a
returned arrow adding an English-fallback argument -- which is ordinary refactoring and
moved the bundle outside this gate: useGiT was not a factory, so `const T = useGiT()`
was not an alias, so every T('…') was not a call site. Discovery now also accepts an
exported function whose body BUILDS a translator, reading a namespace only when the
call states one literally.

And when a namespace could not be read, `if (ns === null) continue` dropped the binding
in silence. That is the line that turned "I cannot see this" into "there is nothing to
see", and it is why the bundle passed by having nothing looked at. Note what it is NOT:
useOptionalT('') gives ns === '', an author DECLARING no namespace, which was already
handled and counted. Empty is a decision; null was this gate's own ignorance.

Such a binding is now registered for the FULL-KEY pass anyway -- a fully dotted key is
checkable whatever the namespace is, which is what catches both poisons -- while the
BARE calls it genuinely cannot qualify are counted, listed by binding, and FAIL. The
gate states which translators it could not resolve instead of scoring them clean.

Both poisons are pinned as permanent fixtures HERE rather than left in gospel's tree,
so the proof does not depend on another agent keeping deliberately-broken code on main.
The fixture asserts the four things that were independently broken: a wrapper factory is
discovered, no namespace is guessed for one that states none, its binding still reaches
the full-key pass, and a bare key behind it is recorded rather than skipped.

Negative-tested: restoring the silent `continue`, or narrowing discovery back to
one-liners, each fails the self-test; restored, it passes.

NOT wired into gates:pre and left on demand, as it already was. It reports 13 real
missing keys on today's main -- 12 of gospel's panel keys plus a pre-existing
legend.noList -- and those are their owners' to add, not this gate's to hide.
2026-09-28 16:49:01 +00:00
Doxa Maps
e1d0bc203f Read the zone tables from the asset pool, and name zones from their slugs
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
L62 moved zone-regions.json and groupings.json into data/tables/, so the tree
copies them into the asset pool and both plugins read one file. This fetches
them rather than importing: a relative import into another plugin's folder is
refused, and a per-plugin copy is the drift the single-source move exists to
prevent.

groupings.label is a definition, not a name, and using it as one would have made
the legend unreadable. Measured on the published file, the labels run from 7 to
206 characters and the long ones are the zone's member list — Tibet's names ten
provinces and three countries. Exactly one of the fourteen is a plain name. So
the tree names a zone from its slug, which is uniform by construction, and the
label is carried separately as the definition it actually is, for somewhere with
room to say what a zone contains.

The tree is built and counted; nothing filters on it yet. Mapping a group to a
zone still runs through the older partition, which places 69 groups in the wrong
printable zone, and that is the Driver's open question.
2026-09-28 16:30:40 +00:00
Doxa Maps
5e0295add8 Punctuate the set-chart counts in one place, not the reader's browser 2026-09-28 16:30:40 +00:00
Doxa Maps
04ac124cb7 Mount the ring legend, and dim the rings it is not talking about 2026-09-28 16:30:40 +00:00
Doxa Maps
f49c81cec6 Gospel × Internet: a ring legend that decodes the centered-set target, every ring named, counted and explained 2026-09-28 16:30:40 +00:00
Doxa Maps
0b81a1636d Put the cross at the centre of the bounded set 2026-09-28 16:30:39 +00:00
Doxa Maps
39cba76c75 UPG Neighbor: each row carries its DX slug, and every DX group ships, so a DX page joins by the key it holds
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 16:28:04 +00:00
Doxa Maps
a0aa4df171 Stop the dimmed pins reading as white rings, and lift the chooser map
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
rega shot the hover dim at 1440 and called the non-hovered pins pale rings on
the pastel fills. That is what they were, and the cause is a property that was
never set: circle-opacity dims the FILL only, and circle-stroke-opacity is a
separate property defaulting to 1. So a pin dimmed to 0.25 kept a fully opaque
1px white stroke, and at the dimmed radius that ring is most of the pin.

Worth saying plainly, because the obvious fix was to keep the stroke opaque
while dimming the fill: that was already the behaviour, and it was the cause
rather than the cure. The stroke now dims with the fill, so a non-hovered pin
recedes as a dot instead of becoming an outline. The fill dims less as well,
0.25 to 0.45, and the dimmed radius goes 2.5 to 3 at low zoom for the same
reason the resting state was raised earlier: a stroke is a fixed px cost, so at
radius 2.5 the coloured core carrying the region's identity is about 1.5px.
dx-life's two modes keep an opaque stroke; only the chooser's dim is ours.

The property is set in both places. A paint property added at layer creation but
not re-applied where the mode is re-applied never updates on hover, which is the
bug this file already carries a warning about.

The map below the fold, measured at 1440 with the container at 1200: the map's
top sits at about 583px and the map is 780px tall, because min-height beats what
16/9 asks for at that width. A reader sees roughly its top 190px. Tightening the
three gaps above it and dropping the outsized title one step recovers about
99px, so about 286px shows on load. It does not put the whole map above the fold
and nothing leaving the map 780px tall can; that needs the map bounded by
viewport height, which changes its rendered size and is on the Driver's sheet.
Scoped to the all-regions page and to 1200px and up, because the region and
country pages are the clone and their rhythm is measured against dx-life.

Rebuilt on current main rather than merged, so the shared manifests carry every
other bundle's current hash and only this bundle's moved: four lines, one
integrity plus the three script tags that embed it.
2026-09-28 16:24:36 +00:00
Doxa Maps
ef9cc92d20 UPG-100: imb_gsec arrives nested, so the packet lost half of every description
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The API returns imb_gsec as { value:'1', label:'…' }. It was missing from
NESTED_FIELDS, so describeGroup did Number({value:'1'}) -> NaN, the level
never matched, and the reachedness sentence was dropped for every group --
silently, because dropping a clause is the deliberate design for a missing
field. Every profile description shipped at half length.

Measured through the real normalizer and real catalogue: nested -> one
sentence, flattened -> two.
2026-09-28 16:09:12 +00:00
Doxa Maps
d7b6a631b6 Move zone-regions.json + groupings.json to data/tables/, the shared asset pool
GOSPEL'S LEGEND RENDERS IN THE BROWSER and needs these zones at runtime; a
build-time import path would not reach the bundle. data/tables/ is the pool the
tree copies into public/dx-maps/assets/tables/, which is how near-neighbors.json
already reaches a browser, so this makes the zone data a single source the same
way (dx-shepherd, 2026-09-28).

admin-to-list.json deliberately does NOT move: it waits on the Driver's row 87,
the "zones fold" regeneration.

★ TWO ROOTS IN internal/check-list-selection.mjs, NOT ONE. Its readJson helper
reads all three files through a single DATA constant, and only two moved.
Collapsing them to one root silently breaks whichever file is not where you
pointed — so the helper now routes admin-to-list.json to the plugin dir and the
other two to data/tables/, with the reason in the comment.

★ THE ASSET-POOL COPY IS MANUAL, AND IT HAS ALREADY DRIFTED. There is no
automated copier: `git grep` finds no rule in vite.config.js or internal/ that
writes public/dx-maps/assets/tables/, and exp's rings note says the same ("only
the tree copies tables"). Measured before copying: data/tables has 309 json
files, the asset pool 304 — and the five missing were groupings.json,
zone-regions.json AND THREE THAT PREDATE THIS COMMIT (index.json,
open-doors.json, platform-reachability.json). So these two would have joined an
existing gap rather than created one. Both are copied here and verified with
cmp; the other three are reported separately, not silently fixed in a move
commit.

Paths verified to resolve, not assumed: P(GROUPINGS) and the REGIONS join both
resolve to existing files. Gates green after the move: check:list-selection,
check:selector-fields, check:describe-group, region-hue-proof.
2026-09-28 15:52:47 +00:00
Doxa Maps
1aa9a0ac99 A composite embedded bare dropped tokenUrl, and passed because a ProfileLoader sat above it
PM-tok, PRODUCTION-MATRIX.md:285. `tokenUrl` names a token SERVICE a host points at
instead of pasting a key: the element asks that URL when it was handed no token. Only
ProfileLoader ever read it. A pane composite bakes its children in rather than mounting
them through ProfileLoader, so a composite embedded BARE -- one tag on a partner's page,
which is the entire point of a composite -- dropped a tokenUrl-only config on the floor.

It worked in every place anyone looked, because a ProfileLoader elsewhere on the page
fetched and published the token: the composite was passing an exam somebody else sat.
And the failure wears the wrong face. No token is "No Mapbox access token provided",
0 tiles, no canvas -- identical to a missing `tk`, which IS threaded -- so the natural
reading is that the host mistyped their config rather than that a knob is unread.

The rule lives in library/composables, not in each composite, because it has already
been copied once (ProfileLoader.vue:132) and a third and fourth copy is how one of them
later learns to ask ALWAYS -- which a token service billed per call notices before we do.
The pure decision is split into host-token-url.js with NO framework import, because a
fresh worktree has no node_modules and a module importing vue is unloadable by the gate
that proves it right. Same shape as describeGroup keeping countryName injectable.

check-composite-token-url takes its expectation from ProfileLoader -- the contract a
composite REPLACES -- not from the composites it audits, so it cannot pass by
construction; it exercises the decision rather than grepping for it; and it finds its
subjects by a PROPERTY, a production bundle already forwarding `tk: hostToken`, so a
third composite is covered the day it is written. stack-demo has the same shape and is
excluded by its STAGE, not by name, so promoting it pulls it in automatically.

Negative-tested 5 ways. The fifth found a bug in the gate itself: /tokenUrl/ also matches
`tokenUrlX`, so the check meant to notice the contract being renamed could not fail. It
is \btokenUrl\b now. A guard that cannot fail is the one nobody ever finds by reading.
2026-09-28 15:52:47 +00:00
Doxa Maps
722ac77892 Join the set charts on a key both sides actually carry
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Both set charts reported "not drawn: 2,104". The join read g.country, and on
these rows country is the country's NAME while the neighbour table keys on ISO
3166 alpha-3, so the pair never matched — and because the rop3 half is absent
from these rows too, nothing matched at all. Measured against the shipped tables
with real names and real codes: the name path resolves 0 of 12,379 rows as
written, and 6,850 once the ISO code is used.

It survived my own verification because I measured the join with the province
table standing in for the dashboard's rows, and those rows carry iso3 rather
than country. The stand-in satisfied the key I had chosen, so the measurement
confirmed the code against a shape the real caller does not have. A proxy has to
match the caller in the fields the code reads, or it is only testing itself.

Three keys now, strongest first. Slug is the only one that is a single field on
both sides and unique by construction, since the table's build refuses to emit
two rows sharing one; rop3 with the ISO code is a compound key these rows may
not carry; name with the ISO code depends on two sources spelling a people the
same way, so it stays the last resort rather than the first guess. A row
carrying only a name is still better joined than dropped.

The slug column is not in the table yet. Each key is indexed only when its field
is present, so today the slug map is empty and every lookup falls through —
verified against the shipped table: 0 slug keys, no entry keyed on a missing
value, and a slug lookup returns undefined rather than colliding. That makes
this safe to land before or after the column arrives.
2026-09-28 15:43:47 +00:00
Doxa Maps
bfd1b78b4e Rudder is thin-client by default; the robot voice is an experimental opt-in (High performance mode)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Rudder is built for a thin client: by default the whole budget goes to speech-to-text and nothing of the
robot voice runs or even loads.

- Settings → Robot voice is marked "experimental". Ticking it first shows a warning (in the page, not a
  browser dialog the desktop webview may block): it runs an extra voice engine, uses more CPU and ~150 MB
  while it speaks, and can slow voice-to-text on a smaller laptop or shared dev server. "Turn on anyway"
  switches to High performance mode; the settings show the current mode.
- Engine: POST /api/rudder/mode {robot} switches thin-client ↔ high performance, and refuses the robot when
  the machine has under ~500 MB available (/proc/meminfo) — a voice tool must never crash someone's laptop.
  /api/rudder/tts refuses in thin-client mode, so an old saved setting can't start the robot either. In
  thin-client mode Piper would yield the CPU; in high performance mode it runs at normal priority.
- The voice list loads only when the robot voice is turned on; a robot voice left on re-enters high
  performance mode on the next visit (the engine re-checks memory).
- Verified on the engine: starts thin-client; speaking refused (409); on → high-performance; speaks; off →
  thin-client; speaking refused again.
2026-09-28 15:35:51 +00:00
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
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
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
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
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
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
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
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
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
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
52a1cf6809 UPG-100: the packet fetch carries language + GSEC, and region ids fold diacritics
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
TWO PREPARATIONS FOR THE GENERATED PROFILE DESCRIPTION (Driver brief A1.5 col 2).

1. THE FETCH. Both packet fetch sites (:1363, :1588) requested
slug,name,country_code,population,religion,image_url. describeGroup needs the
language and a reachedness level, so primary_language and imb_gsec are added.
Both are existing API fields — poster-wall:171 already fetches primary_language
and sources.json:43 lists imb_gsec — so this adds no new API surface. Verified
they survive normalizeRecords, which is non-destructive ({...raw}).

GSEC and not EvangelicalLevel because GSEC is a NUMBER. EvangelicalLevel is
English prose from the API ("2% or Greater but Less than 5%"), so interpolating
it would leave every non-English packet half-English in its widest column. sr
measured both across 12,379 rows; I re-derived the GSEC distribution
independently (266/6573/269/199/1839/1428/1805) and confirmed that no GSEC
description mentions 0.1%, which is why "frontier" cannot be said from this
field and the wording stops at the 2% line the funnel page already states.

2. REGION IDS FOLD DIACRITICS. legend-tree.js built an id with
.toLowerCase().replace(/[^a-z0-9]+/g,'-'), which DELETES a combining mark rather
than folding it: Gujarāt became 'region-gujar-t', a different id from
'region-gujarat'. Nothing is broken today — the labels are regionFor()'s fixed
ASCII set and nothing joins on the id — but the brief's whole point is that a
fork brings its own set and names its own regions, and the first accented label
would split one region in two with no error and no empty state.

Same class gospel measured joining admin-to-list.json: a naive fold matched 514
of 1009 rows and lost THREE whole zones while every surviving zone still looked
populated. A join that returns half is more dangerous than one that returns
nothing. Fixed at the fold, not by enumerating the spellings we know.

Verified behaviour-preserving: Africa -> region-africa unchanged, and Gujarāt
and Gujarat now produce the same id.
2026-09-28 13:30:43 +00:00
Doxa Maps
55cc694fdd Pplr Data Maps: tab and legend labels follow a language change instead of keeping the one they started with 2026-09-28 13:01:16 +00:00
Doxa Maps
439bea6754 Use the keys that landed for three strings the map still said in English
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
L56 published the catalogue keys these three sites were waiting for, so the template now asks for
them instead of carrying the English:

  the gap list's "related:" label            -> gospel.gapList.relatedLabel
  the legend's "Show all N" clear button     -> gospel.filter.showAll, with the count as a placeholder
  the detail panel's "Nothing in X itself"   -> gospel.related.nothingInItself

Each was chosen because the swap costs no markup: the <em> and <b> wrap the whole phrase, so they
stay outside the call and the emphasis survives. The sites where a <b> sits INSIDE the sentence --
"Showing only <b>…</b>." above all -- are deliberately not in this commit, because the key is one
whole sentence and using it would silently drop the emphasis that distinguishes the filter's value
from its label. That needs a markup-preserving form, not a quiet visual regression on a production
map.

Measured, not assumed: hardcoded-english for this plugin 147 -> 144, and every one of the 72 keys
the leaf now references resolves in the catalogue. That last check caught a mistake in this very
change -- a fallback I wrote as t('gospel.state.thisVariety'), which does not exist and would have
rendered the key name to a reader. It is back to its literal until a key exists for it.

i18n parity and never-translated both pass; gospel's own render gate passes in ar, zh, ru and hi.
Rebuilt this bundle only, then fix:sri; check:sri is green at 64 leaves.
2026-09-28 12:57:56 +00:00
Doxa Maps
8a6e5543c0 Localise the region link's screen-reader label
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The new hardcoded-English gate found this in its first run over the production
plugins: an aria-label built by concatenation, so a screen-reader user on a
non-English locale heard "Open the" and "dashboard" in English around a region
name in their own language.

It uses the key the regions list already uses for the same sentence, which is
translated in all eleven locales, so the label now reads as one sentence rather
than two languages spliced together.

Found by the gate in the plugin that wrote it, which is the argument for making
it shared rather than private.
2026-09-28 12:51:28 +00:00