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.
ITS ONLY WITNESS WAS MY OWN HARNESS. The browser path honours the universal
`list` prop — upg-100-list.vue calls applyPeopleGroupList() at every fetch site —
but library/scripts/posters/packet.mjs had NO list support at all. So the
non-default-list branch of funnelPage, including the six-row shape where a list's
own total is inserted before the reader's row, could not be produced by the only
pipeline rega can drive. I had unit-tested it through a script I wrote about it,
which is exactly the state I have been objecting to in other people's gates.
rega offered to shoot a ?list= packet and there was no command to give them.
PACKET_LIST=<id> closes that. Rendered for the first time, and it is right:
rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37
bars 1080 / 640 / 415 / 184 / 152 / 26 (six rows, six bars)
label "Project 4700: frontier people groups" between the network's list and
the reader's 37
share "This packet is 2.1% of the 1,745" (no false "1 of 27 lists")
foot provenance only, no coverage claim
row gap auto-adjusted 218 -> 147px for the extra row; last row stays 14px so
the share still reads as its conclusion.
IT READS THE MEMBERSHIP FILE FROM DISK, not through loadListMembership(), which
fetches from the asset pool over HTTP — there is no server in a Node build. Same
file, same slugs, same counts.doxa.
AND IT FAILS CLOSED: an unknown or empty list exits non-zero rather than quietly
producing a packet of every group. peopleGroupList.js makes the same choice for
the same reason ("a map that silently showed all 2,107 while an embed asked for
Project 700 would be showing the wrong list with no sign of it"), and a packet is
worse than a map — it is printed and handed to someone.
⚠ buildFunnel() IS NOW A FUNCTION, NOT A CONSTANT, because it depends on a list
resolved after the records load. It mirrors upg-100-list.vue's packetFunnel()
exactly: the default list is the only one the zone catalogue describes, so it is
the only one that may claim "1 of K lists" and an unassigned count.
── AND A CORRECTION TO MY OWN VERIFICATION ─────────────────────────────────
"Numerals untouched" is about DIGITS, not GROUPING, and I verified the digits and
assumed the rest. rega measured the rendered hi sheet: Indian grouping,
"8,28,85,63,969 लोग" (lakh/crore 2-2-3) rather than Western 3-3-3. That is
CORRECT hi-IN formatting, so changing it would be worse — the real risk was never
the grouping but INCONSISTENCY, the same number printed two ways in one document.
Measured and it is consistent: num() is the only formatter in the renderer, so the
funnel's populations and the card pages' populations group identically — confirmed
against the rendered hi packet (card populations 1,70,000 / 3,37,000 / 8,10,000,
Indian-grouped like the funnel's). Only values at or above 100,000 can distinguish
the systems at all; my first check classified "1,400" and read a mismatch that did
not exist.
Verified: default path byte-identical (43 groups, 7 pages, 5 rows, 5 bars); all
four fixtures PASS with rows==bars; PACKET_LIST=frontier gives 37 of 43 records
and 6 pages; check:packet-strings-parity PASS; vite build clean, bundle unchanged
(the change is Node-side).
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.
Country-level internet data is not good enough for India. This adds the first
sub-national layer, from the one open, machine-readable source verified for it:
NFHS-5 (2019-21, IIPS / MoHFW), "women / men aged 15-49 who have ever used the
internet (%)", urban / rural / total, for every state and UT (CSV transcription
of the state fact sheets, CC BY 4.0; SPEC-GRANULAR-INTERNET.md).
- data/internet/: the two NFHS-5 internet rows (with provenance, licence and
upstream commit), plus a strict crosswalk from source names to the shipped
gb-IND-ADM1 shapeNames.
- plugin-designs/cities-internet/scripts/build-adm1-internet.mjs (`bun run
internet:adm1`) writes data/tables/internet-by-adm1.json. It is GENERIC, one
row per iso3 x adm1 x measure x sex x area, with meta naming measures,
sources, boundary files and the national figures, so go-int and other plugins
can reuse it. It fails on an unmatched name or a value outside 0-100: a name
is never guessed.
- The map draws India's states in a blue ramp (0-25-40-55-70%+), leading with
women, with a Women / Men toggle. A tapped state shows both halves, urban /
rural / total. Its own legend block names the measure, survey, years and ages,
and says it counts people, not subscriptions, so it is never read as the
World Bank country bands. India's national band is taken out under its states.
- The shipped boundary file is the UPG-100 partition (34 of 36 units): Mizoram
and Nagaland have data but no geometry. They stay in the table and the legend
names them.
- 8 new strings under connectivity.adm1, in all 11 locales (library change:
consumers need rebuilding).
Headless: build and tree OK, and the table and gb-IND-ADM1 are staged under
assets/. The fill expression validates with maplibre-gl-style-spec, and all 34
shipped states get a colour (e.g. Bihar women 20.6 -> 0-25, Delhi men 85.2 ->
70+). A crosswalk poisoned to a wrong shapeName fails the generator. Passing:
universal-props, no-free-identifiers, i18n-parity, i18n-never-translated,
profiles-mount, composites-mount, no-orphan-gates, script-flags, quoted-scripts.
Not rendered in a browser.
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).
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.
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.
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.
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.
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.
- 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.
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 gospel-only dev-assets check is gone in favour of the shared one, which is
better at the job: it tests the /__leaf/<bundle>/<profile> prefix — the one that
actually broke — across all seven map bundles, and confirms each leaf hands its
element the base it claims. The gospel version never looked at that prefix, so
keeping both would have meant two implementations and the weaker one still green
while the map was broken.
What the shared gate samples is the POOL, the files every map shares. It cannot
know this leaf also fetches gospel tables by name, so a table that stopped being
staged would leave it green and this map fetching a 404. That is the only gap
left, and this covers exactly it.
The wanted list is parsed out of resources-map.vue rather than written down,
which immediately earned itself: the hand-written list it replaces was already
two short — pray-index and gospel-link-verification were being fetched and were
not in it. A list maintained by hand is the field-list-goes-stale bug, and this
bundle has produced that three times in one day.
Poison-tested by unstaging a real table: FAIL, exit 1, then restored.
A generated map leaf chose its engine at BUILD time and hard-linked it in <head>: mapbox-gl.css,
the geocoder css, mapbox-gl.js and the geocoder js from api.mapbox.com. The provider is chosen at
RUNTIME (profile-config.provider → ?provider= → window.MAP_PROVIDER), so a partner embedding a leaf
with ?provider=maplibre still had every visitor's browser make 4 requests to Mapbox.
- generate-tree: no static engine tags on map leaves; the element's own runtime loaders
(ensureEngineScript / ensureEngineCSS, the geocoder's on-demand loader) load exactly the engine
asked for, the path composite leaves already ran on. The forge page drops its api.mapbox.com
preconnect. A leaf opened with provider=maplibre (same ladder as resolveMapProvider) is token-free,
so the public copy no longer asks a MapLibre visitor to paste a Mapbox token.
- check:leaf-engine-per-provider (new, browser): every leaf at ?provider=maplibre makes 0 requests
to *.mapbox.com and still draws; at its default every map leaf draws with the engine it drew with
before (--baseline <tree> compares against another tree).
- check:dashboard-geocoder-provider is wired into gates:post alongside it; it had been held out
because this generator's tags made it red.
- verify-map-paint: optional GATE_VIEWPORT for a phone pass.
Measured before this landing (browser runs made before the headless rule): leaf-engine vs main's
tree PASS, 56 leaves, 0 Mapbox requests under MapLibre, same default engine. Pins, region popup,
PPLR search and key-cities PASS on both engines with 0 Mapbox requests under MapLibre. RTL shaping
PASS on both engines wherever RTL labels rendered. Paint under MapLibre: the same 4 leaves fail as
on main (country-posters-mfe, upg-100-mfe, regions-mfe, flower-mesh). Pre-existing, not caused here.
Poster "Render" is missing on country-posters and doxa-research-map under BOTH engines. Pre-existing
on main, read from source: countries-map.vue renders <PosterDialog v-if="showPoster"> without
v-model or getMap, and PosterDialog shows only when modelValue is true.
Headless, on this commit's regenerated tree: 57 leaves, 0 carry api.mapbox.com engine tags (main's
doxa-simple-map leaf carried 5), 56 carry the runtime provider check, check:sri PASS.
Every gate for this bundle measured the built tree, and the map was found broken
where none of them look: the dev server. Boundary polygons were missing there on
five maps at once, this one included, while every built-tree gate stayed green —
the thing that differs is how a leaf resolves its asset base, not what the build
produces.
Measured without a browser, which is also why it can run on every change rather
than when a slot is free: /assets/boundaries/world-adm0.geojson answers 404 in
dev while /dx-maps/assets/boundaries/world-adm0.geojson answers 200 with 1.9 MB.
The file is there; the base is wrong.
It asserts bytes and JSON, not just status, because a dev server that answers 200
with an HTML fallback for a missing .geojson is the shell trap this repo has been
caught by twice. A bogus asset path is requested as a control: if that also
answers 200 the gate reports NOT MEASURED, since a server answering everything
cannot tell a present asset from a missing one.
It does not prove the leaf asks for the right path. That fallback lives in
library/ and belongs to studio; this is the other half — whether there is
anything at the end of the base once the base is right.
Reported from a laptop running `bun run dev`: under the Plugin Studio, maps drew with no boundary
polygons and UPG-100 had no posters. The built tree was fine, and every gate measured the built tree.
Cause: a dev leaf page is /__leaf/<bundle>/<profile>. A plugin that falls back to the page-relative
'./assets' asked for /__leaf/<bundle>/assets/…, and the leaf route answered with its own HTML at
HTTP 200, so JSON.parse failed and the map drew without our data. /<tree>/assets/ was not served
in dev at all. The built leaf never hits this because its page passes assetsBase "../../assets".
- dev-plugin serves the pool at /__leaf/…/assets/ and /<tree>/assets/ too, and the dev leaf page
passes assetsBase "/assets" in its profile-config, as the built leaf passes its own.
- resolveAssetsBase's last rung is '/assets' under the dev server (import.meta.env.DEV, false in
every build); the built tree keeps './assets'.
- check:dev-assets (headless): starts a short-lived dev server for this checkout, requires
boundaries/previews/fonts/images/tables to answer 200 and not HTML at every prefix a dev page can
produce, and requires the dev leaf to carry the root-absolute assetsBase. It fails on the parent
commit (4 prefixes and both leaves) and passes here.
The numeric-sibling rule shipped last tick as a rider on the RTL commit
and was never confirmed by a run — both attempts returned RC=75. Live,
plausible and unmeasured is the riskiest state a change can be in, and
a GENERAL exclusion makes it worse: "an element whose own text sits
beside a sibling rendering a number is enumerating data" can go green by
excluding too much, which is the same thing as never checking.
So --self-test INJECTS a real chrome leak in the position the rule is
most likely to swallow: an English catalogue value in a label that HAS a
numeric sibling, plus a control with none. Both must still be caught.
The injected value is read from the catalogue rather than invented, so
the self-test exercises the same comparison the real run does.
SELF-TEST: PASS — 6 of 6 injected leaks caught, including the one
beside a number. The data, facet and enumerated-value exclusions
narrow the population without blinding the gate.
NORMAL: PASS — 8 of 8 combinations in ar/zh/ru/hi, 74-83 comparable
keys each, so no combination was vacuous. dashboard-plus-grid is clean
in all four locales, which closes Big Picture cell E.
⚠ THE SELF-TEST'S OWN FIRST RUN REPORTED NOT MEASURED, and that is worth
keeping: every non-flag argv is a target, so `--self-test` was consumed
as a leaf path and all four locales 404'd. The "no combination
rendered" guard caught my harness instead of reporting 0 leaks caught
out of 0 expected, which I could have read as a pass. Flags are now
filtered out of the target list.
These two maps build `new gl.Map` directly, so they never got useMapInstance's RTL install, and
their bundle entries registered nothing either. The basemap draws each place in its OWN script,
so over the Middle East most labels are Arabic, Persian or Hebrew for EVERY viewer: they were
drawn as disconnected glyphs in the wrong order. installRtlTextPlugin(gl) now runs after the
engine global resolves and before new gl.Map (an entry-time call cannot work once the engine is
injected lazily).
Gate: check:rtl-raw (+ :poison), gospel's check-gospel-rtl-shaping method (pan to Arabia first;
no RTL label on screen is NOT MEASURED; one defineProperty per global for hook + poison) over
cities-internet on both engines and key-cities (Mapbox-only, gated, no leaf: mounted on a
same-origin host page). The shared gate's --leaf argument can replace the cities rows once it
lands; the key-cities host page still needs this one.
Measured: before, cities-internet mapbox 58 RTL labels with the shaper 'unavailable' and maplibre
62 stuck at 'requested', both unshaped; after, shaper 'loaded' on both, and key-cities 56 RTL
labels shaped. Poison: 3/3 rows caught.
Dragging the side menu's pin-dim slider changed nothing: it wrote a store field
that did not exist, the same defect as the clustering picker beside it and found
while fixing that one.
The obvious fix would have created a worse bug. The pin layer's circle-opacity
already has an owner — legend dimming writes a ['case', …, 1, 0] expression — so
a flat slider value from a second writer would silently erase dimming, or be
erased by it, depending on which control the reader touched last. Nothing would
have looked broken; the map would just have stopped answering one of them.
So there is now exactly ONE writer and the value is the PRODUCT:
opacity = slider × (1 for a matched pin, 0 for a dimmed one)
The slider scales the whole layer, dimming still dims relative to it, neither
wins. Seven call sites that each wrote opacity by hand now go through it.
MEASURED, reading the live paint property rather than the store:
clustering off 1
clustering on, 0.5 0.5
+ a dimming selection ['case', <religion is B>, 0.5, 0]
slider back to 1.0 ['case', <religion is B>, 1, 0]
One thing the gate had to learn, and it is written down in it: a REGION
selection does not dim at all — it sets a layer filter, so non-matching pins are
removed rather than faded. Testing that path would have reported "the slider
erased the dimming" about code that never writes opacity.
★ ALSO: the Globe/Flat control's 24px touch target now applies at EVERY width.
The first fix scoped it to `@media (pointer: coarse)`, reasoning that a mouse
needs no touch floor. Wrong twice: WCAG 2.5.8 is a TARGET SIZE criterion, not a
touch one, and the query meant phones passed at 34px while desktop stayed at
73.5×23 — the same one-pixel failure, now invisible to a phone sweep. Measured
after: 73.5×34 at 390, 430 and 1440.
Driver decisions (cards BUILD-OUTPUT-IS-ONE-FOLDER, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE).
- public/ holds only <tree>/ and a generated README.md. The build no longer writes public/index.html,
public/assets/ or public/__gallery/; `rm -rf public && bun run build` recreates exactly these two.
- The Plugin Studio is <tree>/index.html (tree-base "."), replacing the staging-forge chooser; nothing
is written above the tree. check:staging-lang, which tested that chooser, is retired with it.
- Assets are gathered in a temp directory and the fetched allowlist staged into <tree>/assets/ as
before. fonts/, images/, brand/ and legacy/ existed ONLY in public/assets: they are sources and move
to data/ (git mv). Poster previews keep their tracked sources in plugin-designs/*/assets/previews
(all 378 verified present).
- __gallery/ is archived out of public/ (a copy kept in the umbrella's _qa-cycle/archive) and out of
the build; generate-gallery renders only on request, into a directory you name, never public/.
- Native shells load the output folder: capacitor webDir public/<tree> (cap copy needs an index.html
there), Tauri frontendDist + window url relative to it. internal/sync-app-shells.mjs keeps both in
step with the tree's name on every build, so the coming rename stays one config line; the Capacitor
start page resolves its leaf tree-relative.
- dropin.include now lists only extras (README.md): the tree is always shipped and never named twice;
excludes are tree-relative ({tree}/...). clean.mjs removes the old root entries from existing
checkouts; the build lock and public/.gitignore's rules move out of public/.
- check:wp-embed read the BUILT manifest from gates:pre, before the build, so a from-scratch build
could never pass; it runs in gates:post and reads the tree name from config.
- Stale bundles refreshed by the clean build (key-cities, template-bundle, wall-of-people-groups);
the CLI doc's quoted scripts re-synced; capability exemplars regenerated; the '#' note leaves
check:stale-sweep-claim's command (it was already in //scripts).
Proof: rm -rf public && bun run build -> public/ = README.md + doxa-maps/; check:sri, check:drop,
check:parse, check:public-hygiene, check:quoted-scripts, check:capability-exemplars PASS; all 57 pages
(the front page + 56 leaves) mount under /doxa-maps/ on this tree. check:facet-cascade is red as on main.
Cell E verification for Big Picture and Simple Research needed the
gospel leak gate's method on two other bundles. That gate hardcodes its
four leaves, its element tag and its single catalogue file, so pointed
elsewhere it finds no shadow root and silently compares nothing. This
one takes the leaf, the tag and the locales as arguments and compares
against EVERY catalogue area — a COMPOSITE has no t() calls of its own,
so its chrome comes from the children it nests.
SIMPLE RESEARCH CELL E IS CLEAN, independently measured:
research-map-plus-grid in ar/zh/ru/hi, tree identity checked first, no
leaks, 0 page errors, and 86/34/27/43 comparable keys — so the
comparison was non-vacuous in every locale rather than "0 leaks over
nothing". A second instrument agreeing with the owner's reading is what
the verifier role is for.
★ TWO FALSE POSITIVES, BOTH OF WHICH LOOKED LIKE TEXTBOOK DEFECTS. The
first run reported 3 leaks x 4 locales on Big Picture, and all three
keys ARE translated in every catalogue, so the pages really were showing
English where a translation existed.
DATA CELLS. "Unengaged" rendered in .rd-td inside .rd-grid — table
data. people-groups-grid.vue:150 records the field as
{value:'unengaged', label:'Unengaged'}, 100% populated, and
dashboard.config.js:132 counts 2,081 Unengaged / 27 Engaged. It is an
API VALUE that happens to equal a catalogue string, which is the
population rega's method excludes by name. Whole-label matching stops
a substring impersonating a label; it cannot stop a data cell
rendering the same text.
FACET VALUES. "Other" rendered in span.fr-lbl in the facet rail, which
LOOKS like chrome. The template is
`<span class="fr-lbl">{{ r.label }}</span>` with `{{ r.count }}`
beside it, and the component's own comment calls it "the 'Other'
bucket". The string comes from row data, not from t('regions.Other').
Both are excluded BY ROLE rather than by class. The facet exclusion keys
on the row carrying a COUNT, so the rail's own section headings stay in
scope and a real leak there is still caught — excluding .fr-lbl
wholesale would have blinded it.
⚠ NEITHER IS GOSPEL-SPECIFIC. Any bundle with a data grid or a facet
rail produces these, and whether "Unengaged" and "Other" as DATA should
be localised is a genuine product question — but a different one from
cell E, and filing it as a leak would send someone to fix correct code.
⚠ BIG PICTURE CELL E IS NOT CLAIMED CLEAN. The confirming run after the
facet exclusion came back RC=75 (no browser slot within 900s), and the
run before it still reported regions.Other. Whether the exclusion takes
effect is unverified, so the cell stays open rather than being marked
on an unconfirmed reading.
The map is built raw, for the provider switch, so it never installed the RTL
text plugin — the same opt-out that cost it the engine stylesheet. Without the
plugin those labels draw as disconnected glyphs in the wrong order, which is
unreadable rather than merely ugly.
This was never an Arabic-locale bug, which is why it went unseen. The leaf does
not localise basemap labels, so every place is drawn in its own script: over
Arabia, 57 of 61 rendered symbols are Arabic for every viewer whatever their UI
language. An English reader sees broken Arabic they cannot evaluate.
It installs after the engine global resolves rather than at bundle entry. This
bundle injects its engine lazily, so at entry time there is no engine to install
into and the call would silently do nothing.
The gate pans to Arabia before judging. Registration alone proves little, since
the plugin is registered lazily and a freshly loaded map reports "deferred"
whether or not the shaper can actually be fetched; only real RTL text on screen
turns it "loaded". Asserting without panning would have failed a working map.
Choosing "Native" in the research map's side menu drew nothing. So did MST, and
so did Network. A control that does nothing is worse than one that is absent:
it tells the reader the map cannot do what it can.
TWO CAUSES, AND FIXING EITHER ALONE WOULD HAVE LOOKED LIKE A FIX.
1. mapStore never declared `clusteringMode`. The picker wrote to a property no
reactive system was watching, so useMapClustering's `mode` kept its own
internal default and the click changed nothing.
2. The legend's Clusters checkbox force-set 'mst' "regardless of what's in the
store", with the mode watcher "kept as a no-op for forwards compat" — its
own words. Even with (1) fixed, one tick of that checkbox would have snapped
Native back to MST: a control that works until you touch the other one.
So the two controls are now two views of one state. clusteringMode IS the state
('off' means disabled); the picker sets it; the checkbox maps onto it, promoting
only 'off' to 'mst' so the checkbox alone behaves exactly as it always has. They
can no longer show different things. The dim-slider's clusterPinOpacity got a
real home in the same pass, for the same reason it was missing.
★ THE GATE ASSERTS ON WHAT IS DRAWN, NOT ON WHAT IS SET. `clusteringMode ===
'mapbox'` would have passed the moment (1) was fixed, while the map still drew
MST lines — state was exactly the evidence that let an inert control ship. The
map now reports, per clustering layer, whether it exists, whether it is visible
and how many features are rendered; the gate clicks the real buttons and reads
that. MEASURED, before and after, on the same tree:
before off 0/0/0 native 0/0/0 mst 0/0/0 network 0/0/0
after off 0/0/0 native 37/37/0 mst 0/0/1700 network 0/0/23896
(circles/counts/lines)
It also asserts no orphan layers between modes, and that MST and Network differ
— two labels for one behaviour is the same defect as a dead option.
One measurement bug caught on the way, worth recording: a fixed 6-second pause
reported MST as drawing nothing while Network drew thousands of lines. MST over
~2,100 pins is quadratic and had simply not finished. The gate now waits for the
drawing to settle rather than guessing a duration — a gate that calls a slow
feature broken is worse than no gate.
Injecting mapbox-gl.css collapsed this profile's canvas to a white strip, and
every assertion I owned went green over it: verify-gospel-map counted 2,107 of
2,107 pins "rendered", leaves-mount found a full shadow root, the i18n gates
found every label translated and nothing clipped. queryRenderedFeatures does not
care whether anything is visible. I caught it by opening the screenshot, which is
not a system.
So: screenshot the map region and count distinct colours — a basemap has
hundreds, a blank rectangle one or two — and check the map still fills its
element.
THE POISON ARM CORRECTED THIS GATE TWICE, which is the argument for having one.
First it hid the canvas, and the region still showed 316 colours: the sample was
taken over the whole custom element, and the legend card is itself full of
colour. The sample is now clipped to the right of the legend, so it looks at map
alone.
Then it collapsed the container, and the gate still passed — because I was
comparing the canvas to its own container, and in the real incident BOTH
collapsed together, leaving that ratio at 1.0. The check would have passed the
exact defect it was written for. The ratio that matters is map-to-ELEMENT, and
the poison now reproduces the incident rather than approximating it: 1440x300
inside a 1440x900 host, caught at 33%.
Measured after: 644 distinct colours, map filling 100% of the element, and the
poison arm red.
Arabic text was rendering inside a left-to-right layout: readable glyphs
in the wrong place, which reads as a styling quirk rather than the bug
it is.
TWO DEFECTS IN ONE BLOCK (library/i18n/index.js, createAppI18n):
TARGET — it wrote document.documentElement.dir. An EMBEDDED widget
doing that flips the PARTNER'S WHOLE PAGE: their headings, their nav,
their footer. The existing comment already worried about the host
owning that decision, but guarded only against CLEARING dir, never
against setting it.
TIMING — it ran at createAppI18n() construction, reading
detectLocale(), which sees only the URL and <html lang>. A locale
arriving later through profile-config — the channel a HOST uses —
never reached it. Measured across the previous landing: htmlDir null
on every host-channel run, including leaves whose chrome was fully
Arabic.
Direction now binds on ProfileLoader's own root, driven by
effectiveLocale — the locale actually in force AFTER profile-config, so
it re-evaluates when a host changes the language. This element cannot
tell "I am the page" from "I am a widget", and guessing wrong is exactly
the failure above, so it no longer touches the document at all.
MEASURED on a partner page at a FOREIGN ORIGIN with
profile-config.locale='ar' (same gate, before and after):
before upg-100-mfe plugin root ltr, arabic 1765 FAIL
poster-wall plugin root ltr, arabic 1738 FAIL
after upg-100-mfe plugin root RTL, arabic 1765 pass
poster-wall plugin root RTL, arabic 1738 pass
partner <html>/<body>/<h1> stayed ltr throughout.
Standalone leaves are unchanged and still get <html dir="rtl"> from
?lang=ar — verified, both profiles — because a leaf page DOES own its
document. createAppI18n's document write therefore stays for now;
replacing it needs a page-ownership signal that touches every generated
leaf, which is its own change rather than a rider on this one.
⚠ THE GATE REFUSES TO JUDGE WHAT IT CANNOT SEE, and that guard earned
its place twice before this was measurable. Its first two runs reported
"the element never mounted" — a HARNESS gap, not the product: a partner
embed uses crossorigin="anonymous" (SRI requires it), so the tree must
send Access-Control-Allow-Origin, which `bun run serve` does not. The
gate now proxies the tree through its own CORS-enabled port. Without
that guard those runs would have read as fails-first evidence for a
defect nothing had actually exercised. It also fails if the locale
produced zero Arabic chrome, because direction cannot be assessed on a
page whose language never applied.
Every clone-fidelity gate compares properties of NAMED selectors, so none of them can see an element
that exists on only one side; regions found an extra control and an invented chart by eye, past eight
green gates. This executes the Driver's binary scope test (promoted from regions' probe).
- Harvests every visible text node through every shadow root on both sides and diffs the sets:
OURS-ONLY unaccounted (scope violation) and THEIRS-ONLY (omission) fail; SANCTIONED entries must
carry the Driver's words, date and card or the preset is refused; same-label digit drift is
reported, not failed.
- The negative control is mandatory: a known ours-only string must land in the ours-only set, or the
run prints CENSUS IS BLIND and exits 75. An unreachable live page is also 75.
- Presets live in internal/element-census/<name>.json; ad hoc via --leaf/--live/--neg.
- Measured: regions passes (list 229 vs 228, the only difference its sanctioned poster toggle; Africa
97 vs 97 with 3 live-vs-snapshot figures). With the allowlist removed the toggle FAILS as
unaccounted; a negative-control string not on the page gives CENSUS IS BLIND, exit 75.
resources-list's chrome is translated in all eleven — the three filter tabs, the
generic resource names, the people-group count — and the table's column headers
are localised at render in data-tables. resources-map, data-tables and charts now
report zero leaks by the corrected gate; what remains is in resources-list's
data-driven rows.
A CLAIM I MADE AND HAD NOT MEASURED. The previous commit said the CSV export
"keeps the data file's English field names". It does not: Tabulator exports the
column titles it is given, so a Russian reader's CSV heads "Народ","Страна",
"Регион". I checked only after asserting the opposite. Left translated on
purpose — this is a gift for people to use and someone who exports what is on
their screen should get what was on their screen — with the stable machine
contract being data/tables/ itself, which is untouched and English.
THE GATE NEEDED A THIRD CORRECTION, and this one mattered most. Substring
matching over the rendered text reported keys that were never rendered at all:
"Language" matched inside the DATA — the people-group language "Sign Language" —
and "Region" inside a region name. No length filter separates a label from a
word sitting inside one. The unit is now an element's OWN label, and a leak is a
label that EQUALS the English value while a translation exists. A language name
can no longer impersonate a UI string.
Scripts are alphabetised per the fleet convention, so parallel landings stop
colliding on the last line of package.json.