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.
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.
describeGroup(group, locale, opts) -> one localized sentence pair for a people
group's profile row: where they live, what they speak, and how reached they are.
Pure, exported, no refs or fetch; strings and the country resolver are injected.
The level is phrased from GSEC, a number, never from GSECDescription, which is
English prose the API hands us -- interpolating it would leave the widest column
of every non-English packet in English.
THE LANGUAGE FIELD WAS BOTH WRONG FIELDS AT ONCE.
primary_language is in normalize.js's NESTED_FIELDS, so the API's { value, label }
is flattened to its VALUE: an ISO 639-3 code. Reading it prints "and speak abf".
The name is in primary_language_label, and it arrives INDEX-ORDERED -- "Arabic,
Shihhi" for what a reader calls "Shihhi Arabic". 725 of the 12,379 rows carry an
inverted label, one row in seventeen.
The un-inversion is not a second copy: useLanguageFamilyLegendData already built
one to make a lookup key, so the split moves to library/utils/languageLabel.js and
both call it. Doing that surfaced why the two uses are not the same use -- 13 real
names carry a comma INSIDE a parenthetical ("Boano (Indonesia, Central Sulawesi)"),
and a naive split reverses those into "Central Sulawesi) Boano (Indonesia". As a
lookup key that garbage simply misses and the next candidate is tried; printed in a
sentence there is no such safety net. The shared split is top-level-only, and a
trailing parenthetical is hoisted past the reversal so "Naga, Tangkhul (India)"
reads "Tangkhul Naga (India)".
The legend's own behaviour is unchanged, checked rather than assumed: old and new
lookup compared over all 3,498 labels in langFamilyByLanguage plus every
LanguageName in the dataset, 0 differences.
countryName.js is NOT imported. It resolves only through the Vite '@map/' alias and
imports two JSON files without an import attribute -- fine in a bundle, fatal to a
node gate. Importing it would make this module unloadable by the test that proves it
right in eleven languages, so the renderer passes it in, and a missing injection
degrades down countryName.js's own ladder (localized -> English label -> alpha-3)
rather than blanking the clause.
check-describe-group asserts every GSEC level, each missing field, both locales,
truncation, the inverted label, the parenthetical that must not be shredded, and
that a bare language code is dropped rather than printed. Negative-tested: reading
primary_language, skipping the un-inversion, a naive split, and dropping the
parenthetical hoist each turn it red; restored, it is green.
Not yet wired into the renderer and not yet in gates:pre -- register with
"check:describe-group": "node internal/check-describe-group.mjs".
Reworked to the consuming dashboard's contract. It emits a zone id or null on
zone-change, not a selector: an id is serialisable into a query parameter, a
profile config and a deep link, which is the chain that dashboard already
resolves its list from, and a predicate is none of those. It also keeps the
component promotable -- a selector would have to know what it selects over, and
the consumer holds four different row shapes across its tabs. The join stays in
one place, where the diacritic folding and the province bridge already are.
A leaf emits its zone id; a region row emits the region's id and the consumer
expands it, because that is where the zone data lives.
And it cannot silently drop a zone. Four joinable zones are absent from the
region file today, one of them the consumer's largest at a hundred groups, so
grouping by region loses them with no error and no empty state -- just a shorter
tree. Given the full zone set, the legend renders the difference in an explicit
bucket, each one selectable. That makes the drop structurally impossible rather
than something to keep checking. Omit the set and the tree is untouched, so the
guard is additive.
Its check includes a negative control that the unaugmented tree really does omit
the largest zone, so the bucket is demonstrably not decoration.
The Gospel x Internet dashboard needs the Driver's vertical legend on the side
to select a people-group list and filter its groups by it. This is that legend,
wired as a selector.
It WRAPS the shared semantic tree legend rather than replacing it. That
component is already the vertical legend and already drives the 26-zone
tapestry, so every fix it receives -- theme-awareness, the progress bar, the
mobile sheet path, scroll-to-selected -- keeps applying. A second widget would
have to re-earn all of it.
It emits the SELECTION, not resolved group ids, and that is ownership rather
than taste. Resolving a zone to ids means joining zone membership against the
group rows, and that join is not trivial: the membership keys are ASCII while
the province data carries diacritics, so a naive join silently loses hundreds of
groups. A legend that resolved ids would hold a second copy of that join and
the copies would drift -- appearing as a wrong count, which nobody notices. The
consumer already holds the rows and the join.
A region row selects the union of its zones, and both a leaf and a parent return
arrays, so a single zone is not a special case the consumer will eventually get
wrong. A cleared selection is representable, because a dashboard must be able to
drop its filter.
The selection rule sits in its own module with its own check, including a
negative control that a parent does not select only itself -- which would filter
to nothing while looking like it had worked.
The tree is a prop. Its builder lives in the plugin that owns the zoning, and a
cross-plugin import is barred, so promoting that helper to the library is a
separate coordinated move.
The eight labels came from tStatic inside a computed. tStatic reads a plain window global the
computed cannot track, so its only dependency was props.labels and the row stayed in the boot
language after the host switched locale. They now go through useOptionalT, which calls the
host's vue-i18n (a reactive locale) when installed and falls back to tStatic when not; English
stays the fallback and a host's own labels still win.
poster.region_india / region_other cover the two region headings the poster wall also
returns; messages.bandInRange / bandOutOfRange are the two states of the HUD band label,
two keys rather than a ternary over one. Eleven locales.
gospel: 15 whole-sentence keys from its card (search, filter, gapList, province) with
placeholders each locale may move, 9 standalone strings, and 8 sentences that the template
assembles from fragments (prayer-page link, macrolanguage note, video titles, the largest-N
line), each now one key. UPG-100: map status and token messages, the territory hint, a
label-form territory count that needs no plural rules, and the poster wall's five region
headings. The templates switch to these keys in the owners' own commits.
Five bound English strings no locale could reach, now catalogue keys in all eleven locales:
the column sort tooltip (dashboard.sort_by_column_hint, {column}), the full-screen button's
title and label (fs_*), the export tooltip (export_*_hint, a label form with {n} so no plural
rule is needed), and the donut's accessible name (aria.donutChart, {title}), which read a
translated title followed by the English word "donut". English stays the fallback argument.
retryableAsyncComponent: after the three retries the pane rendered blank. It now shows
"This view couldn't load." with a Retry button (messages keys, 11 locales). Retry swaps in a
fresh async component, since Vue keeps a rejected load for the life of the page.
Each map control wrapper passes data-control (zoom-in, zoom-out, fullscreen, theme, location,
help, share) to its button: an untranslated hook for probes, where title and aria-label are
localised.
Regions: the country and region counts under the dashboard title were English
template literals; they are now label keys ('Countries: %count%') in all eleven
locales, which also sidesteps plural forms.
Gospel: the Zume chip joined 'in' + a language name, which reads backwards in
Hindi; it is now one phrase key with a {language} placeholder.
UPG-100: the Batch Select button title comes from legend.title.batchSelect.
The comment still said the code reads map.getSource()._data, which invites a copy
of the private field back in; the code reads through the shared helper that knows
both engines' source shapes.
Six conversions, one deliberately kept physical. Same method as the legends.
CONVERTED
DataTable.vue:238 .tabulator-cell border-right -> border-inline-end. A per-cell
trailing border is the COLUMN SEPARATOR between adjacent cells; in RTL the
separator has to sit on the other side or it draws on the table's outer edge.
DataTable.vue:269 .dt-actions margin-left:auto -> margin-inline-start:auto
DataTable.vue:293 .dt-close margin-left:auto -> margin-inline-start:auto
Both are "push me to the trailing end of a flex row", which is the left in RTL.
DataTable.vue:299 .dt-dl ul padding-left -> padding-inline-start. A list's
marker indent follows the reading direction.
StackLayout.vue:161,305 .stl-meta margin-left:auto -> margin-inline-start:auto
(twice: the light and dark theme blocks).
★ KEPT PHYSICAL, AND IT WOULD HAVE BEEN A REGRESSION TO CONVERT
DataTable.vue:245 '.dt:dir(rtl) … .tabulator-cell{text-align:right}'
It is already SCOPED TO :dir(rtl), so right IS the trailing edge wherever it
applies. More importantly the cells carry unicode-bidi:plaintext, which makes
`end` resolve PER CELL from that cell's own first strong character -- which is
the exact behaviour this line exists to override (English cells left-aligning
inside an rtl table and clipping at its left edge, rega L20). text-align:end
would silently undo the fix. Comment added at the site so the next reader does
not "correct" it.
No consumer overrides any of the four classes: .dt-actions, .dt-close, .dt-dl and
.stl-meta appear in no other file in library/ or plugin-designs/, so a physical
override cannot defeat the logical rule and produce a half-mirrored table.
LTR is unchanged: inline-start/inline-end resolve to left/right under dir=ltr.
check:sfc-parse PASS. 13 plugin files use DataTable, 9 use StackLayout.
library/ is the build owner's under rule 5B -- source sha to studio; rega eyeballs ar.
Seven conversions, one deliberate keep, and a stale comment.
CONVERTED -- each is text that should follow the reading direction:
LegendDesktop.vue:138,139 the count and population chips, text-align:right ->
end. They are numeric badges at the trailing edge of a row; in RTL the
trailing edge is the left.
LegendDesktop.vue:154,157 the stats and footer VALUE columns, right -> end.
Same argument, and they already used padding-inline-end, so the rule was
half-logical and read physical.
SemanticTreeLegend.vue:711,1105 two button labels, text-align:left -> start.
Label text in a flex row; it follows the reader.
SemanticTreeLegend.vue:764 .stl-tab-info border-left -> border-inline-start.
This is a DIVIDER between two adjacent chips. In RTL the info button sits on
the other side of its sibling, so the divider has to move with it or it
draws on the outside of the pair.
KEPT PHYSICAL, deliberately:
SemanticTreeLegend.vue:805 .stl-hdr-num::after { left:50%; top:50% } with
transform:translate(-50%,-50%). That is the CENTRING IDIOM for a 24x24 WCAG
2.5.8 hit target, not a direction: it computes identically in both, and
converting it would add review noise that hides the real conversions.
PROVEN, not asserted: measured the GLYPH POSITION in a browser rather than the
computed keyword, because getComputedStyle returns text-align as the keyword
("end"), so a naive comparison reports DIFFER in LTR for rules that lay out
identically. Measuring the span's left edge in a 200px box:
LTR right=201px end=201px IDENTICAL left=8px start=8px IDENTICAL
RTL right=201px end=8px MIRRORS left=8px start=201px MIRRORS
And for the border, where computed style does resolve: LTR 1px/0px both ways
(identical), RTL 1px/0px vs 0px/1px (mirrors).
Also fixes a comment that said "margin-left:auto pins it to the right edge"
above a rule that already read margin-inline-start:auto. Prose naming the
physical side is how a reader talks themselves back into a physical property.
library/ is the build owner's under rule 5B -- source sha to studio.
dx-shepherd asked for this with a7b12b44 and the reason is exact: every caller of readSourceFeatures is
written to do NOTHING when it finds no features, deliberately, so that a legend row for an off-screen
region degrades quietly instead of throwing. That is why the MapLibre bug produced no error, no console
line and no visual difference except a camera that did not move — and why it survived until a packet
printed the wrong 50 people.
check:pin-selection drives the reader with fixtures for both engines and no map at all:
· the MAPBOX shape (_data IS the FeatureCollection) reads its features;
· the MAPLIBRE shape (_data is { geojson }) reads the SAME features — the regression that shipped;
· a REMOTE source ({ url }) returns [] AND WARNS;
· the serialize() fallback works, a missing source is [] rather than a throw, a bare Feature is one;
· selectedPinIds ANDs its expressions, and with none returns every pin rather than none.
★ NEGATIVE-TESTED, which is the only thing that makes it worth having: run against the reader as it
shipped, it fails with exactly four findings — the maplibre shape reads 0 instead of 3, selectedPinIds
narrows to nothing, "no expressions" returns nothing, and the remote case returns [] silently. Against
the fixed reader it passes. A test that cannot fail on the code it was written for proves nothing.
★ AND THE REMOTE CASE IS NOW A DECISION, not an accident. dx-shepherd asked what { url } should do. It
returns [] and warns once per source: the features live on a server and cannot be enumerated locally, so
"I cannot read this" is a different fact from "there is nothing here". Collapsing those two is precisely
how the MapLibre bug hid for as long as it did, and no caller can tell them apart from an empty array.
Verified: check:pin-selection, check:mjs-parse, check:doc-scripts, check:package-json-unique-keys all
PASS. gates:pre was NOT run clean here — my main clone sits three landings behind origin/main, so a
newer package.json lists check:targets while that file is absent from the working tree; that failure is
my verification setup, not this change, and I checked it on an untouched tree before reporting.
rega's console answered the question in one line: "0 pin(s) selected but no rows matched", for both a
language-family selection and a prayer selection, with the pins plainly on screen. Not an id mismatch and
not a missing filter — readSourceFeatures returned an empty list.
THE TWO ENGINES STORE _data DIFFERENTLY. maplibre-gl's GeoJSONSource wraps it:
this._data = typeof options.data === 'string' ? { url: options.data } : { geojson: options.data }
so `data.features` is undefined there. Mapbox stores the GeoJSON directly. This code was written against
Mapbox, where it works, and has been returning nothing on MapLibre ever since — which is the keyless
engine, the one the public builds use.
★ THE PACKET IS THE SMALL HALF. This reader is what zoomToLegendRow uses to answer "which pins does this
legend row select", and it backs research-map, country-posters and doxa-simple-map. On MapLibre every one
of them has been failing to fit the camera to a clicked legend row — silently, because the function is
written to no-op rather than throw when it finds no features. The original comment above it explains that
querySourceFeatures is viewport-clipped and would make off-screen rows "silently no-op"; the replacement
did exactly that on one engine, for a different reason.
The wrapper is unwrapped, and a URL source returns [] explicitly — having no local features to read is a
different fact from finding none, and collapsing the two is how this hid.
Verified: gates:pre 13/13. Needs eyes, and the second one matters more than the first:
· the packet on MapLibre prints the SELECTED groups, cover count no longer the whole list;
· clicking a legend row on MapLibre FITS THE CAMERA to that row — on research-map, country-posters and
doxa-simple-map. If that has been dead on the keyless engine, this is the commit that revives it.
VerticalTabsLayout kept a private script loader without the dev-server ES-module
rule, so a bundle nested through it under bun run dev loaded as a classic script,
reported success, and never defined its tag. It and StackLayout now call
library/utils/embedScript.js loadScriptOnce; StackLayout keeps its own
resolveBundleSrc step first. loadScriptOnce gains an empty-url no-op (a
<script src=""> re-fetched the page) and carries StackLayout's stack-demo
measurement beside the gallery's. One copy of the pattern and one cache.
DEDUP-AUDIT.md (measured today: 253 files, 31 cross-plugin duplicate clusters,
622 duplicated lines) ranks this tenth by raw cost and first by suitability. It
is the only cluster where the copies are identical in code, close over nothing,
and sit in two plugins with one owner between them.
loadScriptOnce (13 lines) and whenTagDefined (11) were byte-identical between
doxa-maps-spa and plugin-studio. deriveTreeBase (9) differed by ONE COMMENT LINE
and not one character of code -- drift in the documentation of a shared function,
which is its own argument for having one copy.
They are one concern, not three: load a bundle's script once, wait for its custom
element to register, and work out the tree base to load it from. That is what a
capability-5 app shell does to nest another plugin, and both plugins here ARE the
capability-5 shells. Any future shell needs the same three.
Verbatim move: no parameterisation, no behaviour change, callers import instead
of declaring. The comments travel with the code because each records a
measurement -- particularly the one explaining why onload is not a usable success
signal for a dev-server ES module.
ONE BEHAVIOUR CHANGE, AND IT IS AN IMPROVEMENT: the _loaded cache is now shared.
Two shells on one page previously kept separate caches and each loaded the same
bundle; the second now reuses the first's promise.
deriveTreeBase reads document.currentScript, so it is only valid AT MODULE
EVALUATION TIME. The call sites did not move -- both entries still call it at
module top level -- and the library file documents the constraint, because a
later call silently returns '' and every nested bundle URL would break.
Verified: both bundles build; each built file contains all three functions
exactly once and still derives currentScript; check:sri, check:series-isolation,
check:studio-rail and check:capability-derived all PASS.
library/utils/embedScript.js is the build owner's under rule 5B. The
plugin-designs and public/ changes are in the same commit because they import it
-- landing them apart breaks the build in between.
research-map and countries-map moved to the shared printer, leaving the
composable with no importer on main. jsPDF stays: useMapPoster.toPdf,
posterSizes and my-upg-100-list still reach it. The file map row goes and the
library index is regenerated.
PACKETSEL, found by exp-shepherd: the prayer packet printed the first 50 of EVERY people group whatever
was selected, so a reader who had narrowed the map to one language family got a packet about somebody
else. I carried that behaviour through verbatim when I moved the packet to the shared printer — it was
already wrong and I preserved it, which is worth saying plainly.
The rows now come from the pins currently SELECTED: the layer's own filter decides which pins render,
`_pinMatchExpr` decides which of those are lit (the affinity tab dims rather than filters), and both
must match — a pin at opacity 0 is not what the reader is looking at.
★ ASKED OF THE MAP'S FEATURES, NOT OF THE ROWS, and this is the part that had to be got right rather
than guessed. Pin properties are DERIVED from rows: useMapLayers fills languageFamily in when the API
returns null for all 2,069 groups, and canonicalizes doxaRegion onto the palette's key forms precisely
so setFilter matches. So evaluating a pin filter against a raw data row is wrong in a way that NEVER
THROWS — it just selects a different, plausible-looking subset. The features the map holds are the only
place the two vocabularies agree.
Answering that question already existed, privately, inside useMapFly: zoomToLegendRow has to solve it to
fit the camera to a legend row, including the reason it cannot use querySourceFeatures (viewport-clipped,
so an off-screen region silently selects nothing). Those three functions move VERBATIM to
library/maps/pin-selection.js and useMapFly imports them, so there is one evaluator rather than two —
and country-posters, which prints the same kind of packet from the same kind of selection, has the same
helper to reach for instead of a second copy.
Two failure modes are distinguished rather than collapsed: pins selected but NO row matching them is an
id mismatch, so it prints the full list and warns; a genuinely empty selection still reaches the shared
printer's own "that list resolved to no records", which is honest. And when the 50-row safety cap bites,
the cover now says "— 50 of 214" instead of quietly being a different packet than the one asked for.
Verified: gates:pre 13/13, check:composite-interop PASS. Needs eyes: rega — select one language family,
print, and check the cards are that family and the cover carries the count.
The poster dialog had only a download path: its Render button saved a file, the
same trap PosterWall's old default was (gospel's Driver-lens audit, card 7).
A new action prop defaults to 'print': a PNG poster opens the print dialog in
place (printImageBlob, sized to the chosen page), a PDF opens in the browser's
viewer, and a blocked print frame or popup falls back to the file. 'download'
stays an explicit opt-in.
exp-shepherd found research-dashboard rendering ENDONYMS in every locale — 中国 and Türkiye on a Russian
page — and fetching a glyph range for every script on screen. The reading was that its basemap carries no
localized names and the choice was to swap the basemap or give up on its labels. Measured against the live
tiles, neither is necessary: the names are there, under keys this chain never tried.
The galaxy style is VersaTiles Colorful on the SHORTBREAD schema, whose label keys are name_ar / name_de /
name_en, not name:ar. This coalesce only tried the colon spelling, so all 43 of that style's label layers
fell through to `name` — the local name — while the localizer ran and reported success. A running fix that
cannot reach its data looks exactly like a missing feature.
MEASURED, six z3/z4 tiles across Europe, Asia and Africa, identical key sets on every one: name_ar,
name_de, name_el, name_en, name_es, name_fr, name_it, name_nl, name_pl, name_pt, name_uk. Of the eleven
locales we ship that is SEVEN in their own language — ar, de, en, es, fr, it, pt — with no change to the
basemap the Driver chose and no change to how the map looks.
hi, ro, ru and zh are genuinely absent from those tiles, so they now take name_en: one Latin script rather
than mixed CJK and Devanagari endonyms, which is also the single-script glyph saving. That is the same role
name:latin already plays for the OpenFreeMap styles, so it sits in the same position in the chain rather
than becoming a special case.
NOTHING CHANGES FOR THE OPENFREEMAP STYLES, by construction: the colon spelling stays first, so their
name:<lang> or name:latin still wins and the underscore entries are never reached. The order is also what
keeps the already-rewritten sentinel working — it tests for a chain beginning ["coalesce",["get","name:,
which this still is. Verified both, rather than assumed.
WHAT I DID NOT DO: swap the basemap. galaxy resolves to VersaTiles Colorful because the Driver asked for
blue oceans like the Mapbox map and that style's water measured closest (#bfd9f2; OpenFreeMap Liberty is
#9ebdff, noticeably stronger). Trading a look he chose for four locales' labels is his call, not mine, and
with this change those four get readable Latin labels anyway.
Verified: gates:pre 12/12. NOT measured here: glyph KB per locale, which needs a browser — rega has the
instrument and the before numbers.
exp-shepherd measured on main: SERIES=country ONLY=country-yem made a POSTER,
and the matching packet.mjs run died with 'no zone matches ONLY=country-yem'.
The packet half of SERIES=country could not run for any country.
Cause: allZones() built zones from clusters.json -- the UPG-100 zoning -- where a
country id never appears. generate.mjs got its country zone source from
countryMode in the previous commit; packet.mjs did not. The same 'one concept,
two switches' defect I had just removed from one file survived in the other.
MY OWN TEST MISSED IT BECAUSE IT WAS NOT END TO END. I ran the country packet
against a UPG-100 zone id (ONLY=west-africa), which exercised the WORDING and
never the zone source, then reported the parameter verified end to end. Running
the actual pairing is what found it, and exp-shepherd did that, not me.
allZones() now returns the country zones under countryMode, built to match
generate.mjs's country branch exactly -- same ids, labels, adm0 and the PLURAL
country_codes selector shape -- so a poster and its packet cannot describe
different zones. Same two-path country-list lookup and the same error naming the
command that writes the file.
Verified: country-yem builds 8 groups / 6 pages, SERIES-ISOLATION PASS with 9
country markers and zero UPG-100 framing; the UPG-100 default is unchanged at 12
pages and 13 markers; all four packet gates PASS.
rega read the rendered page and found a fourth leak site my gate had passed: a
SERIES=country poster still printed 'UNENGAGED PEOPLE GROUPS', 'Unengaged of West
Africa', 'Series: List of 100 UPG's · map 11 of 28' and a side box calling it one
of a 30-map set. I had reported that PASS to three agents as proof the render was
clean. It was not proof: the gate's vocabulary was a HAND LIST of the phrases I
had already found, so it could not see framing I never enumerated.
ONE SWITCH (dx-shepherd's call). SERIES=country now implies the country zone
source, the series name and the header strings. COUNTRIES=1 is kept as an ALIAS
that resolves to SERIES=country, so runs that predate series.json keep working
and now get the matching packet too. Two parameters for one concept is exactly
what let rega's render take the new packet path with a UPG-100 header.
The five header strings move into series.json: kicker, title qualifier, series
name, side-box heading and body. One file owns the wording.
THE GATE NO LONGER DEPENDS ON WHAT I HAPPENED TO KNOW. Its vocabulary is DERIVED
from series.json -- every string a series declares becomes that series' own
fingerprint, so adding wording polices it from that moment. Strings shared
between series are dropped (they identify nothing) and a 12-character floor keeps
short phrases from matching innocent prose. The hand list is kept and merged for
phrases no series declares, like 'Project 7000'.
It also strips HTML and CSS comments before matching: generate.mjs ships design
notes into the SVG that QUOTE these strings, and a correct country poster failed
on five of them. A detector that does not know the comment syntax of what it
reads attacks the documentation of the thing it protects.
FOUND WHILE WIRING IT, a pre-existing break: COUNTRY MODE HAS NOT RUN AT ALL.
generate.mjs read plugin-designs/country-posters/data/countries.json, but
ddde518a8 moved that data to library/data/country-posters.json and the reader was
never updated -- no file at that path on main, so every COUNTRIES=1 run died on
ENOENT. Both paths are now tried, newest first, with an error naming the command
that writes it. Country mode builds 173 countries for the first time.
(node --check passed on the fix while existsSync was still unimported -- a parse
check is not a will-it-run check, and the free variable sat in the one branch my
tests had never reached.)
Verified: country header now reads 'Unreached People Groups / Pray for Republic
of The Gambia / What is a Country Prayer Map?'; the UPG-100 default header is
unchanged; the stale poster still fails as country with 11 real hits, so the
comment strip did not blind the check; COUNTRIES=1 alias renders correctly; all
four packet gates PASS.
The ordering rule 'regenerate the poster with SERIES=country BEFORE the packet'
was a fact about the build living in a human's head. Documentation does not hold
that kind of fact: the same bet already failed for 'regenerating the tree alone
ships a stale .js', which is written down in the brief and still happened.
The hazard is silent and I walked into it while building the series parameter.
The three below-map blocks are DRAWN INTO the poster's SVG by generate.mjs, and
the packet lifts that SVG whole, so the text reaches the packet through the
IMAGE -- no string file mentions it and no grep of packet-strings.json can see
it. A country packet built against a leftover UPG-100 poster prints 'Thirty
disciples' on page 1 with no error, the right file size, and a generator
reporting success. It looks exactly like the SERIES parameter does not work.
So the build asserts it instead of remembering it. The packet reads the poster's
series out of the artwork it is about to lift -- fingerprinted by the NETWORKS
headings, which are the one marker that survives every stage of the pipeline into
the artefact -- and exits 1 with the remedy command when it disagrees with its
own SERIES. Suggested by regions-plugin, whose two near-misses today were the
same class: assert against the ARTIFACT with a marker that survives the pipeline.
Verified in all four directions: country packet vs upg100 poster FAILS, upg100
packet vs country poster FAILS, and both matching combinations build clean. An
unrecognised poster is deliberately not an error -- that is the existing 'no
<svg>' branch's failure, not this one's.
Driver rule 2026-09-28: an export-poster button opens the print dialog with a
preview, never a direct download. PosterWall defaulted posterAction to
'download', so country-posters and key-cities, which set nothing, downloaded.
The default is 'print'. check:poster-action-print (gates:pre) keeps the default
and fails a consumer that opts into 'download' without a
"posterAction-download: <why>" comment. PDF packets and CSV exports are not
poster buttons and stay downloads.
The country posters shipped carrying UPG-100 framing: the UNENGAGED PEOPLE
GROUPS kicker, 'This packet is 1 of {n} UPG-100 lists', the Project
2000/7000/4700/700 narrowing funnel, and a below-map block telling the reader to
divide the set among THIRTY disciples -- coherent for ~30 UPG-100 zones and
incoherent for ~170 countries. Reported by the Driver.
Not a mislabel: there was NO series parameter anywhere, so 'the packet' and 'the
UPG-100 packet' were one thing and nothing could disagree.
series.json declares each series' funnel flag, string overrides and the three
below-map NETWORKS blocks. Both packet.mjs and generate.mjs read it, because the
leak was in TWO independent places -- the packet strings and the poster's own
furniture, which is not a packet string at all, so fixing either alone leaves the
other printing. SERIES absent or unknown resolves to upg100.
The funnel page drops for country: it narrows every people group on earth to THIS
list, which is not a description of a country. Reusing the existing
missing-vision-funnel path means one way for the page to be absent, not two.
Four packet keys are overridden, not seventeen: 12 of the 17 are funnel_* and go
with the page; the 12 prayer points are invariant, because they are about a
PEOPLE and a country poster still prays for the peoples within it. Overrides
apply to every locale on purpose -- an English country kicker is visibly awaiting
translation, whereas inheriting the translated UPG-100 kicker prints the wrong
series fluently.
The country English is a DRAFT from the Driver's dictated shape, flagged
_draft_awaiting_driver; no translations until he signs off.
check:series-isolation asserts the property, with a self-test over 6 known-bad
inputs. It caught a real leak this commit would otherwise have claimed fixed: the
packet's page 1 lifts the whole SVG from the pre-rendered poster, so a country
packet built against a stale UPG-100 poster still printed 'Thirty disciples'.
Verified at the rendered layer -- country 11 pages, upg100 12, the delta exactly
the funnel page; upg100 networks byte-identical to the text they replaced.
rega measured the WAGF Regions tab on Simple Research: the polygon highlight lands 51-85 ms after the
click, then 433-667 ms later it is gone — fill-opacity back to 0.7, line-width back to 0.6, no row
marked, every region at full colour. That reads exactly like the Driver's "the legend isn't connecting
with the map", and it is worse than not connecting: it connected and then undid itself.
Those two values are the fingerprint. 0.7 and 0.6 are written only by this profile's CLEAR paths, every
one of which is reached from a deselect — so something sent select(null) half a second after the click,
and nobody clicked twice.
SemanticTreeLegend watches the IDENTITY of its nodes array, and every tree feeding it is a computed that
returns a NEW array each evaluation. Any late async that touches a dependency — pins still arriving, a
lazily loaded layer, a refresh — rebuilds the array with IDENTICAL CONTENT, and the reset in that
watcher then cancelled the reader's selection: expandedIds cleared, selectedNode nulled, select(null)
emitted. The reset is right for genuinely new data; what was wrong is that it could not tell the two
cases apart.
It asks one question now: does the selected row still EXIST in the new nodes? If it does, re-point at
the new object and leave the tab and the expansion alone, because nothing about the reader's state has
become invalid. If it is gone, the reset runs exactly as before and the deselect is honest. Re-pointed
rather than kept, because the old object belongs to the discarded array and holding it would compare the
highlight against a node the tree no longer contains.
This is shared, so it is every tree legend on every map, not only mine: any profile whose data arrives or
refreshes after a click was silently losing that click.
NOT changed, and deliberately: the WAGF Regions TAB click also compiles shaders for regions-fill and
regions-border (rega: 2.9-4.1 s on SwiftShader), because those layers are added on that click. Warming
them at boot would mean paying for two more layers on every reader of every tab to save a one-time cost
on one tab, and the 6.4 MB regions GeoJSON is lazily loaded there on purpose. SwiftShader inflates a
compile by roughly an order of magnitude — the pins variant measured 3.2-3.5 s there and +0.5 s of real
boot delta — so the honest expectation on a GPU is a few hundred milliseconds, once, on a tab that is
already loading megabytes. That is a trade I am declining to make rather than one I missed.
Verified: gates:pre 12/12. Needs eyes: rega, the same WAGF Regions row click — the highlight should now
still be there at 5 s, and the variant compile may appear on that click once the selection sticks.
rega's contact sheet showed what no gate could: page 3 read as ONE SOLID GREEN BAR. 32 contiguous cells
fill most of row 1 of 52, so the page said "a big green block" where the Driver's intent is "32 boxes lost
in 5,200". Rarity is the whole message and a bar cannot carry it.
⚠ AND THE COMMENT I DELETED SAID THE OPPOSITE, WHICH IS THE PART WORTH KEEPING. It read: "Row-major, so the
shaded run reads as a contiguous block from the top — a scattered shading of the same COUNT would be honest
arithmetic and an unreadable picture." I wrote that reasoning about a picture I had never looked at, and it
was exactly backwards. Scattering is what makes the eye hunt for them, which IS the point.
DETERMINISTIC, NEVER Math.random(): a 32-bit FNV-1a hash of the ZONE ID feeds a mulberry32 PRNG, so the
same packet prints identically every time — two copies of one zone must not disagree — while different
zones get different constellations, so a print run is not 28 sheets wearing the same pattern. Verified: two
consecutive renders produce byte-identical files (md5 febafc1813c6 both times).
Partial Fisher-Yates over the index space rather than shuffling all 5,200, so a large share degrades into
an even spread rather than clumping.
★ AND THE NEW ASSERTION IS THE ONE THAT WOULD HAVE CAUGHT THIS. check-packet-render now measures the shaded
cells' VERTICAL SPREAD as a fraction of the grid height, at the RENDERED layer via getBoundingClientRect —
because a COUNT cannot tell a scatter from a bar. 32 shaded is 32 shaded either way, which is precisely why
every existing assertion stayed green on a broken page.
POISON-TESTED rather than trusted: rebuilding the same 32 cells as a contiguous run makes it FAIL with
"span only 0% of the grid height". The real packet measures 95% (32 cells across 26 distinct rows). The 50%
floor is deliberately loose — a genuine scatter spans nearly the whole page, a bar spans ~1%, and anything
between deserves a human look.
⚠ The poison test first ran on a copy in /tmp and the gate REFUSED it — "outside any checkout, so which
build produced it cannot be checked". A sensible guard, and it meant my first poison run proved nothing;
re-ran inside the tree.
en and ar both PASS at 9 sheets. Page 2 (the empty life grid reading as blank graph paper) is UNTOUCHED and
held for the Driver with the page-order question — shading a reader's life we do not know would be a guess
presented as a fact, which is a product decision, not mine.
English readers got the style's own bilingual labels, native script under every name,
so an English page fetched a glyph range for every script at world view: Prayer
Progress loaded 18 font/sprite requests (777 KB) in English against 5 (185 KB) in
Arabic. English now gets the same single-language labels as every other locale.
rega's load waterfall: the engine was requested only after the component tree
had rendered, and the dashboard's 384 KB country boundaries only after the whole
basemap style had loaded, so each waited on the other. The engine download now
starts when the map composable is set up, and the boundaries when the dashboard's
map panel mounts; the style handler awaits that same download.
The Driver, 2026-09-28: an export-poster button must open the PRINT DIALOG with a poster preview,
never a direct download. Simple Research was doing exactly the forbidden thing — render a PNG, hand it
to a download helper — so a reader got doxa-prayer-poster.png in their downloads folder with no
preview, no paper size, and no way to print it without opening it somewhere else first.
The dialog IS the preview. printImageBlob() puts the rendered poster on a sheet of exactly the spec's
size (@page 8.5in x 11in, margin 0 — the renderer already composed the margins in) and raises the
dialog over the map. It goes in library/composables/printInPlace.js, which is THE print mechanism here
and already carries the Driver's earlier ruling that killed the pop-up path: an iframe cannot be
blocked and needs no user gesture, so the same call works for a human click and for an agent.
The ellipsis comes back on the label. It was stripped while the button handed over a file directly,
and an ellipsis promises a dialog — which is now what happens.
★ AND THE GATE ASSERTED THE OLD RULING, which is the part worth naming. check-export-one-click passed
while the button did the thing the Driver has now ruled against: it waited for a download and checked
the PNG's dimensions. A gate that encodes a superseded ruling does not just miss the defect, it defends
it — the next person to "fix" this button would have had to break a green gate to do it. It is replaced
by check-export-poster-prints, same four cases (leaf and gallery, en and ar), asserting the new
contract: one click, some frame calls window.print() within 120s, the printing document is the
1700x2200 poster on an 8.5in x 11in sheet, and NO download arrives. window.print is stubbed in every
frame — the native dialog is modal and would hang the run — so this measures everything up to the
dialog and not the browser's own chrome, which is the right boundary for a headless gate.
Why the download half is asserted separately: a button that opens the dialog AND saves a file still
breaks the rule, and a download that lands late is still a download, so it is judged over the whole run
rather than at a moment.
Verified: gates:pre 12/12, check:doc-scripts and check:no-credentials-omit PASS with the rename.
NOT verified here: the new gate needs a browser, and this agent does not start one — rega runs it.
rega's ?lang=ar pass (KA3, 3d64bcad) found English on a localized map in four places, and the census
behind it explains why no gate saw them. Catalogue coverage is COMPLETE: of the 760 keys my bundles
name across the 21 areas they reach, ZERO are missing from any of the ten non-English locales, and the
per-locale English-identical values are numerals, acronyms and cognates rather than debt. Everything
rega actually read was a string no catalogue ever held.
· THE PROJECTION PILL said "🌐 Globe" / "▭ Flat" in all eleven locales. The icon is now the label.
Neither word has a catalogue entry and authoring ten new strings for a two-state toggle to say what
a glyph already says is the wrong trade — but mapui.enableGlobe / mapui.disableGlobe DO ship in all
eleven, as the ACTION, which is what a button should announce. They become the accessible name and
the tooltip. min-width joins min-height on the pill: the 24px used to come free from the text.
· THE TABLE FOOTER said "Σ 2,107 groups". dashboard.groups has shipped in eleven locales all along
(ar "مجموعة") and this one call site never read it. Written with the same singular/plural test as
the header count so the two cannot disagree at one row.
· THE REGION CHART labelled its bars with the prayer API's English region names. Seven regions is a
closed set, so they are now CATALOGUED — five generated from vendored CLDR by
internal/generate-wagf-region-names.mjs, which is the same data countryName.js already trusts.
Generating is what keeps seventy translations out of my hands. middle_east and
north_america_&_non-spanish_caribbean stay English: M49 145 is "Western Asia" and 021 is "Northern
America", and neither means the region we draw. An English label a reader can match to the map beats
a localized one that names the wrong territory. --check verifies the catalogue against CLDR.
· TOP COUNTRIES labelled its bars with the API's English country names. By aggregation time the
alpha-3 is gone, so countryName.js gains iso3ForEnglishName() — reversing its OWN table, exact where
it matches and silent where it does not — which is precisely the ✓ case its header describes.
One thing found while wiring those: the BAR label was the only chart surface still printing the raw
facet key. The donut, its legend and both tooltips already went through legendLabel(); the bar list did
not, which is why these labels stayed English after the donut was fixed for rega's earlier survey. Both
new facets are projected there, so the two charts cannot disagree about a name again.
English is left EXACTLY as authored. Both projections return the key untouched at locale 'en', which
also keeps the 42 countries whose CLDR name differs from ours reading as they do today. Nothing
localizes a VALUE: data-key, isActive() and every click and filter path still carry the canonical
English, the rule the religion donut already followed.
Verified: gates:pre 12/12, check:i18n-keys-exist / parity / never-translated PASS, and the generator's
own --check PASS. Rendering on ?lang=ar goes to rega.
doxa-maps-spa.js carried "internal role noun (Driver)" twice. It was not the
SPA's own file: the text is a CSS comment inside VerticalTabsLayout's style
literal, so it is string content and ships to every bundle using that layout.
Rewritten in BOTH synced copies (lines 355 and 844) to state the decision
rather than who made it. check:synced-css passes, so the copies stayed
identical. Verified against output: doxa-maps-spa.js goes 2 occurrences to 0.
Located by matching the SHIPPED text back to source, after two wrong guesses
worth recording. A backtick-parity test over a whole .vue file said all 25 of
that file's "Driver" mentions were outside a template literal, while 2
demonstrably shipped -- the crude lexer again. And the leak was not in
doxa-maps-spa.vue at all, which is where the bundle name pointed.
check:public-hygiene flags "internal role noun (Driver) — process vocabulary"
inside shipped bundles. Three of those reach regions-dashboard.js, and they
are not from the regions components: they come from the shared poster and
packet code, which every bundle using PosterWall carries.
The worst is not a comment at all. packet-strings.json's note_on_scripture is
a shipped STRING, so it is data a reader can read. The other two are a JS
comment and a CSS comment that both live INSIDE template literals, which
makes them string content rather than comments -- the minifier cannot strip
what is not a comment, and the shadow-CSS stripper does not reach a print
stylesheet.
Rewritten to say what the decision was rather than who made it. Three lines,
no behaviour, no reformatting. regions-dashboard.js goes from 3 occurrences
to 0 after a rebuild.
This is the white-label requirement, and it matters most for the artifact we
intend to hand over: a recipient reading our bundles should not meet our
internal process vocabulary.
ShareButton is not a .mcb. It wraps its button in a .share-wrap div carrying
border-radius:50% and a conic-gradient ring on ::before at inset:-2px, so the
button rules missed it and it kept a round rainbow halo inside the square
panel -- breaking the single-card illusion at the corner the eye lands on.
rega measured it at 390 and asked whether square should flatten it. It
should: in this variant the panel owns the outline, so a child may not bring
its own. The round default is untouched.
Gated, with a control asserting ShareButton really does carry a 50% radius,
so the two rules are not decorative.
A zero-width segment button was still rendered: an invisible tab stop, and the
first child that took the bar's rounded left end. Segments with no width are
now left out.
The dev backend answers /status with the same offline status as /events, and
Rudder reads it on mount and whenever the event stream drops, so a proxy that
buffers the stream no longer leaves it saying "Runs on your computer" on a
server that should run the engine. The local-microphone notice no longer shows
off this computer. The legend progress bar paints its grey band as a rounded
strip and rounds the first and last segment, which the 24px touch targets had
squared off.
Each segment was a 10px-tall button, under the 24px minimum (WCAG 2.5.8). The
track is now 24px with a centred 10px painted band, the segment buttons clip
their colour to that band, and negative margins keep the legend's layout
unchanged.
Collapsed, the pill reads "Rudder · voice to text". While recording it drops the
label for a red dot, a mic that lights only while speech is detected, and the
last few words on one truncated line, so a closed panel still shows the reader
is being heard. Rudder reports this through an `activity` event. Open, the
panel no longer scrolls: the draft fills it and scrolls on its own, stays
editable while recording, and notices sit above it; with settings or info
open the panel scrolls so they stay reachable.
The Scripture chip reads gospel.charts.newTestament and fullBible, so the two
gospel.res copies had no reader. In Arabic the count now follows a label
("number of parts: {n}"), which is correct for every n.
Links on partner pages write band=yes|partial, and the reader split on ',' only,
so such a link read back as one value and matched no row. Reading now accepts
either separator; writing stays ','. A literal '|' in a value is percent-encoded
before any join, so every separator left in a URL is a real one.
With no facet filter and no selection, a table narrowed by typing exported every
row in the source. The export now takes an optional search transform, and the
data table passes the matcher it already uses for its visible rows. An explicit
selection still outranks the search, and a caller that passes nothing is
unchanged.
A shared table link can now be written as one query param per facet, and a
param name may map to a different bus key, so links already published on
partner pages keep working. The CSV export matched no rows once a reader picked
a second value in a filter; the default row match now reads a multi-value filter
as OR, and the gate no longer exempts that line.
Card BUILD-SHAREABLE-FILTERED-TABLES, for researchers and media-to-movements teams: narrow the table to
the peoples you are working on, then give that exact view to a teammate or reopen it on a phone.
TWO GAPS, both measured on main before building.
1. IT WAS NOT REUSABLE. The share hash was a PRIVATE function inside
plugin-designs/research-dashboard/src/composables/useDashboardShell.js, so exactly one dashboard in
the fleet could produce a link. go-int's board had none — grepped, zero matches — and every future
kit dashboard would have had none either. Nothing in it was research-specific except its address.
It is now library/composables/useShareableView.js, parameterized: the bus and labelFor are passed
in, the filter and search param names are options, and no dashboard is named inside it. The
research dashboard supplies the one genuinely research thing — labelFor, for the display-only slug.
2. THE LINK WAS INCOMPLETE, AND SILENTLY SO. The hash serialized filterSet ONLY. Typing in the search
box is the commonest way this persona narrows a table, so a shared link reopened with MORE ROWS than
the sender saw, with nothing to tell the recipient their set was wider. For population data passed
between teams that is worse than a link that plainly fails. `q` is appended only when a term is
present, so a dashboard with an empty search box writes byte-identically the hash it wrote before.
`&` joins params now, so it is reserved in values alongside `;` `:` `*` `,` `%` — otherwise a search for
"rice & beans" would truncate the link at the ampersand.
check:share-policy grows a DRIVEN arm: the two pure functions are lifted from the kit source and RUN,
so nine guarantees are measured rather than asserted — the old format byte-for-byte on an empty search,
slug still display-only, multi-value still dropping the slug, the typed search reaching the link and
coming back, a literal comma in a people-group name surviving, an ampersand in the search surviving, and
both legacy shapes (JSON hashes, and old #f= links with no q) still restoring. No browser needed to
prove a string round-trips.
Source only. Rebuild: research-dashboard, dashboard-plus-grid.
check:filter-multivalue arm C follows the file: it lifted escapeValue/unescapeValue from the shell to
drive the hash round-trip, and said so plainly when they moved ("the hash format moved and this gate is
blind to it") rather than passing vacuously. Repointed to the kit module.
gospel-plugin's field card (f0798a67) shows them with English inline until the keys exist. share reuses each
locale's existing buttons.share wording; linkCopied is new. From dx-shepherd (CARDKEYS).