The Driver's ask: one folder per BUNDLE with its ELEMENTS inside, bundles sorted by capability, highest
first. The rail listed every element flat under its own capability, so a bundle's elements were
scattered (gospel-resources' five screens sat in three sections). Now each capability section (5 → 1)
holds the bundles whose HIGHEST capability it is, in name order; a bundle with several elements is one
folder (country-posters, gospel-resources, my-upg-100-list, regions-dashboard, cities-internet), and a
single-element bundle is shown as its element. Composites keep their folded composition trees.
StudioTree identifies a plugin's row by a `canonical` flag rather than "depth 0", so auto-reveal opens
the bundle folder above an element chosen by URL or restored tab. New folder icon; 'elementsCount' and
'elements' in all 11 studio catalogues.
check:studio-rail (headless, gates:post) runs the studio's real rail code on the built manifest and
asserts the layout and one canonical node per plugin (21 bundles, 33 elements); it fails on the previous
rail. Also: poster.json:dpi is declared identical on purpose in check:i18n-never-translated (a unit
symbol kept as-is by project decision), which had turned that gate red on main.
THE GAP rega NAMED AND I LEFT OPEN: "the gate passed: it counts rows, not the
row's language." It was right. When the list's label was English among Devanagari,
every assertion on that page was green — sheets, rows, bars, share line,
zero-populations. I asserted the page's STRUCTURE and nothing about whether its
prose reached the reader, which is my own standing rule turned on my own gate.
IT ONLY WORKS IN A NON-LATIN-SCRIPT LOCALE, and that is exactly why it is worth
having rather than aspiring to a general "is this translated?" test, which is not
decidable. On a hi/ar/ru/zh sheet a run of Latin letters is a proper name, a
number, or an untranslated string — three possibilities, two of them enumerable.
In de/fr/es an untranslated string looks like ordinary text and this measures
nothing, so those locales are NOT covered and the gate says so rather than
implying coverage it does not have. rega offered to chase the de/fr/es case; I
declined it for that reason, not for cost.
MEASURED FIRST, ASSERTED SECOND. Across every non-Latin packet in out/ (hi, ar,
ru, zh) exactly TWO Latin runs appear, both the data source's name. That is what
makes a floor of "nothing else" defensible instead of a guess.
THE ALLOWLIST IS THREE ENTRIES AND ALL THREE ARE DECISIONS, NOT BOOKMARKS:
IMB, peoplegroups.org — the publisher and its domain. A reader following the
citation meets the English name at the destination, so translating it would
hide the thing it identifies. Same reasoning the i18n allowlist uses for
"Open Bible Stories" and "Scripture Earth".
Project — the campaign designation. Digits break a run, so "Project 4700"
surfaces as "Project" and the numeral is never part of the match; the WORD is a
name, while the description beside it ("frontier people groups") is prose and
must be translated. That is the line check-i18n-never-translated draws around
"UUPGs", applied in the other direction.
A fourth entry must state whether it is settled or deferred: an allowlist that
does not distinguish the two turns an open defect into a verdict.
MUTATION-TESTED AGAINST THE REAL DEFECT, not a synthetic one. Restoring the
pre-5c254970 English list label on the hi sheet reports ['Project', 'frontier
people groups']; replacing a translated key with English reports ['THIS packet'];
the current sheets report clean. So it fails on the bug it was written for.
Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and
this new script check — 6 non-Latin packets actively checked, 12 correctly out of
scope. The gate also prints the runs it found, so a reader can see what it
measured rather than trusting a silent pass.
TWO FINDINGS FROM rega'S ?list= RENDER, and the first one is a hazard I created
in the same commit where I argued against it.
I MADE PACKET_LIST FAIL CLOSED because "a packet that showed the wrong list is
printed and handed to someone" — and then wrote the frontier packet to the SAME
FILENAME as the default-list packet of that zone and language. rega measured the
result: out/packets/ held frontier packets under the DEFAULT names, beside an `ar`
file that was still the default list, and nothing in the name could tell them
apart. Guarding the input while leaving the output ambiguous protects against
asking for the wrong list, not against being unable to tell what you got.
Fixed both ways. The list is in the FILENAME
(asia-china-tibet-autonomous-region.frontier.hi.html) and STAMPED INTO THE
DOCUMENT as data-packet-list, because a filename can be renamed, copied or
emailed while the packet itself should be able to say what it is. Same reasoning
as data-packet-build: out/ is gitignored, so a sweep grades whatever files happen
to be there, and a packet that cannot describe its own provenance makes an
accidental set indistinguishable from a current one. The gate prints it too.
THE LIST'S LABEL WAS ENGLISH IN EVERY NON-ENGLISH PACKET. "Project 4700: frontier
people groups" sat among Devanagari labels in a Latin serif face, because it came
verbatim from library/lists/lists.json — which carries ONE label per list and has
no locale dimension at all. The gate passed because it counts rows, not the row's
language.
It is translated in packet-strings.json rather than by restructuring the shared
lists.json: that file is the packet's OWN catalogue, where a new language is a new
block and never a code change, and it degrades per key like every other string. An
unknown list still falls back to the data's English label.
THE NUMERIC DESIGNATION STAYS — "Project 4700" / "مشروع 4700" / "4700 计划" —
because it names the campaign, while "frontier people groups" is prose. That is
the same line check-i18n-never-translated draws around "UUPGs": translate what a
bilingual reader would not dispute, keep what is an identity.
rega'S MEASUREMENTS, all confirming predictions I could only compute: 6 pages, 37
of 43 records, rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37, bars
1080/640/415/184/152/26 identical in en and hi, share "2.1% of the 1,745" with no
"1 of 27 lists", provenance-only footnote, fail-closed rc=1 on an unknown list.
And the number I most wanted: share-to-footnote clearance 314px en, 222px hi — so
hi+frontier, the tightest combination that exists (128px rows, gap down to 147px),
still clears by 222px and no label wraps.
Verified: all 18 packets in out/ pass sheets, rows==bars and zero-population.
The 11 laos packets FAILED the zero-population assertion until regenerated — they
predated the fix, which is the gate distinguishing stale artefacts from current
ones exactly as data-packet-build was added to do. Default path unchanged (43
groups, 7 sheets, 5 rows). frontier renders 6/6 in en, ar and hi with the label in
each script. check:packet-strings-parity PASS with the 5 new keys.
A fixed 7 s wait shot the upg-100-list counter over a bare basemap: that leaf paints 9.5-16.5 s after
load (rega, 3 cold loads at fc2fa2e4). Every map a shot's page or frames create is now recorded (an init
script on the engine global's Map.prototype.setStyle, as in rega's probe), and the capture waits until
every visible map has its style and tiles loaded and has drawn features on OUR layers (GeoJSON sources;
the basemap's are vector tiles), capped at 60 s with a WARN naming the unready map. Each shot's `wait`
becomes a 3 s floor, and the full fallback for a surface with no map. Not browser-run here (browsers are
rega's): the next shot run is the proof.
rega measured it at fc2fa2e4 (3 cold loads, tokenless, judged by rendered features on the leaf's own
layers): first paint +9.5 to +14.3 s, idle +12.1 to +16.5 s. The 7 s wait then network-quiet plus
1.5 s made the English shot a coin toss; the Arabic shot of the same leaf (12 s) only won it. Both go
to 20 s. A stopgap: the lasting fix is to wait on the map (style + tiles loaded, rendered features)
rather than a timer.
research-dashboard and dashboard-plus-grid imported '../doxa-research-map/src/colors/…' — another plugin's
private code — which is how two copies of the same palette drifted (C1a moves them to library/colors).
The gate refuses any relative import that resolves into plugin-designs/<other>/ below that bundle's root;
a composite mounting another bundle's PUBLIC profile .vue (the capability-4 pattern) stays allowed. It
FAILs on main (the 4 palette imports C1a removes) and PASSes with C1a; --self-test in gates:post.
library/lists/lists.json carried one English label per list, so every surface
that showed a list's name printed English in every locale (reported by upg100,
which worked around it inside the packet's own strings).
- A `lists` catalogue area, library/i18n/locales/<lc>/lists.json: a label and a
definition for each of the five lists, in all 11 locales.
- listLabel(id, t) / listDefinition(id, t) in peopleGroupList.js resolve
lists.<id>.label through the caller's translator (useI18n's t, tStatic, or any
key -> string), accept ids and aliases ('700'), and fall back to the
registry's English. The module still imports no i18n runtime, so it stays pure
and testable in Node.
- The registry's labels are now documented as the English fallback only, and the
doxa list gains the definition its catalogue entry has.
- check:list-prop-unit (24 cases, was 17): the Arabic and German catalogues come
through, a missing translator or a raw key falls back to English, the English
catalogue must equal the registry word for word (a drifted registry label fails
it, naming the list), and every locale must carry both fields for every list.
- documentation/reference-universal-props.md: read names through listLabel.
Headless: list-prop-unit, i18n-parity, i18n-never-translated,
no-free-identifiers and universal-props pass. No consumer calls listLabel yet
(the packet has its own strings), so nothing renders differently until one does.
The province table and the map's province layer both read gb-IND-ADM1.geojson,
which is the UPG-100 list's curated 34-unit partition — not a country's states.
Eleven groups came out with no province because they were standing in states that
partition does not draw. Both now read the complete ADM1 that landed with the
internet work (36 states, geoBoundaries via DataMeet India): 998 assigned becomes
1,005, 44 province rows become 46, and the rows name Indian states.
Four groups are still unassigned and each one is legible: Great Andamanese and the
Sentinelese sit on islands whose simplified coastline their point falls just
outside, and Jing and Pana are on China's Vietnam and Laos borders, outside its
twelve-province subset. They are listed with their coordinates, not dropped.
The staged-assets gate now also asserts the map and the table read the SAME
geometry. Staging could never catch this: both files were staged.
A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (e3d25ff6) and this profile already honours it — applyPeopleGroupList()
filters the rows at all four fetch sites, packetRecords included — and the
membership files are on disk. So under ?list=frontier-500k the packet's rows were
the 71 network groups that are Project 700, while the funnel took its denominator
from the last step of vision-funnel.json and printed "of the 2,107". Off by a
factor of 30, with nothing on the page looking broken.
Measured from data/people-groups/lists/*.json (counts.doxa, what a Tier A map
shows): frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 ·
the default 2,107.
THREE NUMBERS WERE WRONG, NOT ONE, because all three descended from one total:
the share's denominator; the footnote's "N are in no list" (total minus assigned);
and "1 of 27 lists", which is a property of the UPG-100 ZONE CATALOGUE, not of the
active list — the zones partition the default list and nothing else.
A non-default list now gets its own step, labelled with the list's own label, so
the narrowing does not jump from one list's total to another list's packet.
AND EACH CLAUSE IS SUPPRESSED SEPARATELY, because they are provable separately.
A first pass dropped the whole share line whenever listCount was absent, throwing
away a true statement ("5.4% of the 1,745") to avoid an unprovable one. The
footnote splits the same way: provenance holds for every list, coverage does not.
⚠ The footnote is NOT split by parsing the translated sentence — hi ends in "।"
and zh in "。", and reading translated prose for structure is the trap
check-packet-render's own comment warns about. It is a second key.
⚠ And `funnel.unassigned ?? 0` would have printed "0 of the 1,745 are not yet in
any list" — a confident falsehood whose zero reads as a completeness claim.
── AND THREE FINDINGS FROM rega'S RENDER, none of which I could see ──────────
"0 people" PRINTED ON THE READER'S ROW OF EVERY PACKET. Number(null) is 0 (only
Number(undefined) is NaN), so `Number.isFinite(Number(x))` admitted the
deliberate `population: null` marker as the number zero. I asked "is this a
finite number?" when the question was "did anyone give me a population?" — the
same presence-vs-value confusion as inferring "did it work" from a field that
answers "did it isolate". Zero is suppressed too: a people group with no people
is missing data wearing a number's clothes.
THE SHARE LINE READ AS PART OF THE FOOTNOTE. Measured: 218px below the reader's
row, 42px above the footnote, because the last row kept the full inter-row gap.
Proximity is the only thing grouping them, so an even gap grouped the share with
the wrong element. The last row's margin is now 14px: share 14px under its row,
288px clear of the footnote.
THE GATE ASSERTED PAGE 2 AND PRINTED NOTHING ABOUT IT. rega: "a reader cannot see
WHAT it asserted." A silent pass is the mirror of a printed-but-unasserted number
— the check is real and the evidence invisible. It now reports rows, bars and the
share line, and gained two assertions: every row must have a BAR (the reader's
step had none, so the funnel never visibly narrowed to "yours"), and no row may
print a zero population.
Verified: all five list cases render their own true numbers — doxa keeps the full
claim, unreached/frontier/unengaged get their own step and a percentage with no
false "1 of K", frontier-500k reads "100% of the 71" with the duplicate row
suppressed (a step that repeats its neighbour is not a step). The four fixtures
are byte-for-byte unchanged on the default path, so the list-awareness is purely
additive. check:packet-strings-parity PASS with the three new keys. Both new gate
assertions mutation-tested in en and ar: removing a bar is DETECTED, injecting a
zero population is DETECTED. 5 rows = 5 bars, 0 zero-population rows, "0 people"
absent. Six-row layout recomputed: fits with 318px headroom, 206px if every label
wraps. BUNDLE=my-upg-100-list vite build clean.
NOT re-rendered in a browser: rega measured the previous state and these are
responses to it, so the new geometry wants one more look.
THE HALF I OWED. check-packet-render gained --expect-funnel last commit so a
packet with no funnel page can fail — but nothing passed the flag, and I said so
to regions-plugin while advising them not to reuse the pattern as though
declarable meant covered: "I have traded an invisible defect for a visible
obligation, which is better but not the same as fixed." This is the obligation.
check-packets-all now passes --expect-funnel for every packet it sweeps.
WHY IT CAN BE UNCONDITIONAL. Every packet in out/ is produced by
library/scripts/posters/packet.mjs, which always passes FUNNEL to buildPacket. So
a packet there without page 2 was either generated before the funnel existed or
by a caller that dropped the argument, and this sweep should say both out loud.
Verified across all 16 packets in out/ — the 4 fixtures, the Arabic tibet, and
all 11 laos locales, which I regenerated for this: 95 groups, 9 sheets each (was
8), 95 photos, page 2 present in every one. So the requirement matches a property
the pipeline actually holds rather than one it aspires to.
THE ESCAPE HATCH IS DELIBERATELY AWKWARD: PACKETS_ALL_NO_FUNNEL=1, which prints a
warning. A legitimate funnel-less host is a real state and deserves to be
recordable, but it must be a decision someone takes rather than a default that
quietly tolerates a missing page.
Verified: node --check clean; the arg construction exercised both ways (flag
present by default, absent under the env var). All 16 packets satisfy the sheet
arithmetic with 0 failures. Hindi laos re-checked as the longest-label locale —
5 rows, 5 bars, 0 Devanagari digits, longest label 51 chars so the wrap slack is
not even consumed.
NOT re-run end to end: check-packets-all spawns check-packet-render per packet,
which launches Chromium, and the Playwright policy reserves browsers to rega.
That is also how I learned this wrapper starts browsers — I ran it earlier
without realising and reported it.
TWO FIXES FROM A BROWSER RUN I COULD NOT DO MYSELF. rega measured page 2 in
print media and found the opposite of the defect I feared: no overflow, but the
four global steps ended at y~330 of 1056 while "43 · are in THIS packet" sat
alone at y~965, leaving the middle ~60% of the sheet empty so the reader's own
step read as a detached footer. And it had NO BAR, so the funnel never visibly
narrowed to "yours" — a funnel whose final term is not drawn as part of the
funnel fails at the one thing it is for.
So the five steps are now ONE list with the reader's included, and it has a bar
like the others: 1080 -> 640 -> 415 -> 184 -> 26px, the last two tinted so the
network's list and the reader's list read as one thought. The 26px floor is what
keeps the smallest list a visible sliver rather than a hairline.
THE ROW RHYTHM IS DERIVED FROM MEASURED ROW HEIGHTS, not guessed. A first
attempt subtracted a magic 118px as "about one row" and left 418px of dead space
at the foot — rega's emptiness, just redistributed. Summing the real heights
closes it to ~298px.
AND IT KEEPS DELIBERATE SLACK, because two runtime facts are unknowable at
layout time: whether a step carries a population line, and how many LINES its
translated label wraps to. Arabic and Hindi labels are far longer than the
English, so LINE_SLACK reserves one extra wrapped line per row. Computed: 298px
headroom normally, 196px even if EVERY label wraps an extra line. Tight-packing
this page would trade "60% empty" for an overflow in the locales I cannot eyeball.
── AND A GATE HOLE A NEAR-MISS EXPOSED ──────────────────────────────────────
regions-plugin reported, unprompted, that a merge resolved with
`git checkout --ours` silently deleted the printPacket call site supplying the
funnel data — 60 lines — AND EVERY GATE STAYED GREEN. They filed it as merge
discipline. It is also gate design, and that half is mine: the funnel page is
OPTIONAL by design (buildPacket omits it when the arg is null), so a packet
without page 2 is not malformed, just smaller. My own choice made the deletion
invisible.
--expect-funnel closes it. The wrong fix is to always require the page: the 11
pre-funnel laos packets and any host with no funnel data would go red, and a
false red costs as much as a false green. Instead the CALLER, which knows whether
this packet was built with funnel data, declares it. An absence is then either
DECLARED (no flag, fine) or a FAILURE (flag passed, page missing) — never a
silent nothing.
Verified: all 16 generated packets satisfy the sheet arithmetic, 0 failures,
including the 11 laos packets that have no funnel page. fixture-tibet renders 5
rows with 5 bars ending in 43. Arabic regenerated: 7 sheets, dir="rtl", 5 rows,
5 bars, ZERO Eastern-Arabic digits on the sheet. Mutation-tested --expect-funnel
three ways: baseline+flag PASS, funnel-sheet-deleted+flag FAILS, deleted without
the flag PASSES. Flag filtered from the positional read, so a bare
--expect-funnel is not eaten as the packet path (that trap has bitten this repo
three times). BUNDLE=my-upg-100-list vite build clean, +141 bytes vs main.
NOT re-verified in a browser: check:packet-render needs Chromium, reserved to
rega. The geometry above is computed, not rendered — the print-media layout is
exactly what rega's eyes caught that my arithmetic did not, so this wants one
more look.
The served gb-IND-ADM1.geojson is the 100-list's curated partition (Mizoram and Nagaland excluded,
listId-tagged), so a map colouring every state had data for 36 and shapes for 34. data/boundaries/
gb-IND-ADM1-full.geojson is all 36 from geoBoundaries gbOpen (boundaryID IND-ADM1-1811400, DataMeet /
Election Commission of India, CC BY 2.5 IN), with source and licence in gb-IND-ADM1-full.meta.json;
generate-tree stages both, and the dev server serves data/ directly. Reproducible with
library/scripts/boundaries/source-full-adm1.mjs (coordinates rounded to 3 decimals: 1.84 MB). The curated
file is untouched.
It claimed to read "only the PALETTE export" but matched every hex from `export const PALETTE` to the
END OF THE FILE, comments included. A later note such as "REJECTED #AF7AC5, scored 2.95, under the 3.0
floor" (regions' affinity-block.js, being prepared) would have obliged every dashboard to ship a colour
rejected on WCAG grounds. paletteHexes() now slices to the export's matching closing brace and reads
hexes only inside string literals. --self-test (check:one-palette:self-test, gates:post) plants a hex
in an inline comment, a block comment, a trailing comment and a later export, and requires only the
two real values back. The verdict on main is unchanged (PASS, 6 palettes).
d6bf410a (a merge) committed conflict markers into 10 built files under public/ (big-picture,
dashboard-plus-grid, research-map, research-dashboard, both manifests) and every precondition passed:
a built bundle with markers throws on load while its SRI hash still matches. Step 0 now scans the
COMMIT's tree with git grep (1.3 s, every file, whatever the working tree holds) and refuses.
Run on d6bf410a it names exactly those 10 files; --markers-self-test builds a throwaway commit object
holding a textbook conflict under public/ and requires the scan to refuse it (PASS).
9384c1ba (simpleresearch) captures the instance in setup, so research-map.vue's
map-filtered is dispatched again, and the gate correctly reported the leftover
LATE_KNOWN entry as stale. It failed on main 688ba6cf with exactly that one
finding. With the entry removed, the gate passes and enforces the rule for
research-map.vue again. The two reflectDir entries stay: those sites are
unchanged.
The credit line said who translated the Scripture and called them "the likeliest
people to ask", then stopped at a name. 838 of the 929 credited groups now reach
that organisation's own homepage from the sheet.
The URL does not come from eBible's homeDomain field, which is still dropped: it
comes from a hand-screened list of 16 organisations, every one fetched on the run
that wrote the table. Where a credit names two, the one it LEADS with wins, so
"Pioneer Bible Translators and The Word for the World International" reaches
Pioneer and not the other way round.
National Bible Societies hold 89+ credits and are deliberately linked to none of
them: several UBS members publish editions carrying the deuterocanonicals, so
whether a Society contact sits inside the 66-book filter is a ruling to be made,
not one to assume. They keep the name without the link, and the count says so.
A contact is screened as somewhere to write to, not as a publisher this map reads
text from — two separate lists, so wycliffe.org appearing as a scripture link is
still a failure. Four organisations answered a datacentre IP with a bot challenge
while serving browsers normally; those are proved by robots.txt and the table
records which method proved which.
pplr-data-maps and the Wall each carried the same ~40-line d3 loader (pinned, SRI, and a trap that keeps
the UMD from writing into a host page's own window.d3). It is now one library helper, fed from the
CDN registry. check:pinned-umd (headless, gates:post): the tag carries the pinned integrity, a host's d3
gains no keys, and a failed load rejects leaving the host's d3 in place. Both bundles build.
library/i18n/index.js globbed every catalogue area into every bundle: 25 areas x 11 locales, other
plugins' strings included, and each new area grew every bundle (CRITIC-10).
- internal/i18n-areas.mjs walks a bundle's SOURCE import graph (relative, @map/, dynamic import,
import.meta.glob) and keeps an area when its name appears as a key prefix or a bare string literal
anywhere reached; runtime-fetched data carries no i18n keys (measured), and every rule errs toward
including.
- vite.config.js: in a BUNDLE build, a pre-transform narrows index.js's glob to those areas; dev keeps all.
- check:i18n-treeshake (gates:post): every area-prefixed key literal in each BUILT bundle belongs to an
included area. Poisoned (one area dropped) it fails.
Measured, raw (gzip): people-groups-grid 611,595 -> 210,308 (203,176 -> 72,658); wall 659,876 -> 332,644;
doxa-simple-map 902,907 -> 686,910; research-dashboard 843,625 -> 637,940.
P1 rendering. Opening India or China now draws its provinces, coloured by the
same ladder as the country beneath them, and tapping one gives its groups, its
people, how many have nothing, the largest of those and the way in.
It arrives on intent rather than on load. India's ADM1 geometry is 1,628 KB —
nearly the size of the whole world ADM0 file — on a leaf that already loads about
4 MB and is meant to be embedded on thousands of partner sites. Loading it for
the 173 countries nobody is looking at would tax every reader for the benefit of
two. Opening one of the two IS the signal; China is 54 KB, and both are cached
after the first open.
The counts come from the measured table, never recomputed in the leaf. The
point-in-polygon belongs in the build, and a second implementation here is how
two surfaces come to disagree. Provinces repaint when the mode changes, because a
country saying one thing while the province on top of it says another is worse
than having no provinces at all.
The panel repeats that placement is approximate, and that the way in was chosen
for the COUNTRY — which is exactly the limitation going below the border exposed.
Also fixes the gate I added for this, twice. The province fetch interpolates its
filename, so the parse captured the literal prefix "boundaries/" — a real
directory of 4,096 bytes that always exists, proving nothing. The first fix
expanded that prefix against the filesystem, which was worse: unstaging a real
geojson simply shortened the list and the gate stayed green on nine assets
instead of ten, exit 0. The expectation now comes from the filenames the leaf
names, so a file the map asks for and the tree lacks fails. Poison-tested both
ways.
The library's pin and region popups (useMapEvents) interpolated data fields raw into the HTML handed to
setHTML(); a name like <img src=x onerror=…> from an API or a partner's list would run in the host page
(CRITIC-3). library/utils/html.js adds escapeHtml and safeUrl (http(s) only); every field of both popups
goes through them. check:popup-escaping (headless, gates:post) mounts the real composable, renders both
popups from hostile data and requires no markup, handler or javascript: URL to survive: it FAILs on the
previous code (both popups) and PASSes here. check:cdn-pins and check:no-eval join gates:post too.
vue-i18n's default message compiler builds every message with `new Function`, so 20 of 25 bundles needed
'unsafe-eval' and a partner with a strict Content-Security-Policy got no translated text (CRITIC-4).
vite.config.js defines __INTLIFY_JIT_COMPILATION__ (vue-i18n 9.14: messages compile to an AST, no eval),
and the bundler drops the eval path: rebuilt doxa-simple-map carries 0 `new Function` (1 before).
check:no-eval (gates:post): no built bundle contains new Function / eval, and vue-i18n in JIT mode renders
real en/ar catalogue messages with Function and eval blocked. documentation/how-to.md lists the CSP an
embedding page needs (style-src 'unsafe-inline' for the shadow <style>, worker-src blob:, tile hosts).
maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime loader set `integrity`,
so a CDN could serve any bytes to every partner's visitors unnoticed (CRITIC-5).
- library/constants/cdn.js is the one registry: mapbox-gl 3.24.0, maplibre-gl 5.24.0 (the release the
@5 range already resolved to), both geocoders (1.9.4 / 5.0.0) and d3 7.9.0, each with its sha384.
Every host answers Access-Control-Allow-Origin: *, so integrity + crossorigin work cross-origin.
- ensureEngineScript / ensureEngineCSS, the geocoder loaders (library and PPLR) and the generator's
static MapLibre tags set integrity + crossorigin from it; the embed snippet's engine preload matches.
- check:cdn-pins (gates:post): no floating CDN url in shipped code, and every one is in the registry;
--online re-fetches each and compares the hash (all 9 match today).
MapLibre stays on 5.x knowingly. GHSA-jrc7-96c5-q579 (DOM.sanitize, reached through a source's
attribution string) affects <= 6.4.0, and 6.x is ESM-only (no UMD global), so the upgrade is a loader
migration of its own. The attribution strings rendered today come from our configured basemap styles
and our own sources; the note in cdn.js says who is exposed until then.
check:composites-mount gains --all-profiles: every .vue profile of every bundle, and every preset instance
with its props (62 x en/ar, 124 mounted clean, ~38 s), in gates:post. Its entry defines are now recorded
once per bundle per locale (a bundle's second profile read an empty record). Scope stated honestly: this
is a SOURCE mount; an error that exists only in the built bundle (a minified name hoisted out of order)
needs a mount of the build and is not covered here.
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
Page 2 of every packet is now the funnel: 12,379 people groups on earth, 7,341
unreached, 4,754 frontier, 2,107 on this prayer network's list, and N of those
in the reader's hand. A partner holding one list can see where it sits in the
whole, which is the argument the packet is making.
★ IT RENDERS NUMBERS IT DID NOT COMPUTE. Every count is read from
data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates
from the IMB CSV and the live network list and which already drives the Vision
Card. Re-deriving them in the packet would give one fact two sources; they would
drift, and two internally-consistent surfaces disagreeing is worse than one
wrong number because a reader cannot tell which to trust.
K AND THE SHARE ARE MEASURED AT RENDER TIME, NEVER TYPED, and this mattered:
the brief said "~30 lists of 50-100" and the catalogue says K=27 with counts
from 8 to 118 (west-africa 118, asia-india-rajasthan 116, uncovered-scatter 8).
Only about half sit in that band, so no copy claims it. The page states what the
data can defend -- "1 of 27 lists" and "N of the 2,107" -- and a footnote states
plainly that 21 of the 2,107 are in no list, because a partner adding up rows
would find the gap anyway. Reported to the Driver; K=27 pending his ruling on
whether the two residual buckets count.
A SHARE MUST NEVER ROUND TO ZERO. One decimal gave the single-group fixture
"0.0%", which reads as "this packet covers none of them". Verified exhaustively
over all 2,108 possible list sizes against 2,107: no size prints 0 and none is
malformed. 1 group -> 0.05%, 20 -> 0.95%, 21 -> 1%, 43 -> 2%, 118 -> 5.6%.
NUMERALS STAY WESTERN, as required. num() is toLocaleString(lang), and CLDR's
default numbering system for the bare ar/hi tags is latn -- verified 12,379 and
2,107 in both. Reusing num() rather than writing a second formatter is also what
keeps the funnel's digits identical to every other page of the packet.
PAGE COUNT NOW COUNTS THE ARTIFACT. packet.mjs computed
`1 + ceil(n/PER_PAGE) + 2` under a comment claiming the number was "asked of the
renderer, not recomputed here". It WAS recomputed, and the funnel page silently
made it wrong by one -- exactly the drift the comment warned of. It now counts
the emitted sheets.
THE GATE IS TAUGHT, NOT LOOSENED. check-packet-render went red on every fixture
("43 cards should lay out as 6 sheets, but the packet has 7") -- correct
behaviour on a structure it had not been told about. The wrong fix is to accept
N or N+1: that assertion exists to catch silently dropped people groups, and a
±1 tolerance is the slack a lost list page hides in. So the funnel is DETECTED
via .funnel-title and added as a known term, and a funnel page that renders its
title but no share figure is now its own failure -- a number printed but not
asserted on is decoration.
The page is OPTIONAL: buildPacket omits it when no funnel data was supplied, so
every existing caller keeps working and a host without the data still gets a
correct packet. Better a 4-section packet than one asserting figures it could
not read. A multi-list batch selection keeps the steps but drops "1 of 27",
which is false for a custom selection, while "N of the 2,107" stays exactly true.
Verified: check:packet-strings-parity PASS (31 string keys x 11 locales, my 10
new ones present and translated in all 10 non-English blocks, 0 declared-absent
additions). A partial locale with 3 of 10 funnel keys translated renders those 3
translated and the other 7 in English, nothing blank. Arabic packet generated:
dir="rtl", funnel is sheet 2 of 7, all four steps carry Western digits. All 16
generated packets satisfy the corrected sheet arithmetic, including the 11 older
laos packets that have no funnel page. Mutation-tested: dropping a list sheet is
caught by the independent page-header witness (20 DROPPED), a funnel page with no
share figure is caught by the new assertion, and removing the funnel page passes.
BUNDLE=my-upg-100-list vite build 319 modules clean.
NOT verified in a browser: check:packet-render launches Chromium, which the
Playwright policy reserves to rega. The simulations above parse the same
attributes from the same generated HTML.
verify-map-paint.mjs:293 asserts shadow && glContext && attribution &&
!asksForToken && cssOk && no-real-errors. Every one of those is TRUE of a
flawless basemap carrying none of our zones, polygons or pins, so a data-layer
regression lands green. Measured by starve-probe (abort our
/assets/{boundaries,tables,people-groups}/ and people-groups API requests,
leave the basemap untouched, then read exactly what verify-map-paint reads):
NINE of its thirteen leaves still report a live canvas, live GL, attribution
and ZERO page errors with none of our data on them. The hole is the majority of
the suite. The other four lose their canvas entirely, so the existing gate
already fails them and they are deliberately not listed here -- a leaf list is
a claim about where a failure would otherwise go unseen, not a trophy cabinet.
TWO MEASURES, PASS ON EITHER, because each alone produces a false red on a real
leaf. dashboard-plus-grid's map is a 686x340 pane whose basemap already
contributes ~846 distinct colours, so removing a choropleth deletes AREA
(26.4% of pixels) while barely moving the palette (ratio 0.788 -- I nearly
reported that as a defect). demo-map is the mirror: palette collapses to 0.196
while only 5.0% of pixels change. A map with NO data moves NEITHER.
A global floor cannot work here at all: check:map-paints requires 40 distinct
colours and a starved upg-100-list scores 306.
THE INSTRUMENT MATTERS MORE THAN THE THRESHOLD, three findings:
- drawImage/readPixels straight off a GL canvas returns an EMPTY buffer
(preserveDrawingBuffer:false). Three leaves read colours:1, nonzero:0 while
visibly full of colour. page.screenshot({clip}) goes through the compositor.
- elementHandle.screenshot() waits for the element to be STABLE, which an
animating dashboard canvas never becomes; it timed out on three leaves.
- A FIXED WAIT MAKES THE GATE FLAKY AND THE FLAKINESS READS AS A DEFECT. With
waitForTimeout(10000): countries-map measured 1868 distinct colours then 54,
and research-dashboard PASSED at 19.1% then timed out, on identical code
minutes apart. The fix is a readiness predicate, not a longer clock --
isStyleLoaded() && areTilesLoaded(), reachable on ANY leaf without a
per-profile handle via host()._instance.provides.mapStore. countries-map
then reported 1862->194 / 46.6% twice, settling in 12.7s and 10.5s: the old
10s wait was 2.7s short, which a clock cannot tell you and the map can.
IT REFUSES TO JUDGE RATHER THAN GUESS, exit 75 (never 0): no canvas, a starve
that aborted 0 requests, images that cannot be compared, a readiness that could
not be measured, or the gate itself throwing. That last one is not theoretical
-- the first run died on a Playwright arity error and exited 1, which a wave
runner reads as "these maps have no data on them". Three leaves are EXCLUDED
with their measured reasons rather than left to emit a permanent no-measurement
that reads as coverage.
--poison starves BOTH runs so neither measure can move and every leaf must go
red; it is mandatory because "either measure moved" is a weaker claim than
either half, and a widened assertion that still passes everything reads as
coverage while proving nothing.
Choosing MST on the research map built and sorted every pair of pins (~2.2 million edges for the
2,107-pin roster), ~12 s on the main thread, and then did it all again for a log line. The page froze,
and check:clustering-modes read the map before any line existed: "MST drew no cluster lines".
- MSTClusteringUtil runs Prim over the implicit complete graph: O(n²) time, O(n) memory, no edge list.
Starting a new tree when the nearest outside point is beyond maxConnectionDistance gives the same
spanning forest as before.
- useMapClustering no longer recomputes every tree for its stats log line.
- check:mst-clustering (headless, in gates:post): same edge count and total length as a reference
all-pairs Kruskal on 2-400 pins, capped and uncapped; 2,107 pins in 872 ms here (budget 3 s).
On the previous code it FAILs at 11,206 ms.
A reader whose OS asks for reduced motion (prefers-reduced-motion: reduce) still got flying and easing
maps: callers passed { duration: 0 } themselves, and flyToCoords took no options argument, so theirs was
silently dropped. useMapFly now routes every camera move (flyTo, easeTo, fitBounds: 12 call sites)
through one guard: jumpTo, or fitBounds with animate:false, under reduced motion. flyToCoords takes and
honours caller options. check:map-fly-reduced-motion runs the real composable against a recording map,
headless: 7/7 PASS here; 5 of them FAIL on the previous code.
The engine default is now auto (c8fee416): no token means MapLibre, which is what every keyless
partner embed shows, and the Driver wants the README pictures to show that. The script no longer
reads a baked token or fetches doxa.life's, and appends no ?tk=. SHOTS_WITH_TOKEN=1 restores the
old token ladder for Mapbox shots.
Checked headless: vite.doxa-maps-build-config.json tokenUrl is empty and the in-process server has
no /api/maps/token, so a leaf's token ladder ends empty and generate-tree's auto branch mounts
MapLibre with no warning banner (TOKEN_FREE, no 'Token fetch failed' text). node --check passes.
upg-neighbor/flower-mesh is a preset of the upg-neighbor element that opens on
its Flower Mesh lens, which is SVG and has no GL map. The leaf is still a map
element: the view-switcher reaches Send Map and the other GL lenses, so it must
keep isMap (engine peer, token ladder). Marking it isMap:false would break those
lenses. check:leaf-engine-per-provider judged it as a drawing map and failed it
on both engines ("drew: none"), before and after the auto engine alike.
- upg-neighbor.instances.json: the flower-mesh preset says "opensOnMap": false.
- generate-tree.mjs: an instance's manifest entry carries opensOnMap:false when
it is set (absent otherwise, so every other entry is byte-identical).
- check-leaf-engine-per-provider.mjs: such a leaf is not judged as a drawing
map. Its Mapbox-request count under ?provider=maplibre is still measured.
Headless: after bun run tree, the manifest marks only
upg-neighbor/upg-neighbor/flower-mesh with opensOnMap:false. The gate's leaf
selection, replayed over it, judges 28 of 56 leaves as drawing maps, with
flower-mesh excluded and its five siblings unchanged. No browser run.
check:composites-mount and check:composite-interop join gates:post, and composites-mount is also a landing
precondition (3b, ~20-45 s): 08f89f55 blanked Big Picture and Simple Research while every existing check
passed, so a landing that blanks a composite is now refused before it reaches main.
A composite is a composite because its children talk, and every link is a name
written in two files that nothing ties together. Rename it on one side and the
build stays green while the link silently disappears. The interop matrix also
found the other silent way a link dies: research-map.vue looked up its host with
getCurrentInstance() inside a watcher callback, where Vue returns null, so
map-filtered was never sent, and the only proof had used a synthetic event.
Headless, in about a second:
- A: each link's send pattern is in the sender and its listen pattern in the
receiver (17 links across 7 composites).
- B: the literal is in every built bundle that ships an end of the link.
- C: every capability-4 composite is mapped, or states why it has no links
(stack-demo).
- D: no getCurrentInstance() inside a plain function in plugin-designs/ or
library/. The three routed sites are printed as KNOWN, and an entry left
behind after its fix fails.
Negative-tested on 521f8b18, and each of these fails: a receiver renamed in
source, a built bundle out of step, an unmapped composite, a new late
getCurrentInstance (one-line function, arrow, multi-line), and a stale KNOWN
entry. check:no-orphan-gates, check:script-flags and check:quoted-scripts pass.
It is not wired into gates:post; that is the build owner's call.
08f89f55 shipped Big Picture and Simple Research calling getRuntimeLocale,
detectLocale and RTL_LOCALES with no import. The build was green and every
structural gate passed, but setup threw a ReferenceError in every locale and
both composites rendered nothing.
This gate runs the part that failed, with no browser. Vite loads each
capability-4/5 composite the way its bundle entry defines it (configureApp,
plus ProfileLoader's provides where the entry mounts ProfileLoader), and
@vue/runtime-core mounts it on a no-op renderer in en and ar. Any error from
setup, a watcher's first run or a mounted hook fails it, naming the composite.
Measured on 75c4b54e: it fails exactly the two broken composites in both
locales. On 521f8b18 all 20 mounts pass. Throws poisoned into a directly
defined element and into a ProfileLoader profile both fail. It takes about
20 s. check:no-orphan-gates, check:script-flags and check:quoted-scripts pass.
The build config no longer bakes a Mapbox token, and the default engine is 'auto' (Mapbox only when a
token is available). Two gates still assumed the old contract.
- check:nested-token: with the build config's token empty (the shipped default), no leaf and no bundle
may carry a pk. token, and every leaf must carry the runtime engine ladder (EXPLICIT / HAS_TOKEN),
so with no token it draws MapLibre, keyless. A builder who bakes their own token gets the original
check. Fails on the pre-rebuild tree, which still has baked tokens.
- check:provider-switch: the Mapbox baseline is now ?provider=mapbox with a fake host token (its
requests are aborted, never sent); the switch run adds ?provider=maplibre to the same token (an
explicit engine must win); a third run requires the no-token default to make no Mapbox requests.
- check:pin-dim-slider: its description sat in "scripts" under the same key as
its command, so the prose won and bash failed on the apostrophe. It moves to
"//scripts".
- check💓 the panel's bin labels now call T(key, en), defined above the
slice the gate evaluates, so the gate threw "T is not defined". It now passes
the same shape, answering in English.
- check:map-props-unit (Node, 26 cases) and check:auto-engine (browser) had no
script, so check:no-orphan-gates failed. Both are registered; map-props-unit
joins gates:post. auto-engine stays a browser run, outside gates:post.
A shared dev server follows main, so what it serves changes under whoever checks against it; a check
that cannot name the commit it measured is not evidence. /__dev-sha answers "<sha>" (or "<sha>+dirty"),
read at request time. internal/dev-server.mjs: url(), devSha(), waitForSha(sha) (resolves once the
served commit contains it) and a CLI, for headless checks against the shared server on 127.0.0.1:5190
(DOXA_DEV_URL overrides). Its header says when NOT to use it: an unlanded change, and release/embed
checks, which stay on the built tree.
(cherry picked from commit e43310e479)
Headless static gate for the contract in documentation/reference-universal-props.md: scans each
production and experimental bundle's source (and the shipped bundle for strings the minifier keeps)
for a bypass. The bypasses: a mapbox:// style in setStyle or a raw new gl.Map (a style handed to
useMapInstance is translated there and passes), a bare mapboxgl constructor, a literal provider pinned
into useMapInstance, a window.DOXA_TOKEN read, a pk. token literal, and toLocaleString('en-US')
(warn-only until the Driver sets a numerals policy). Documented design exceptions are ALLOW-listed
with their reason. A self-test plants each bypass and one clean case; a blind or over-eager rule
exits 3. Today: FAIL, 7 bypasses in 4 bundles (routed as defects), 12 locale warnings.
The freshness precondition rebuilds every bundle in its scope (~10 min for a library change) and ran
inside the lock, so a build-owner landing held main for 30+ minutes while other landings timed out
behind it. Its verdict depends only on the committed tree and the scope, so a PASS is now recorded
against both (in the repo's git dir). The landing runs the precondition before taking the lock; the
scan inside the lock finds the record and skips the rebuild. A different tree or a wider scope has no
record and is checked in full, as before.
An RTL locale reflected onto document.documentElement whenever it
resolved — so a reader following a ?lang=ar link to a PARTNER'S page
flipped the partner's whole <html>: their headings, their nav, their
footer. The existing comment already worried about the host owning that
decision, but it only guarded against CLEARING dir, never against
setting it.
A generated leaf is the only thing that knows a document belongs to the
plugin, so it says so: window.MAP_OWNS_PAGE, written into all three leaf
templates. library/i18n reads it before touching the document. Nothing
else sets it, so the DEFAULT IS "not ours" — a partner page that
declares nothing is never touched. Absence has to mean restraint for a
flag like this.
The element's own direction was never this code's business: ProfileLoader
binds dir on .profile-loader from effectiveLocale (0f6d5049), so the
plugin mirrors correctly whether or not it owns the page.
★ THE OLD GATE COULD NOT HAVE SEEN THIS, which is why it needed a new
case rather than a re-run. check-embedded-rtl drove the locale through
profile-config — the channel a HOST uses. This defect fires on the URL
channel, which is what a READER uses, and detectLocale() reads the URL
FIRST. Added a ?lang=ar case on the partner page:
before profile-config cases partner html unset / body ltr pass
?lang=ar partner html RTL / body RTL FAIL
after ?lang=ar partner html unset / body ltr pass
plugin root stays rtl throughout — it still mirrors
Standalone leaves are unchanged and verified: ownsPage true, html
dir="rtl", plugin root rtl. The page we own still mirrors completely.
template-literal-check reads source with a simple state machine that does not know regex literals.
The .replace(/"/g, …) in esc() looked to it like an open string, and it stayed "in a string" until a
later quote. Its state happened to re-sync on a `//` inside the forge page template; when 87ebbace
removed that line (the static api.mapbox.com preconnect), the check lost track and flagged a
backtick in an ordinary JSDoc comment (generate-tree.mjs:1084) as a stray backtick in a template.
gates:pre failed for everyone.
The regex is now /\u0022/g: the same character, so escaping is unchanged (verified: 'a"b' → a"b),
and the tree regenerates byte-identical.
Under the Playwright policy only rega runs a browser here, so this
check's result has to come from someone else's eyes. But the partner
page only ever existed INSIDE check-embedded-rtl — two createServer calls
on ephemeral ports — so "open a partner page at a foreign origin" was not
a thing anyone but the gate could do. NEEDS-EYES rows 3, 4 and 6 name a
runnable probe or an exact URL; row 7 was mine, and it named a construct
that lived in my own process.
--emit writes the same pages to disk, with the two-terminal recipe and
what to look at, so the item is reproducible by whoever has the browser.
⚠ THEY GO IN documentation/, NOT _qa-cycle/. I wrote them there first and
then checked: _qa-cycle is gitignored (.gitignore:113), so the files
could never have travelled to the person who needs them — which is the
entire point. documentation/ is tracked and already carries
README-embed-example.md.
⚠ AND THE ?lang=ar FIXTURE CARRIES NO locale IN ITS profile-config —
1100 bytes against 1114. The two channels reach the document-direction
code by different routes and detectLocale() reads the URL FIRST, so a
config that also supplied the locale would let a reviewer believe the URL
had done it when it had not. The first emit produced all three files
BYTE-IDENTICAL, which would have handed someone three pages they could
not tell apart; I only caught it by checking the emitted sizes rather
than trusting that the flag had taken effect.
The CASES array now carries the ?lang= row itself, so this stands alone:
the emit produces every fixture committed beside it. It previously
depended on the library/ commit for that row, which left the repo with a
fixture the tool would not regenerate — worse than either half.
Also: --emit was being consumed as the baseUrl positional ("SKIP —
nothing served at --emit"). Flags are filtered out of it now. That is the
same bug, in the same shape, that check-leaf-english-leaks had three days
ago — worth knowing it recurs in any gate that takes both a positional
and flags.