documentation/how-to-build-mobile-apps-from-plugins/README.md explains why Deep Research
(doxa-maps-spa, its app preset) is the plugin to wrap first: the seven production maps in one
phone-ready shell, token-free on MapLibre. It then walks the whole path: tools, the wiring check,
one command, what each step does, another plugin as the app, making it your own, and fixes.
The one command needed two small changes; the default app is unchanged:
- internal/set-app-start-page.mjs takes APP_START=<plugin>/<screen>[/<preset>] and opens that page
instead of the UPG-100 list. Unset, it behaves and marks the page exactly as before. A malformed
value, a page missing from the build, or links that would not resolve are refused, as before.
- package.json: phone:from-tree is sync, start page, verify, assemble, install from the committed
tree, with no full rebuild (bun run build refuses without BUILD_ALLOW=1).
phone:deep-research is phone:from-tree with Deep Research as the start page.
how-to.md links the guide, and two of its lines are corrected: webDir is public/dx-maps/, and
apk:debug's first step is the full build, which asks for BUILD_ALLOW=1.
Checked, with no Android tools: the start page written for doxa-maps-spa/doxa-maps-spa/app, then
the app root served and loaded in the shared browser at 390 and 1280. It showed the gallery
element, 15 tabs (the seven maps and the experimental ones) and a drawn map, with 0 console errors
and 0 failed requests. --check passed for APP_START and failed for the default until it was
restored; the default run still writes the UPG-100 page with its original marker. The Gradle and
adb steps are those bun run phone already uses; they were not run here.
The Deep Research page publishes the rail as Dashboards (Big Picture, Simple Research, Regions),
Prayer Mobilization (Prayer Progress, UPG-100 List), Gospel Research (Cities with Internet, Gospel
Resources), then the Experimental folder. Only the Prayer Mobilization pair moves. Big Picture now
says "desktop only" on every width, not only once a phone has disabled it.
check:spa-tab-order asserted the old four-name order with UPG-100 first; it now asserts all seven
published rows in order (still relative, with tab 1 positional). It needs a browser: rega runs it.
Three defects on country-map/country-map, which is production -- DX Life's
country pages. Found by auditing the element rather than from a report.
1. ANY profile-config change refetched all 2,106 records. The watch getter
returned a NEW ARRAY literal, and Vue compares a watch value with Object.is,
so a fresh array always reads as changed; both computeds read the injected
cfg, so the effect re-ran on every config change and the callback then fired
unconditionally. MEASURED with real Vue: flipping isDark refetched the whole
endpoint and redrew the map, and so did a host adding pinHint. A joined
STRING compares by value, so only a real language or country change refetches
-- which must still refetch, because the API localises the names.
This was latent waste until the composite re-forward made config writes
routine. It is now on every upstream locale switch, so the two changes are
complementary rather than independent.
2. A SLOW EARLIER RESPONSE OVERWROTE A NEWER ONE. loadPoints is async and had no
sequencing at all -- 0 abort or generation tokens in the file -- so two runs
could be in flight and the one that STARTED first could FINISH last. Both
assigned points, so the map showed the previous country's pins with no error
and nothing for the reader to notice. Every run now takes a ticket and a stale
ticket writes nothing: not the points, not loadFailed, not the spinner the
current run is still using. fetch is left to complete rather than aborted,
because the response is cached for the next switch back.
3. `loading` WAS WRITE-ONLY: assigned in three places and read in none, zero
template references. A reader on a country page waited on an empty map -- no
outline, no pins, no message -- which is indistinguishable from a country that
genuinely has no people groups, and this element fetches the whole list before
it can draw one pin. "Loading results..." was already in the catalogue in all
eleven locales, so saying so cost no new string and no parity debt.
check:country-map-refetch guards all three. It drives the REAL reactive chain
with real Vue and takes the watch key expression OUT OF THE SHIPPED SFC, because
bug 1 is entirely a question of what Vue considers changed and is invisible to
any test that fakes reactivity or re-types the watch.
Mutation-tested, 5 mutations, all caught by name. The first version of the gate
MISSED one: a bare /if \(!current\(\)\) return/ grep matched the guard in the
catch block, so removing only the guard before the points write still passed.
The assertions are positional now -- the guard must sit immediately before the
assignment it protects -- and the catch guard is asserted separately. That hole
existed because the behavioural half of the test reproduced the guard's shape
inside the gate instead of reading the component, which is the self-consistent
oracle this repo's other geometry gate explicitly warns against.
Registered in scripts and gates:pre, verified by re-parsing: one key, 107
checks. Bundle rebuilt (BUNDLE=country-map), then tree, then fix:sri.
`bun run build` now ends with `sync-dropin --from-config --if-present`. It copies public/dx-maps into the
ONE configured host path (vite.doxa-maps-build-config.json dropin.dest, default ../doxa-life/public, or
DOXA_DROPIN_DEST). No site at that path: it prints "skipped" and exits 0. Nothing else on the filesystem
is searched for repos or sites. DOXA_DROPIN=off skips it even when the site is there. Without
--if-present, a missing host is still an error, as before.
Tested: no host skips (exit 0); a host gets 1,209 files; DOXA_DROPIN=off copies nothing; the plain
command still refuses a missing host. The container's /cdn build (build-cdn.sh) does not run this chain.
On this box ../doxa-life does not exist beside the doxa-maps checkouts (DX Life lives in root-dx-life), so the step is a no-op for the fleet's builds.
- check:no-orphan-gates stopped at internal/check-grid-merge-defaults.mjs, which had no script. It
asserts defaults of three grid props that are not on development (the grid merge has not landed),
so it is registered ON DEMAND with that reason, not chained red. Past that first arm, its chain arm
found check:grid-image-loading and check:retry-fetch run by no chain; both depend only on code and
pass in under 300 ms, so they join gates:pre.
- A tracked vite.config.js.timestamp-*.mjs (395 KB, eight absolute paths) failed check:no-abs-paths.
Removed, and ignored at the source: vite writes one beside its config while it bundles it.
- check:library-index: documentation/reference-library-index.md regenerated for retryFetch.js.
- run-parallel printed a failed step's output only with --verbose: the unbraced else bound to the
inner if. Braced; a failing step now prints why.
- The Pages workflow ran bun run build without BUILD_ALLOW=1, so build-lock refused it with exit 75
and the workflow ran no gate at all. A CI runner is the only builder on its machine; it opts in.
sr asked to adopt usePaneConfig for research-map-plus-grid rather than write a
second fix, and found the one shape it did not cover: people-groups-grid takes
its filters as THREE separate attributes (wagf-region, religion-family,
primary-language) instead of one profile-config blob, so that composite has one
pane of each kind. The helper hardcoded 'profile-config' and gave the grid
nothing.
registerAttrs is the sibling for that shape. It shares the same entries list,
the same watchEffect, the same prune and the same write-only-on-change -- one
mechanism for all four composites rather than a fork per adopter. A helper with
a single caller has not been shown to generalise at all; this is the second
shape that showed it.
Two decisions worth recording. Each attribute is compared and written
independently, so a getter whose region changed does not rewrite the two that
did not and re-trigger the child. And the attribute NAME LIST is pinned at
registration: a later getter that omits a key CLEARS that attribute rather than
leaving a stale filter silently narrowing the grid, which is the same class of
lie as the frozen config this helper exists to fix.
The gate runs the shipped helper with real Vue, not a re-typed copy, because the
dependency-tracking hole found in review is invisible to any test that fakes
reactivity. 28 cases over both shapes. Its subject is the measurement on
development 92ca8b6e6: the outer element reached locale ar by +1.5s while every
nested host carried NO locale key at boot and still none at +15s, across four
composites -- so case 1b asserts that an ABSENT key is still a change.
Case 7 is the adopter's property rather than mine: a re-forward triggered by a
LOCALE change must produce the SAME primary-language string. gridLanguage() can
fall back to a display label, and the grid compares whole normalised values, so
a name fetched in one language matches no row in another. It does not bite today
because the surviving fallback is a value the grid itself produced, but the gate
asserts the class, not today's instance.
Mutation-tested, 7 mutations, all caught: plain array, no write-on-change guard,
skip-instead-of-prune, escaping getter throw, no per-attribute compare, omitted
key left stale, and the label-instead-of-code regression. The first two crashed
rather than naming a case, so cfgOf is null-safe now -- a gate that dies reports
"something went wrong" instead of naming the pane that stopped following.
Registered in package.json scripts and in gates:pre, verified by re-parsing the
file: one key, 104 checks. gates:pre PASS 103 FAIL 0 NM 1.
useShadowStyles keys its <style> by the label and overwrites an existing
tag with the same key, so two components sharing a label keep only the
last one's CSS in a shared shadow root. The regions list lost its sheet
this way to the region card, which had kept the list's label when it was
extracted.
check:shadow-style-labels-unique (gates:pre) fails any label two calls
share, globally rather than per shadow root, so a copied or forked
component is caught when it is copied. It reads the real parsers,
classifies every reference to the function (a rebinding, .call, a
re-export or a namespace import fails instead of hiding a call),
cross-checks the AST against the token stream, and covers the other
ways two calls share a key: unlabelled calls under the dev server's
per-component key, and a fixed label over CSS that varies per caller.
countries-map.vue, a fork of research-map.vue, carried the same label;
it is now 'countries-map', with the country-posters bundle rebuilt and
the tree regenerated. The composable's JSDoc called the label a
debugging name; it now says it is the key.
Ported from rega's snipgate and mbxcheck probes (card GATE-EVERY-PUBLISHED-SNIPPET-DRAWS-A-MAP). Two scripts so the keyless arm never waits on a token: check:snippets-draw pastes each production embed.script and requires a MapLibre map to draw (a hooked instance with its style loaded via a setter trap on the engine global, a sized canvas, tiles 2xx, no failure note, 0 page errors); check:snippets-draw:mapbox does the same over embed.scriptMapbox with only the token filled from MAP_TOKEN, NOT MEASURED without one and never printing it. Composites that open on a list (regions-mfe, upg-100-mfe) are pressed to their map view under the settle rule first, since a hidden pane still draws a canvas. The browser slot is checked before any server starts, and the tree server is spawned --exit-with-parent. Registered and declared on demand until rega's first run; it joins verify-browser-all after.
The list-pattern gate (internal/check-list-pattern-doc.mjs) came in with
its doc but was never registered, which check:no-orphan-gates refuses.
Registered, and chained into gates:pre: it is headless and re-derives the
7 numeric claims in PATTERN-A-LIST-OF-ZONES.md from the data files.
Merging the branch backlog cut gates:pre to 49 entries of 102 and gates:post to
86 of 108: package.json holds each gate on its own line, but the chains are
single long strings, so a line-wise union takes one side wholesale while every
definition survives. A run then passes and means almost nothing.
Both chains come from development, with the one gate these merges add appended
rather than replacing them. Every gate named in a chain is defined: checked,
not assumed.
The union resolution kept every script DEFINITION but took the older branch's
gates:pre and gates:post chains, which are single long strings rather than one
line per gate -- so a line-wise union picked one side wholesale. That silently
dropped 95 gates from the pre chain and 30 from post: every definition present,
almost nothing actually run.
Both chains are restored from development, with any gate this merge adds
appended rather than replacing them. Caught by counting the gates in the chain
against development, not by the run, which passed 7 of 7 and looked green.
A cold map ran serially: leaf HTML, then the bundle (250-470 KiB gz, parsed),
then only the engine (from cdn.jsdelivr.net or api.mapbox.com), then the style
host, tiles, and data. Four origins discovered one after another, none
pre-connected. That waterfall is the shape behind A9's 3-5 s cold map.
generate-tree now emits, in each MAP leaf's <head>, a preconnect for the
engine origin, the style host and the data origin, and -- only on the 'auto'
leaf, which carries no engine <script> today -- a preload of the engine, so
DNS/TLS and the engine download start alongside the bundle instead of after
it. A non-map leaf (grid/card/chart-table/app-shell/composite) gets nothing.
The engine is decided at build time from the SAME inputs the runtime ladder
reads (profile-config.provider, then a baked token or a token endpoint; 'auto'
with no token path is MapLibre keyless), so the hint and the ladder cannot
disagree. Every URL, integrity and crossorigin comes from
library/constants/cdn.js, so a preload cannot drift from ensureEngineScript's
tag. The one case a static hint cannot foresee -- an explicit ?provider= or
window.MAP_PROVIDER overriding the build default at runtime -- costs one unused
preconnect.
New gate check:leaf-hints (gates:post): a map leaf without hints fails, a
non-map leaf carrying a map-engine hint fails, and a hint origin must be one
the code uses. PASS: 36 map leaves carry hints, 28 non-map leaves carry none.
Poison-tested both sides.
Unlanded (dx-base/a9-leaf-hints); the bundled-vs-shared-vs-today trade-off
report and any landing wait on rega's timing and regions' tiles.
verify-browser-all's rule is that every gate listed there was run before it was
listed, so this waited for a real run rather than being registered on the
strength of the code reading correctly.
Measured, on the browser seat: the positive arm PASSES 9/9, and the mutation arm
-- the maplibre selector inside bottomControlInset broken to a name that matches
nothing -- FAILS on cases 1 and 5, the two whose pages carry only a maplibre
attribution. Cases 2, 3, 6 and 7 pass under the mutation by design: they assert
the inset is 0, so a selector that stops matching cannot make them fail. They
guard the opposite error, reserving space that is not there.
Registered in all three places: the script, its //scripts description, and
verify-browser-all's list. Deliberately NOT in gates:pre -- every assertion rests
on getBoundingClientRect, so it needs a browser and exits 75 NOT MEASURED
without a slot, which is never a pass.
The file walk read internal/ only, and the chain arm counts package.json NAMES, so a plugin-local
check-*.mjs that nobody registered was invisible to both: on 2026-09-29 six were run by nothing
(dx-shepherd's census; exp's four have since been registered). Every tracked gate-shaped file under
plugin-designs/ or library/ must now be run by PATH from a script or another file's code, carry
@one-shot, or sit in the shrink-only `files` baseline for its owner.
Two rules keep it honest. Comments are stripped before searching, since a file mentioned in prose is
not run by it. And a basename counts only where no other gate file shares it:
library/scripts/research/verify-map-paint.mjs (199 lines, run by nothing) shares its name with
internal/verify-map-paint.mjs (342 lines, what verify:map-paint runs), so a name match let one file's
registration vouch for the other. Self-tested on planted cases (unregistered, comment-only, shared
basename). On the real tree it finds 4, now baselined for their owners:
library/scripts/research/verify-map-paint.mjs likely a stale copy
plugin-designs/doxa-research-map/scripts/check-reduced-motion-jumps.mjs
plugin-designs/gospel-resources/scripts/verify-translator-org-links.mjs a data tool? @one-shot or register
plugin-designs/research-map-plus-grid/scripts/check-ui-strings-have-keys.mjs
The chain arm's writer now keeps every key of the shared baseline file.
The popup auto-pan had no gate. Its founding bug was invisible to the obvious
one: measured at 390 on a 366x732 map, the pan fitted the popup inside the
container, the cut really was zero, and a real click on Full Profile still did
nothing, because the top element at the button's centre was
div.maplibregl-ctrl-attrib-inner on one pin and span.pin-hint-pill on the other.
Two pins that looked fixed and were not.
So this asserts the property that matters -- where the popup ENDS UP relative to
the band that takes the click -- rather than whether it was clipped.
It runs the SHIPPED panPlanFor, read out of the SFC with new Function because it
is not exported, the same way check:optional-t-equivalence reaches useOptionalT.
A re-typed copy of the arithmetic would pass forever while the component drifted,
which is the one failure a geometry gate must not have. POPUP_OFFSET is read from
the source too rather than assumed.
Each case states the resulting geometry independently instead of re-deriving the
expected number from the same formula: a self-consistent oracle cannot see an
error that shifts both sides, which is how a 12px offset hid here once already.
The band is measured at runtime, never hard-coded, so the rule is swept across
0-120px rather than pinned to one number -- 120 is far beyond anything observed
and is there to prove the rule, not the value. Also swept across every width the
component renders at, since below 768px the container is 2x width.
Mutation-tested three ways, each failing the case written for it: ignoring
bottomInset (the pre-fix behaviour) fails 15 cases; reserving only half the band
fails; dropping the Math.max clamp fails exactly the negative-inset case.
24 cases, registered in all three places, verified by re-reading the parsed file.
check:wall-wysiwyg (0.9 s), check:wall-print-watch (0.7 s), check:wall-no-bg-photos (13 s) and check:template-i18n (9.7 s), each run once on this round's tip before chaining (all PASS) and each two-sided on its known-bad (dx-base poisoned all four independently). Placement read, not assumed: the two Vite runs use middlewareMode and bind no port, and wall-no-bg-photos serves a committed fixture with fetch stubbed, so no answer depends on the network, the machine or the moment. The orphan arm now counts 219 of 330 reached.
The gate asserts the regions idle paint has one source. It could not be chained
while only one of the two maps had adopted the library export, because it was
legitimately red on the other plugin owner's unlanded work, and chaining a red
gate holds the shared run for everyone.
Both halves are now in: this map took the export, and the other adopted it too,
so the gate reports all ten assertions green and can run with the rest.
This replaces the on-demand declaration rather than following it. Declaring a
gate as run-by-hand is a decision that costs a reader something to understand,
and it is only worth making while it is true -- the second half landed sooner
than the declaration assumed, so the honest end state is a chained gate and no
entry at all, not an entry added in one commit and deleted in the next.
Verified on the combined tree: the gate passes, the orphan guard passes with it
counted among the chained scripts rather than the declared ones, and poisoning
the gate still fails it.
Two production maps paint the doxa-regions layers directly -- 35
setPaintProperty('regions-fill'|'regions-border') calls between them, 10 in one
file and 25 in the other. Twenty-one pass their value through a one-line helper
that wraps it in the same ['case', ['has','iso_3166_1_alpha_3'], v, v]
expression, and each map defined its OWN copy of that helper while already
passing it into the library composable that paints with it. The library owned
the paint logic and not the expression, so the two copies could be edited apart
and the same "idle" would render differently on the two maps, with nothing
failing. No existing check covers this: the nearest one reads a different
component entirely.
The gate asserts the idle paint is single-sourced: the library exports the
helper, neither map keeps a local definition, and neither inlines the
expression. It deliberately does NOT require all 35 writes to route through the
library. The raw multi-line writes are highlight states and outline resets that
differ by design and by map; demanding uniformity there would make this red on
the day it was written, which is how a gate earns being ignored. It also does
not assert how OFTEN each map repaints -- one repaints the idle state more
than the other and has a dynamic call the other lacks. That is a real
difference in when, and flattening it is how a map goes blank. This checks that
the maps agree on what idle looks like, never on how often they say it.
Two-sided. Poisoning it un-exports the library helper and re-inlines a local
copy in a consumer; three assertions flip from ok to FAIL, each catching its
own planted regression, and the run exits 1.
Registered but NOT chained yet: it is legitimately red until the second map
adopts the library helper, and chaining a red gate would break the shared run
for everyone else. It joins the chain with that change.
Building it found a third copy nobody had counted, under a different name, and
two bugs in the gate's own first draft: a block-comment stripper that ate the
declarations it was meant to find (so every single-source assertion passed
while both files plainly defined the helper), and an exemption for lines
mentioning the helper, which hid a one-line definition that mentions it. Both
were caught by the rule that a gate must fail when poisoned.
check:poster-browser-equiv is chained in gates:pre and exited 75 NOT MEASURED
every run. Its input, out/_records.json, is gitignored and written only by
generate.mjs against a live API, so on CI and on any box without credentials the
gate never looked at anything. The runner reported that honestly rather than as
a pass, but an identical NOT MEASURED every run is indistinguishable from an
unwired gate -- and this is the gate that proves a sheet generated in the browser
is byte-identical to the one Node writes.
An equivalence check needs the SAME input on both sides, not fresh input, so a
committed fixture loses nothing it was measuring. The fixture is the 124 public
records the two arms actually consume: Nepal's 34 for the country arm, and the
90 slugs the Gujarat zone selects for the zone arm. Same fields the served maps
show; no other data.
Verified equivalent to the full 2,106-record cache: identical zone pins in the
same order, identical Nepal set, count 90, pop 3,164,770, series {2209, 28, 1}.
Record ORDER is load-bearing -- it drives draw and label order -- so the fixture
keeps the cache's order. A first cut sorted it: every aggregate matched and the
pin SET matched, while the order differed. Sorting a fixture would have quietly
changed the sheet it produces.
generate.mjs gains RECORDS_FILE so a caller can name the offline input instead of
only the API-written cache. It still fails closed without an API when no offline
file is given. The gate passes it the very file the browser arm read, so both
sides are fed the same records by construction.
The gate now says which input it measured, because the full cache widens coverage
to all 175 countries where it exists and the fixture is the floor everywhere else.
Two-sided, on this tree: with the fixture it PASSES byte-identical on both arms
(834,174 and 921,522 bytes). Dropping the province boundaries at
posterSheets.js:206 -- the defect this gate's header records, where a country
beside India or China printed without them -- makes the country arm 251,576
against Node's 921,522 and the gate exits 1. Restored, it passes again.
check:coverage-consistent:chained sets the link-age threshold out of reach. The proof that this narrows its scope and does not disarm it is the poison arm: its clock is computed RELATIVE to the threshold, so all 15 clauses (A-O, L included) still fail when poisoned at any setting (regions measured it at 99999). That proof was an orphan; it now runs on every landing, like the other gates' self-tests. 1.6 s.
check:dxlife walked the whole root-dx-life umbrella, so git worktrees of doxa-life (wt-rg-*) and
a dangling one (research-map-clean, whose .git points at a path that no longer exists) read as
+4,524 "added" files and held it red while the clones were untouched. It now skips any directory
whose .git is a FILE beginning `gitdir:` (keyed on that file, not on `git worktree list`, which
cannot see the dangling one) and .cache with the other generated dirs, and compares the baseline
only within that scope. Result against the 2026-09-20 baseline: 2,844 clone files, +0 -0 ~0. So the
law has held since that date, and the baseline stays as it is: re-taking it would reset the evidence.
Nothing is deleted; research-map-clean may hold the only copy of its work. With no clone on the
machine it reports NOT MEASURED (75) instead of exit 2, so chaining it cannot fail other machines.
check:coverage-consistent fails when the oldest verified link is over 45 days old: its verdict
depends on the calendar, and chained it would turn gates:pre red on 2026-11-08 with no change
(regions measured the margin). Every other clause depends on code and data and is worth running
on every landing, so gates:pre runs check:coverage-consistent:chained (the age knob set out of
reach). The plain script is declared on demand as the freshness run, with that reason. Its poison
self-test still proves every clause can fail.
gates:pre 94/0/2 of 96. The orphan baseline shrinks to 105; 7 checks are declared on demand.
From regions' triage of the headless orphans, every candidate re-run on this round's tip before
it was chained (all exit 0; the slowest, no-conflict-markers, 8.2 s):
gates:pre click-sheet coverage-consistent dashboard-filter-a11y filter-multivalue hover-channel
kit-options-reachable layout-key-namespaced legend-mirrors map-unmount-race
no-conflict-markers series-isolation snapshot-guard stale-sweep-claim
surfaces-read-the-tier translator-contacts (source readers)
gates:post embed-base link-coverage no-public-binds shared-reached tree-weight (built tree)
stale-sweep-claim reports SKIP while 1 of 1184 gap codes is undecided: it measures once the pool is
fully read, and the wave shows it as not measured until then, never as a pass.
Declared on demand instead: catalog-currency and published (they read the published host),
composite (minutes of rebuilds; landing's fresh step does this per landing), hardcoded-english
(report-only while its findings are triaged; its self-test is chained), and shot-fonts (it checks
the SCREENSHOT MACHINE's fonts, so chained it would fail a contributor's gates for their fonts).
Two proposals were declined: check:settled stays on demand (it fails inside any build by design,
and passing standalone is how it was wrongly chained before), and shot-fonts as above.
The orphan baseline shrinks 131 -> 106. gates:pre 93/0/2 of 95, gates:post 91/0/16 of 107.
The two scripts arrived without //scripts entries. The text is regions', written to match the neighbouring entries; its '8 self-tests' was re-read against the file this round carries (sha256 a886af2dca66e012, 124 lines).
check:no-orphan-gates only REPORTED scripts outside the chains, and the list grew to 87 main checks.
That day two of them hid real defects: check:i18n-never-translated, while a dead key identical in all
11 catalogues sat on development for hours, and check:auto-engine, which asserted a retired engine
rule that nothing ran. The report also matched by substring, so check:hardcoded-english counted as
run because its :self-test is in gates:pre.
The arm now decides. Every check:/verify:/smoke: script must be:
reached by exact name from gates:pre, gates:post, the browser suite's GATES or the build,
following what those scripts `bun run`;
on demand in internal/fixtures/on-demand-checks.json WITH its reason (check:settled, whose
reason this gate already documented: it fails inside any build by design);
baselined in internal/fixtures/orphan-checks-baseline.json, today's 131 orphans, for their
owners to chain, declare or delete. That list only shrinks (--shrink).
A new unchained script fails by name. A self-test runs first on every invocation. Proven two-sided on
the real tree: a planted unchained script exits 1 naming it; swapping exact matching for the old
substring test makes the self-test refuse; restored, it passes.
A browser given a 390px viewport still lays the page out at the 980px desktop
fallback unless the document declares <meta name="viewport">. Measured in
Chromium at 390x844 with isMobile:true:
without the meta: window.innerWidth = 980 (body 964px)
with the meta: window.innerWidth = 390 (body 374px)
Nothing warns. The page renders, the probe reads its numbers, and the check
goes green having measured a viewport no phone has. A full set of mobile runs
was taken this way before the layout width was ever compared to the requested
one, so the conclusions drawn from them described the wrong viewport.
Three gates already read window.innerWidth and none compares it to the width it
asked for, so all three would pass at 980 while reporting 390. This asserts the
property that makes their numbers mean what they say.
Checked on the built artifact rather than the generator template: a template
containing the right string proves nothing about the HTML that shipped. No
server needed.
width=device-width is required specifically, not just the tag: a meta carrying
only initial-scale still lays out at 980, so matching the tag name alone would
leave the gate unable to fail on its own founding bug.
The 28 sheets under assets/posters/ are exempt by their stated shape - a fixed
paper canvas (.stage{width:1500px;height:2000px}) has no mobile layout to get
wrong. Keyed on that property, not the directory, so a poster keeps its
exemption if it moves and an app page in that folder does not inherit one.
Mutation-tested against the real tree: stripping the meta from a shipped page
fails it, and replacing it with an initial-scale-only tag fails it. 8 self-tests
cover both, plus the exemption and its inverse.
check:gospel-whiteboard does not cover the adoption in the previous commit. I
poisoned GiMap into whiteboard:true — the exact regression that blanks the
production geographic map — and it still exited 0. It asserts chart geometry
(projection, radii, the bounded gap) and imports no component, so a call-site
option is invisible to it. Its pass was never evidence for this change.
check:board-map-options asserts what actually regresses: every
createWhiteboardMap call states `whiteboard` explicitly, and a geographic caller
(whiteboard:false) also states `boundaries`. A caller that inherits defaults
gets the whiteboard's, which are the inverse of a real map's.
Two-sided, because a check that cannot fail is not evidence: poisoned, it exits
1 naming GiMap.vue; restored, 0. Both negative controls run in the gate itself —
a call omitting the option must be judged non-compliant, and a compliant call
must not trip it, or the gate would just ban the helper.
Registered in all three places: script, gates:pre, //scripts. gates:pre is 77.
The check was defined and in no chain. So gospel.json's sheet.isoLine, identical in all 11
catalogues and referenced by no code, stayed on development through every landing since it
arrived; it surfaced only when someone ran the check by hand. This round deletes the key and
chains the check beside check:i18n-keys-exist, so it lands green and stays guarded.
Run before chaining, on this round's tip: PASS (18.8 s in the chain), 2 keys deferred pending a
ruling. On development without the deletion it fails on that one key, as gospel measured.
The suite lists a gate only after it has been run, so these waited for one. On 5b05f019e, in the
browser slot:
check:auto-engine PASS. no token -> MapLibre, 0 Mapbox requests; a host token alone ->
MapLibre, 0 Mapbox requests; ?provider=mapbox + the token -> Mapbox,
carrying it
check:engine-contract PASS. 2 providers x 3 load states: the engine asked for, or a visible
failure, never a substitute
Both entries record those results. check:engine-contract cannot demonstrate a failure at runtime
without the defect rebuilt into a bundle; its falsifiable arm is the source gate's self-test, so
check:no-silent-engine:self-test now runs in gates:post beside the gate it proves. It was chained
nowhere.
Its case 2 expected a host token alone to draw Mapbox, the rule until 2026-09-28. It was in no
chain, so it never ran against the change that made that false, and nothing else measured the case
the change is about. The cases now:
1 no token: MapLibre, zero Mapbox requests, painted, no prompt (unchanged)
2 a host token alone: still MapLibre, painted, zero Mapbox requests
3 ?provider=mapbox with that token: Mapbox, carrying that token
It joins gates:post beside check:leaf-engine-per-provider, together with check:engine-contract
(gospel's resources-map uses the engine it was asked for, or fails visibly), which was also in no
chain. Both are browser gates: NOT MEASURED outside the browser slot, measured in it.
A wall whose host passes `sheet` now generates its poster first, from the served
lib/poster-render.js, and never requests posters/ at all. The two gates for the stored-file path
predate that, and neither measured anything since:
- check:poster-print-fallback blocked only the .webp, so the generator printed and the gate saw
0 .webp and 0 .html requests: a FAIL on two served trees that said nothing about the fallback.
It now aborts lib/poster-render.js and its classic twin as well, so rasterising posters/<id>.html
is the one way through, which is what a reader gets when the generator cannot load.
- check:poster-degrades looked for a tree serving neither stored file and SKIPPED with exit 0 on
any other. The served tree ships the sources now, so it skipped every time and the skip read as
a pass. It now forces the worst case in the browser (every posters/*.webp and *.html answers 404,
the generator is aborted) and asserts the honest refusal on any tree. No wall url is NOT MEASURED.
The fallback itself is live code, not dead: it is the path whenever the generator is absent or fails.
Every map profile's copy-paste snippet began with a preload of
api.mapbox.com's mapbox-gl.js, while its own profile-config said provider
"maplibre", and since 2026-09-28 only an explicit provider "mapbox" selects
Mapbox. So an unedited paste downloaded the Mapbox engine on every view,
never used it, and made a Mapbox request from a keyless page. Its only
guard was a stale comment asking the partner to delete the line.
- embed.script now preloads MapLibre's script and stylesheet, pinned the
way useMapInstance loads them so the preloads are reused, and its note
points a host who wants Mapbox at the variant.
- embed.scriptMapbox is that variant: the Mapbox preloads and the same
element with "provider":"mapbox".
- check:snippet-engine (gates:post, with a self-test) reads the built
manifest: no default snippet names api.mapbox.com, every map snippet
preloads MapLibre, and every Mapbox variant preloads Mapbox and sets its
provider. Planted: the old snippet and a provider-less variant both fail.
package.json's gate descriptions, vite.config.js, the build config's
comment keys, capacitor.config.json and .gitignore named or quoted the
Driver in 13 lines. They sit outside check:no-new-driver-references'
areas, but they ship in the giveaway all the same, so they are reworded
the same way as the sweep. The two left are the reference gate's own
description, which has to say what it checks.
openPrintFrame's autoprint prints on the frame's 'load', which waits for every image, but the frame was removed 60 s after it was CREATED, loaded or not. The Wall's poster loads 939 photos (132 MB), so on a connection below about 18 Mbps the frame was gone before 'load': no print dialog, and nothing to say so. Now the frame is reclaimed 60 s after its load, at afterprint (as before), or at a 10-minute ceiling for a load that never ends. printImageBlob revoked its object URL on the same creation clock, which blanks an image that has not loaded; it now follows the frame's clock too.
New gate check:print-frame-reclaim (gates:pre), fake timers and a fake frame, so 61 s and 10 minutes take no time: a load at 61 s still finds the frame (and the object URL), 60 s after load it is reclaimed, a frame that never loads goes at the ceiling and not before, afterprint reclaims at once. Two-sided: the copy before this fix fails 6 of its 12 cases (the load-after-61-s and ceiling arms); the controls pass on both.
A locale can arrive two ways. ?lang and <html lang> are read at script
evaluation, and the preload in i18n/index.js covers them. profile-config —
the channel a host page uses — reaches ProfileLoader's watchEffect and
nowhere else, which is the channel's whole purpose; the comment there
already records that an earlier document-level fix never saw it. So with
the build shipping one locale, a host-configured embed set its locale,
mirrored its direction, and then rendered English with no error anywhere.
One line beside the setRuntimeLocale that marks that path.
The RTL hold is the second half. When the catalogue is fetched rather than
inlined, nine locales get a re-render — the words change, nothing moves —
and Arabic gets a layout flip: English left to right, then the whole chrome
mirrors. So the text waits and the map does not.
Not a v-if on the profile: <component :is="ProfileComponent"> is the entire
plugin, map included, and gating it would hold the one thing that must
never wait. A data attribute on the existing root plus a scoped rule over
named chrome classes leaves every component mounted, re-enters nothing, and
lets the map paint on its own schedule. The classes are named rather than
matched by a blanket descendant rule because two of the text-bearing
components also carry map objects. visibility rather than display, so the
chrome keeps its box and nothing reflows when the text appears — a reflow at
swap time is the same defect as the flip, only quieter.
The gate asserts the seam. "ProfileLoader mentions loadLocale" would pass on
an import nothing calls, so the watchEffect's own body is read by brace
depth and the call has to sit inside it. It also refuses a hold that names
anything map-shaped, a hold applied to the whole subtree, and a fetch that
could run without a pin. Poisoned both halves: removing the call and
pointing the hold at a map container each fail with the message that names
the defect.
(cherry picked from commit 5c54750d58)
A map says how it behaves on a narrow screen once, in its <profile>.meta.json:
"mobile": "desktop-only" (blocked on a phone) or "larger-screens" (opens,
under a dismissible notice). Absent means phone-ready. Until now the rule
lived only in the Research Gallery, as two hard-coded id sets, so a map
opened standalone on its own leaf page on a phone got neither the block nor
the notice.
- profile-meta.mjs knows the vocabulary; generate-tree and the dev manifest
carry the flag into each profile's manifest entry, and generate-tree into
the leaf page's BASE_CONFIG (with the map's name for the desktop-only
sentence).
- ProfileLoader mounts library/components/LargerScreenNotice.vue on a screen
under 640px: for desktop-only the profile is never mounted and a centred
message says why; for larger-screens a slim in-flow bar sits above the map
(it cannot cover the map's controls), dismissible per profile for the
session, in the element's own locale and direction. Inside the Gallery it
stays quiet: every Gallery pane now carries data-doxa-gallery.
- One text: the Gallery's two sentences moved into the library catalogue as
messages.largerScreenNotice / desktopOnlyNotice (+ noticeDismiss), all 11
locales moved as they were, and the Gallery reads them from there.
- The Gallery derives its rules from the manifest's flags. On a phone it
mounts nothing until the manifest has landed: its baked first tab (Big
Picture) would otherwise mount there before the flags say it is
desktop-only.
- dashboard-plus-grid is desktop-only and gospel-internet/board is
larger-screens, the two sets the Gallery held; nothing changes for either.
- check:mobile-gating now asserts the vocabulary, that each flagged leaf page
carries its flag, the Gallery's derivation and its closed-before-manifest
rule, that Big Picture stays desktop-only, the 11 locales, and the
notice's mounting. Its self-test plants one failure per rule.
Code loaded at print time executes on partner pages, so it is pinned like every embed. The bundle
carries the generator's sha384 (library/packet/poster-render.integrity.js, written by
`bun run pin:poster-render`); posterSheets fetches <assets>/lib/poster-render.js as TEXT, hashes it
with WebCrypto, and on a match imports the verified text from a blob: URL — never the network URL a
second time. A mismatch refuses to run, with a message naming the URL and both hashes; the print falls
back to what it can do without the generator.
The page base is captured when posterSheets evaluates, and every URL it resolves (the generator, the
boundaries, the places) resolves against it — not the bundle's location, and not a URL a single-page
host changed after load.
check:poster-render-pinned (gates:pre), two-sided: the pin equals the sha384 of the source and of the
served copy (a stale pin names the command — proven by an unpinned edit); a TAMPERED copy is refused
and its planted side effect never runs; the genuine copy loads and draws. check:poster-browser-equiv
still passes byte-identical through the pinned path. country-posters 1,472,734 bytes (ceiling
1,500,000), my-upg-100-list 1,232,508, both carrying the pin; verify-bundles-shipped PASS; gates:pre 74/74.
The Driver's print standard: every export is generated when it is asked for, and only previews are
stored. The zone and country map sheets — the poster, and the map-only sheet on a packet's page 1 —
existed only if library/scripts/posters/generate.mjs (Node) had written them ahead of time; no country
had a print-resolution sheet at all.
- library/packet/poster-render.js is generate.mjs's drawing layer (2,645 lines) as a pure factory,
createPosterGenerator({ env, SERIES_DEF, ISO3_NAME, adm0, adm1Ind, adm1Chn, nePlaces, series }). Moved
VERBATIM (template literals carry their whitespace into the output), `process.env` read through a
local shadow; its inputs are its free variables, found by a scope-aware parse, not a grep (a grep
missed adm0, and the proof caught it). generate.mjs is now its Node driver.
- PROVEN BYTE-IDENTICAL: all 118 sheets of a pinned offline matrix — the 28 zones in 3x4, 3x3, letter
and packet, and a country sample at both sheet sizes — hash the same before and after.
- data/zone-sheet.js is which group pins in which zone (first claim wins), now ONE function both the
Node driver and the browser call, so a group cannot pin in one zone on paper and another on screen.
- library/packet/posterSheets.js feeds the factory in the browser: the served full-resolution
boundaries, the shared series and places tables, a generator per call (caching by format pinned the
first caller's data on every later sheet). generateCountrySheet does a country end to end;
data/zone-sheet-print.js a UPG-100 zone. ISO3_NAME moves to library/data (territory-counts re-exports).
- usePosterPrints gains opts.sheet: the poster print is the generated letter sheet as a VECTOR page,
and the packet's page 1 the generated map-only sheet (item.mapId honoured); stored images remain the
fallback when generation fails. PosterWall forwards it; the Country Posters wall, the UPG-100 wall and
the UPG-100 map pass it. The UPG-100 wall and map drop their own record fetches for the shared
provider, and the map's legend and selection HUD offer the card deck (production).
- check:poster-browser-equiv: the browser feed's sheet is byte-identical to the Node generator's for an
India zone (packet) and a country — proven two-sided (it fails when the feed drops the city dots).
check:selection-kinds reads the handler's branches instead of a fixed poster/packet pair, anchored on
`if (` (proven: it fails with the cards branch deleted). library/packet/README.md: the standard.
gates:pre 68/68; both bundles build.
poster-wall translates its section headings by looking the key up by the
English: one table maps a zone id to a heading, a second maps that heading
to a catalogue key, and the helper returns the raw English when the second
table has no entry. Two headings reachable from four zone ids had no key, so
they printed in English in all eleven locales. upg100 found and fixed that
one; this is so the next table cannot repeat it.
The hardcoded-English scan cannot see this class. There the English is a
lookup index rather than a rendered label, and the key is computed, so a
scan that reads keys written literally at the call site has nothing to
check. Resolving arbitrary key expressions is a static-analysis problem;
this does the decidable part — two object literals in one file where the
values of the first are the keys of the second, and coverage is a set
difference. The table list is declared, not discovered, because a heuristic
hunting for related-looking objects would be guessing about code it has
never read.
Two things the check has to get right and could quietly get wrong. It
compares the VALUES of the producing table, not its keys — the ids are
never translated, and reading them finds nothing while looking like a
check. And a label passed straight to the helper as a literal counts as
produced: three of the nine headings never appear in the table at all, and
"Other" is exactly the catch-all that gets added last and keyed never.
A renamed table fails loudly rather than comparing two empty sets and
calling it agreement. The self-test covers a covered table, a gap, a gap
reachable only through a literal argument, and the renamed case; poisoning
either parser or the object matcher trips it.
Verified against both real states: development's poster-wall fails naming
Americas and Europe, and upg100's fix passes.
RegionCountryMap builds its map popup by concatenation and escaped every value
in it -- the name, the religion, the description, the image, both button labels
-- except the two URLs:
<a class="cm-popup__btn" href="${profileUrl}">
Both come from joinBase(props.researchUrl, p.slug), and joinBase says plainly
that it only normalises slashes. So two untrusted values reached an HTML
attribute raw: the slug, which is API data, and the base URL, which is an
attribute set by whoever embeds the element. With a slug of
`x" onerror=alert(1) data-x="` the rendered tag is
<a href="https://doxa.life/research/x" onerror=alert(1) data-x="">
out of the attribute and into new ones. esc() was already imported in that file.
The same omission in library/packet/packet-render.js, found by sweeping for the
class rather than assuming it was one site: the people-group card writes
src="${src}" where src is photos.get(slug), and usePosterPrints builds that Map
directly from the API's image_url. esc(g.name) sits two lines below it.
Neither could be caught by review or by template analysis -- both are inside
template literals -- and the popup had never once rendered, because the Regions
map was grey. So the gate: no href= or src= in built HTML takes a raw
interpolation. Data URLs, blob URLs, encodeURIComponent and safeUrl() are
recognised as safe, each verified at its call site rather than assumed from the
name; anything else needs an href-escaped-ok: reason in the four lines above.
Two such reasons are recorded, for the two sites the check cannot see the safety
of: useMapAttribution's hrefs are module constants selected by engine type, and
export-public builds its rel entirely from encodeURIComponent one line earlier.
The sweep also cleared seven sites it first flagged -- three blob: URLs from
createObjectURL, two data: URLs from readAsDataURL, safeUrl(), and the
encodeURIComponent pair -- so the check now recognises those shapes instead of
reporting them.
Mutation-tested: reverting either fix alone turns the gate red. 6 self-tests
carry the founding bug and the safe shapes. gates:pre 65/66, the one red being
check:poster-action-print waiting on gospel's GiTree fix.
check:poster-action-print asserted that PosterWall defaults to printing and
that any consumer passing posterAction 'download' explains itself. Both held,
and both missed the case that actually shipped: a component that never touches
PosterWall and writes its own <a download> for a poster image. GiTree had a
"Download SVG" button as the sibling of its own printPoster(), same
makePoster() behind each, so the poster could reach disk without the print
dialog ever opening.
Second arm: no shipped file may set a.download to an image or document name
(svg/png/webp/pdf/jpg) unless the line is reached only after a print attempt
failed (if (!opened) / if (!w)), or is the prop-gated opt-in the first arm
already polices, or carries a posterAction-download: reason. Data exports stay
out of scope -- csv, txt and json are not posters, so the five legitimate CSV
and letter exports stay green.
Keying on the filename at the call site rather than on the helper matters:
four of the seven download sites pass a variable, so a check written against
the helper would have passed them without being able to see what they wrote.
Six self-tests carry the shapes: the GiTree line fails, the print-blocked
fallback passes, the gated opt-in passes, the explained exception passes, csv
and txt pass. They are the proof the arm still bites now that no offender is
left in the tree.
An absolute embed address works where it was built and breaks everywhere
else, which is how the CDN route failed and was retired. The build config
keeps embedBaseUrl empty, so the committed tree is relative and a deploy
makes it absolute at build time (EMBED_BASE_URL). But nothing stopped a
tree built with EMBED_BASE_URL from being committed: check:drop looks for
root-relative "/..." references and deliberately skips https:// ones.
With embedBaseUrl empty in the config, the manifest must say embedBase "."
and every scriptUrl, iframeUrl and embed snippet src must be relative. Data
addresses (runtime.apiBaseUrl) are not the tree and are not checked. SKIPs
when the config sets embedBaseUrl. In gates:post, after check:drop.
Today's tree: PASS, embedBase "." and 173 embed addresses, all relative.
Poison-tested, each fails: an absolute snippet src, an absolute embedBase,
a protocol-relative iframeUrl, an empty manifest. A real deploy manifest
built with EMBED_BASE_URL fails with 174 findings, as it should.
gates:post: PASS 82, NOT MEASURED 13 (browser/server), FAIL 1: the known
check:packets-all worktree failure (no generated zone packets here).
The country wall passed no records, so each of its 175 cards offered only the poster and said the
prayer list was "coming soon", while the people groups were one request away. library/lists/
peopleGroupRecords.js is the records half of the print standard: one people-groups fetch per language
(normalised the way the prints read rows, list membership applied), then a SELECTOR picks the set — a
country by ISO3, explicit slugs, any field, or a host's own selector language. A selector nobody
understands selects nothing rather than every group on earth, and a failed fetch is not cached.
The country wall uses it by ISO3, so every country now prints the list, the deck (all five stocks) and
the full or lightweight packet. The packet's page 1 is, for now, the country's stored poster when it is
really there; the in-browser map generator will replace that.
check:people-group-records asserts the selection two-sided with an injected fetch; registered in
gates:pre.
Every reference to the Driver in internal/ is reworded the same way as in
library/: the reason stays, the speaker and the quote go, an open decision
reads "provisional, pending a product decision", and agent names and decision-
sheet rows are dropped. Where a gate printed the word, its message now names
the reviewer or the ruling instead.
Three spots were code, not comments, and changed with their callers:
- the element census's allowlist field `driver` is now `ruling`, in the gate
and both presets (same validation: match, ruling, date and card required);
- check:i18n-never-translated's DRIVER-GATED marker is DECISION-PENDING, in
the entries and in the regex that counts them (its output is otherwise
byte-identical);
- the package.json description of the census says "ruling".
public-hygiene-check keeps its two patterns for the word: they are what keep
it out of the served folder.
The pattern this replaces built a sentence by concatenating translated fragments
around untranslated text — "languages have", "of", "people." are each a separate
string in gospel-resources today. Arabic does not put those clauses in English
order, so translating the fragments yields Arabic words in English order:
grammatically broken, and worse than leaving it English, because it looks
translated and nobody re-checks it.
library/composables/useSentence.js takes ONE message with named placeholders and
a value per name. It renders sentinels, splits on them and drops the values back
in, so a translator moves the clauses freely and the markup survives as slotted
content: the bold counts and the dir="ltr" island are passed in, never written
into the catalogue. No <i18n-t>, and it works with precompiled messages.
Built to studio's four constraints: the caller passes a whole key literal (the
helper takes a MESSAGE and cannot assemble a key, so the build's area scan still
sees it); t comes from the calling component; a sentence with two counts receives
each already pluralised from its own key, since vue-i18n resolves one plural form
per key; and the sentinels are private-use (U+E000+n) so nothing in the pipeline
may rewrite them.
check:sentence-slots, 14 assertions, every arm with a negative control — and two
of them caught real defects in this change. A slot value of 0 rendered as an
empty string (the bug was in my test's flattener, not the helper — 0 is a real
count and "0 of 7,296" is a sentence a filter legitimately produces). And a
private-use character already IN a message was consumed as slot 0, printing
"a X b X" while the arm still passed because it only checked the value was
present; strays are now stripped before ours are placed, so every remaining mark
is ours by construction.
The gate measures the built folder, so it belongs after the build, in
gates:post, with its description beside the others. It now reads the tree
name from _tree-name.mjs like every other tool: the folder was renamed
once already, and a gate hardcoding the old name kept reading it.
The row now says 1205 files, 154 MiB: the folder as it stands with the
350 city and country posters this landing ships.
dx-base measured two defects in the A8 draft for text that goes public:
- Links into the private DX Life repo answer 404 to anyone without an account. The live
example is now plain words (README, DEPLOY), and the clone line points at the public repo
(doxa-open/doxa-maps, as DEPLOY already did). DEPLOY's "three ways" becomes the two routes;
the CDN route is retired.
- The web folder row said 536 files, 76 MiB. The folder is 855 files, 119.2 MiB. New
check:readme-size re-measures public/dx-maps and fails when the row is more than 10% off,
printing the line to paste. It passes at 855/119; the old numbers fail.
Two halves, Node only. The helper, against fake responses: a real WebP
is accepted by type or by its bytes; a host's 200 fallback page is
refused as a raster and as a source; HTML labelled octet-stream is
refused; a 404 and a network error are misses for the raster; a network
error on the source is thrown as itself. The callers: neither
usePosterPrints nor PosterWall may fetch posters/ directly again, since
each once carried its own copy and a guard in one did not reach the other.
Poison-tested: restoring the old accept-anything fetch fails 3 checks,
and PosterWall's direct fetches failed the caller check before its switch.
gates:pre: PASS 67, FAIL 0.
With posterSources on, the tree shipped only the 28 zone poster sources,
so exporting any of the 176 city or 174 country posters said "poster not
found (404)": 350 of 378 posters could not be printed.
Each poster now ships once: its .html source where one exists, else its
.webp. Generating sources for the 350 was not an option: city posters
need OSM street tiles to regenerate (publish-html.mjs), and a source is
the heavy form (about 1 MB each against about 0.1 MB for a raster).
Measured with check:tree-weight: 102.7 MB before, 137.9 MB after, of
160 MB. The 350 rasters are the tracked data/posters/*.webp: all 350 are
existing git blobs, so the repository gains no new pack bytes.
New gate check:posters-exportable (in gates:post). Every poster id, from
data/posters and the wall's previews, must ship a real WebP or a real
poster source (its class="stage" element), so a placeholder or a host's
fallback page cannot pass. It skips when posters are off by config, which
check:poster-degrades covers. Poison-tested: a missing poster and a fake
image each fail.
A locale can arrive two ways. ?lang and <html lang> are read at script
evaluation, and the preload in i18n/index.js covers them. profile-config —
the channel a host page uses — reaches ProfileLoader's watchEffect and
nowhere else, which is the channel's whole purpose; the comment there
already records that an earlier document-level fix never saw it. So with
the build shipping one locale, a host-configured embed set its locale,
mirrored its direction, and then rendered English with no error anywhere.
One line beside the setRuntimeLocale that marks that path.
The RTL hold is the second half. When the catalogue is fetched rather than
inlined, nine locales get a re-render — the words change, nothing moves —
and Arabic gets a layout flip: English left to right, then the whole chrome
mirrors. So the text waits and the map does not.
Not a v-if on the profile: <component :is="ProfileComponent"> is the entire
plugin, map included, and gating it would hold the one thing that must
never wait. A data attribute on the existing root plus a scoped rule over
named chrome classes leaves every component mounted, re-enters nothing, and
lets the map paint on its own schedule. The classes are named rather than
matched by a blanket descendant rule because two of the text-bearing
components also carry map objects. visibility rather than display, so the
chrome keeps its box and nothing reflows when the text appears — a reflow at
swap time is the same defect as the flip, only quieter.
The gate asserts the seam. "ProfileLoader mentions loadLocale" would pass on
an import nothing calls, so the watchEffect's own body is read by brace
depth and the call has to sit inside it. It also refuses a hold that names
anything map-shaped, a hold applied to the whole subtree, and a fetch that
could run without a pin. Poisoned both halves: removing the call and
pointing the hold at a map container each fail with the message that names
the defect.
poster-wall translates its section headings by looking the key up by the
English: one table maps a zone id to a heading, a second maps that heading
to a catalogue key, and the helper returns the raw English when the second
table has no entry. Two headings reachable from four zone ids had no key, so
they printed in English in all eleven locales. upg100 found and fixed that
one; this is so the next table cannot repeat it.
The hardcoded-English scan cannot see this class. There the English is a
lookup index rather than a rendered label, and the key is computed, so a
scan that reads keys written literally at the call site has nothing to
check. Resolving arbitrary key expressions is a static-analysis problem;
this does the decidable part — two object literals in one file where the
values of the first are the keys of the second, and coverage is a set
difference. The table list is declared, not discovered, because a heuristic
hunting for related-looking objects would be guessing about code it has
never read.
Two things the check has to get right and could quietly get wrong. It
compares the VALUES of the producing table, not its keys — the ids are
never translated, and reading them finds nothing while looking like a
check. And a label passed straight to the helper as a literal counts as
produced: three of the nine headings never appear in the table at all, and
"Other" is exactly the catch-all that gets added last and keyed never.
A renamed table fails loudly rather than comparing two empty sets and
calling it agreement. The self-test covers a covered table, a gap, a gap
reachable only through a literal argument, and the renamed case; poisoning
either parser or the object matcher trips it.
Verified against both real states: development's poster-wall fails naming
Americas and Europe, and upg100's fix passes.
The standing rule is that comments, docs and UI strings say what the code does and why, never who said
what. Several hundred existing lines break it; owners remove them in one sweep per area before the public
giveaway. Until then check:no-new-driver-references (gates:pre) fails any NEW line that refers to the
Driver and baselines the rest, keyed by file and line text with counts rather than line numbers, so
parallel sweeps only ever shrink it. It matches the role word, not a GPU or device driver, and its
self-test proves both that and that a new line beside baselined ones is isolated.
It had a script but no description and sat in no gate list, so a map fetching the people-group list
around the library (upg-neighbor's photo lookup) stayed red on development until someone ran it by
hand. With that lookup allowed by name (the commit before), it passes; it goes into gates:post, where
it reads the shipped bundles too, and now carries its description.
The drop-in was proven on a laptop with one server: the three DX Life branches rendered their maps,
7 of 7 checks passed, with internal/sync-dropin.mjs --from-config --dest <doxa-life>/public. This
makes that the default path:
- dropin.dest is ../doxa-life/public. The old ../doxa-life/public/embed/doxa-maps-build was stale.
- bun run dropin = sync-dropin --from-config; deploy:dev (build + drop-in) now uses it.
- The README gains "The easiest path": the studio for the work, dropin.dest at your site's public/,
then build and drop in.
One hazard came with the new destination, and it is closed here. --prune deletes every file in the
destination outside the drop-in's allowlist, which was right while that was a folder of its own. Into
a site's public/, it would delete the site. deploy:dev passed --prune, so it no longer does, and
sync-dropin refuses --prune into any folder named public/. Measured in a scratch site: --prune into
site/public exits 1 with the reason; a plain drop-in copies dx-maps/ and leaves the site's own
file in place.
Both were registered but in no gate list, because each was red on development: wiring either in
would have failed every landing. They are clean now (the plugin-studio widget release fixed the
first, the country map's legend rows the second), so they go into gates:pre, where a regression
fails the landing instead of waiting for someone to run them by hand.
key-cities is a gated profile: generate-tree leaves it out of the manifest and the pages. Its
bundle file was still in public/dx-maps/key-cities/key-cities.js, committed, and the landing
freshness pass rebuilt it into public/ every time it looked "stale", so any host that serves or
drops in the tree folder whole shipped it.
- vite.config.js builds a gated bundle into .gated/<tree>/ (ignored by git) instead of public/.
- check:composite-freshness skips gated bundles: they have no committed bytes to be fresh against.
- The committed public/dx-maps/key-cities/ is removed. History is not rewritten.
- generate-tree removes a gated folder it finds in the tree (an older build, or a landing that kept the
tree's side of a conflict), so the tree it writes is clean whatever came before. Measured: a planted
public/dx-maps/key-cities/key-cities.js fails the gate, and one regeneration removes it.
- The gated list is read in one place, internal/_tree-name.mjs, beside the tree name.
- check:gated-not-in-tree (gates:pre) fails if a gated name has a folder in the tree, a manifest
entry, or a page referencing its script, and proves each arm on a planted leak first. It FAILED
on the tree before this change (key-cities/ is in the tree) and PASSES after.
The data tables are left alone on purpose: key-cities.json also feeds the production Cities with
Internet map, and whether that dataset should be public is a separate decision.
doxa-maps-spa marks the pane of every tab that has explainer text with data-doxa-help, listens for
doxa:help, and opens the same what/how dialog for the active tab. Closing returns focus to the
exact "?" that opened it (composedPath reaches into the map's shadow root). The top-centre button
and its CSS are gone; a comment for the device-rule toast that sat above the old button's rule
is back on its own rule.
check:help-reachable (gates:pre) keeps it reachable: every map with text has a hosted "?" or a
named help control of its own; the app listens, marks its panes and has no top-centre button; and
the composable, executed on a small fake DOM, dispatches doxa:help bubbling and composed and finds
the host across a shadow root. Six planted defects, each caught.
e18c7f05a declared the map relabeller above LANG, which it reads: a
temporal-dead-zone ReferenceError at startup, so every offline packet
run stopped before building anything. node --check passed, because it
checks syntax and not evaluation order, and no gate ran the generator.
The block now sits after LANG and VARIANT, with a note on why.
packet:ci already built the committed fixture packets through
packet.mjs, with no network, in under five seconds - and it catches
this exactly: exit 1 on the broken generator, exit 0 on the fix. It was
in no gate list, so it never ran. It is now check:packet-offline in
gates:pre.
Its failure message printed the last line of stderr, which for a crash
is Node's version banner ("Node.js v20.20.2"). It now names the error
line: "ReferenceError: Cannot access 'LANG' before initialization".
The map sheets are generated once, in English, so in an Arabic or
Chinese packet page 1's map labelled every country in Latin capitals,
and its privacy line was English.
The labels are now replaced as the sheet is lifted into the packet.
They are recognised by the generator's own rule rather than a list:
generate.mjs draws each label as esc(ISO3_NAME[iso3].toUpperCase())
from the zone data's ISO3_NAME table, so inverting that table under
that transform names the country behind every label it can draw. The
inverse is unambiguous (162 labels, no two countries sharing one), so a
label is known exactly or left as drawn. The map's privacy line is word
for word the packet's privacy_note, so it takes this language's.
A width guard: the generator placed every label and then dropped any
that would collide, measuring the English name, so a translation is
fitted into that same box - estimated wider, it shrinks in proportion
and cannot reach a neighbour the English name did not. On the Sudan
poster, six Arabic names shrink (the longest, Saudi Arabia, from 15px
to 10px); none of the narrower Chinese ones do. Capitals' tracking is
dropped for scripts without capitals, where it breaks Arabic joining.
English is untouched: its relabeller is null, and the lifted sheet is
byte-identical with and without it. No sheet or poster is regenerated -
the change is in the lift, so the tracked 3x4 posters page 1 now uses
are relabelled as they are.
Both print paths supply the relabeller: the UPG 100 profile from its
own ISO3_NAME and the library's country-name resolver, the offline
generator from the same table and the injected ISO-3 to ISO-2 table.
check-map-labels (gates:pre), on the tracked Sudan poster: English
byte-identical; in ar and zh no known English name left, halo matching
fill, privacy localized; the guard fires for Arabic, not Chinese, and
grows nothing; Arabic tracking off, Spanish kept; an unknown label left
alone. Mutation-tested five ways, each caught.
The page says each box is a share of the people in this list. It
printed one DOXA-wide figure - 0.6133% of 209,079,159 people - in
every packet, true of no single list.
That figure had no builder in the repo. build-evangelical-share.mjs
makes its method reproducible, per group: the IMB CSV joined to the
DOXA snapshot by ROP3 + country, each group's EvangelicalLevel band at
its midpoint (0 for No Known Evangelicals, the midpoint read from the
band's own wording), weighted by IMB population. It writes
evangelical-by-slug.json and refuses to write unless the table, summed
over every DOXA group, reproduces christian-share.json exactly: 2,091
matched, 209,079,159 people, 1,282,178 evangelical, 0.6133%. Change a
single band's midpoint rule and it refuses.
Not the API's imb_gsec: GSEC puts 225 groups at "no evangelicals" where
EvangelicalLevel puts 1,444, because its "less than 2%" band also
carries church-planting status. A share from it sits on a different
scale from the method the page's own footnote states.
evangelicalShare.js sums a packet's own groups - pure, zero imports, so
the browser print and the offline generator run the same sum. Across
the 28 zones the page now carries 28 different figures, from 0.182%
(Deaf) to 0.922%. A host that supplies its own figure still wins.
A list with no published levels prints a dash and no number. The old
guard, Number.isFinite(Number(x)), passes null as 0 and would have
printed "0% evangelical" for no data. Where some of a list's groups
lack a level, the footnote says what the figure is computed over; it
says nothing when coverage is whole. That clause is in all eleven
languages.
check-evangelical-share (gates:pre): the table reproduces the committed
figure; different lists differ; no data is a dash; coverage appears
only when partial. Mutation-tested four ways, each caught - the fourth
only once it corrupted an existing group rather than adding one no sum
reads.
Every packet was titled with its zone's hand-written English label, so
a Spanish Sudan packet said "Sudan" and an Arabic one printed Latin
script on page 1, on every profile page's header and in the document's
own title.
A one-country zone's title is that country's name, and Intl supplies it
in every language: the packets now carry it - Sudán, السودان, सूडान,
苏丹 - through zone-title.js's tier A, on both print paths. Every other
zone keeps its hand-written label, since a region's name is not a list
of its countries (tier B and C are a pending product decision). The
selection packet names its cover the same way.
In the browser the title uses the library's country-name resolver; the
offline generator cannot load that module under Node, so it reads the
ISO-3 to ISO-2 table and injects it, the path zone-title.js was built
for. A data set without zone-title.js keeps its labels.
check:zone-title-tier-a was committed unregistered and so never ran; it
is now in gates:pre. Its comments, and zone-title.js's, no longer cite
people or dates.
FULL DETAIL is four front pages, the profiles, four back pages:
map, triangle, life calendar, % evangelical, profiles,
bounded set, centered set, prayer wheel, 12 prayer points.
LIGHTWEIGHT is two front, the profiles, two back:
map, triangle, profiles, prayer wheel, 12 prayer points.
Both are generated from data, in every packet language, from the
browser and from the offline generator.
PAGE 1 - the map, on a vertical page. One lift for every caller
(packet-render.js liftMap): the browser composable, the UPG 100
profile and the offline generator each carried an identical copy of
the old wrapper (250px down, scale 0.62), and three copies of a layout
decision is how page 1 drifts between print paths. The lift prefers
the map-only vertical sheet (mapSheetFiles: <zone>.packet.html, then
the poster) and places it so its names land on the same 60px margin
every other page keeps; a poster falls back fitted under the title at
0.81 rather than 0.62. The title block is drawn after the map, since a
map-only sheet begins with a full-page background. Anything that is not
a map sheet - an icon-sized <svg> from a host that answers a missing
file with a 200 page - is refused, and page 1 says the map is missing.
Page 1 now states which set it belongs to ("One of 28 UPG 100 prayer
lists"), only when the caller supplies the count.
PAGE 2 - one inverted triangle in the Vision Card's look. The card's
steps are the top (every people group, the unreached, the frontier,
the network's list), narrowing to the UPG 100 packets and, at the tip,
this packet. The card's greens, darkening as it narrows, walked
through its own colour stops; every numeral picks white or ink by the
WCAG ratio (all at least 3.4:1, large bold type). Tier widths are
geometric, not proportional - 2,107, 28 and ~100 against 12,379 would
be hairlines - so where two sets truly nest the caption states the
step removed, and the network's list, which does not sit inside
frontier, gets the card's "N of them are frontier" note instead of a
false subtraction. The packets tier and its count appear only when the
caller supplies one.
BACK PAGES - the bounded and centered sets get a page each at twice the
width, under the one page key, so lightweight drops the pair together.
THE WHEEL - the Metacamp Module 1 prayer wheel: its twelve colours,
sampled from the fill operators in the source PDF's content stream; its
orientation, the opening PRAISE at nine o'clock and READ THE WORD at
twelve; white labels with a shadow and a faint outline, so a print
path that drops SVG filters still leaves them legible on the palest
segments; "5 minutes" badges; the attribution with the website. The
labels stay the packet's own rhythm names, translated in every
language and matching the 12 points page.
The offline generator makes both packets (PACKET_VARIANT=lightweight;
the full packet keeps its names, lightweight is <zone>.lightweight and
stamped with its variant).
STRINGS: four new keys, in all eleven blocks - each block's own words
for "packet" and "prayer list", the frontier note aligned to the word
the frontier tier uses on the same page.
GATES: check-packet-a1-structure (registered in gates:pre) reads every
sheet's CONTENT for the order of both variants, page 1's lift in all
four cases, the triangle's tiers and steps, the wheel's colours and
orientation, and all 28 zones. Mutation-tested eleven ways, each caught
by its own message. check-packet-variants now asserts both sets pages
and a four-sheet difference. The browser gate check-packet-render
counted funnel rows and bars by inline-style substrings, which an SVG
triangle has none of - both would have read zero and passed - so it
now counts .funnel-n against .funnel-tier, its sheet arithmetic counts
the sets pages, and its Latin allowlist carries the renamed product
name. check-packet-browser used a copy of the old wrapper; it now uses
the product's lift.
One landing because it is one bundle: the ring band layout wiring (already on
this branch) plus the three set-fit commits, replayed as SOURCE only. The
generated tree is rebuilt below rather than merged — a previous cherry-pick of
mine swept a conflicted generated file into a commit, and hand-resolving build
output is how that happens.
From set-fit: extentFraction 0.17 -> 0.095 on both charts, so the drawing fits
the height the engine's zoom floor leaves it; crossFeature restored from history
and called by BOTH maps through one shared addCrossLayer; and the ResizeObserver
fit restored, which rega found missing because zoomAtOpen read 1.00 in all eight
chart/layout combinations — too clean to be a fit result.
Two things this merge had to get right, and neither was automatic:
- Taking GiCenteredSetMap.vue wholesale from set-fit silently REMOVED the ring
wiring — bandFill, curvedRingLabels and openAndFrame all went to zero while
every gate still passed. Both feature sets are now in the file together.
- Taking package.json wholesale would have deleted studio's check:ring-bands
registration, including its gates:pre entry, landing a gate nothing runs. It is
rebuilt from development's, with only check:gospel-set-fit added — in all three
places this time: script, gates:pre, and //scripts.
layoutCenteredSet gives every group on ring N the same radius, so a crowded ring
draws as one line of dots on a circle. The target needs each ring to be
a BAND, groups spread evenly through it, never overlapping each other or the
ring's label.
library/components/charts/ringBands.js is that geometry, with no Vue, no engine
and no DOM, so the Wall poster can consume the same module rather than growing a
second copy of it. bandFill() places; curvedRingLabels() returns each label's arc
AND the keep-out that bandFill refuses to place inside — computed from one source
so "pins never overlap the labels" is true by construction rather than usually.
Determinism is a requirement, not a nicety: every position is a pure function of
the item's own id, so two printings of one packet compare sheet to sheet and a
dot that moves means the DATA moved. There is deliberately no seed. Input order
does not affect any position either, so a caller that filters or re-sorts does
not reshuffle the chart.
An over-full ring SHRINKS its pins to a floor before anything is dropped — a
visible pin beats an unplaced one — and only then reports the remainder with the
ring and the counts.
check:ring-bands, 16 assertions, every arm with a negative control. One arm is
there because this gate passed the bug it now catches: uncapped, a long label on
ring 1 wanted 96% of that small circle, so capacity was zero and all twelve
groups on the innermost ring were reported unplaced while every arm stayed green
— "reported, not silently dropped" is not the same promise as "the ring draws".
Labels now truncate at a share of their own circumference and report how many
characters fit. Poison-tested: restoring the old share reproduces "empty rings: 1".
Seven shadow-root components each carry their own copy of DX Life's
base/_globals.scss rules, because page CSS cannot reach into a shadow
root. Measured first: where the copies overlap they are byte-identical
(the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter 2x),
so this is deduplication, not a fix.
The module exports FUNCTIONS, not strings, and the measurement forced
that: the six copies of the Cyrillic rule share one declaration but have
six different subjects (.rd-goal-card__title, .rd-h2, .rd-title,
.region-card__title, .rl-title x2). A fixed rule string would have to
pick one class and silently stop styling the other five. The caller owns
its selector; the module owns the declaration.
cyrillicHeading takes a selector LIST, not a selector. GoalCard styles
one heading class; RegionSectionCard groups two into a single rule
sharing one declaration block. A one-selector signature emits two rules
instead of one -- the same CSS to a browser, but not the same
stylesheet, and the bar here is unchanged, not equivalent.
Migrated GoalCard and RegionSectionCard. regions-dashboard's two files
port the same rules and stay with their owner; the harness records them
so that migration can run against this same baseline.
The img border-radius rule (_globals.scss:80) is documented as
deliberately NOT ported, because an invisible omission is one that gets
"fixed" later.
PROOF: internal/check-ported-globals-identical.mjs executes each
component's setup, captures the string handed to useShadowStyles and
hashes it. The emitted CSS is byte-identical across the refactor
(987d94b1cd66110d, 897d948b17b21efe, both captured from 45e31f102).
The harness's first version hashed the SOURCE of the call and was
wrong: this refactor replaces a literal with a function call, so the
source changes by construction -- a text-hash gate fails a perfect
refactor and cannot tell that from a broken one. It measured the
artifact when the property under test was the output. It failed exactly
that way on first run.
Shown capable of failing, four ways: dropping letter-spacing, a wrong
font name, 500->600, and splitting the grouped rule into two. All four
go red; restoring goes green. Components that cannot be evaluated are
reported NOT MEASURED (exit 75), never as a pass.
Seven components with no importer, removed. The interesting part is not the
deletion, it is that an import census was NOT sufficient to authorise it, and
two other agents proved that rather than me.
WHAT AN IMPORT CENSUS MISSED. internal/check-gospel-set-charts.mjs iterated
['BoundedSet.vue', 'CenteredSetTarget.vue'] as literal STRINGS and read their
props off disk. No import, so no census and no build would have warned; deleting
them would have turned a gate red on main with nothing to explain it. gospel
found that in their own file after I asked, and retargeted the arm at the
components that actually render (GiBoundedSetMap / GiCenteredSetMap, 2d2e853a7).
Their retarget also fixed a quieter defect: the seed guarantee had been asserted
against two files NOTHING renders, so it would have kept passing while the
shipping component grew a seed prop.
I did not take either agent's word for it. Verified gospel's retarget on their
sha and ran their gate green before deleting; verified upg100's structural claim
that packet-render.js cannot mount a component at all — 0 resolveComponent, 0
defineAsyncComponent, 0 dynamic import(), with the same probe returning a hit
elsewhere in library so an empty result is a real absence.
DELETED: charts/LanguageTreeChart.vue, components/charts/VisionFunnel.vue,
components/charts/BoundedSet.vue, components/charts/CenteredSetTarget.vue,
components/legends/ListSelectorLegend.vue,
components/dashboards/DashboardBreadcrumb.vue, composables/useMapSelection.js.
NOT DELETED, and worth stating because the names collide: the lowercase
boundedSet.js / centeredSet.js HELPERS are load-bearing — packet-render.js and
GiBoundedSetMap import them directly, and check-gospel-set-charts still does too.
Also left alone: colors/resource.js (a colour STRATEGY resolved by MODE NAME
through _registry.js, 'resource' appearing in mapStore.js and GiResources.vue)
and layouts/app-wrapper-material/nav.js (115 mentions). Both look dead to an
import census and would break a working map.
check:no-dead-library scans for a filename as a STRING across internal/,
library/ and plugin-designs/, and:
· DISTINGUISHES A READ FROM A COMMENT (gospel's ask). check-packet-variants.mjs
discusses CenteredSetTarget.vue in prose while asserting against JSON. A
comment hit is reported, never fatal — a gate that cannot tell the two apart
cries wolf once and is then ignored. It currently reports 4 such mentions.
· IS TWO-SIDED (upg100's ask). It also asserts a KNOWN-PRESENT dynamic
reference is found — LegendDesktop lazy-loads LegendMobile through import().
Mutation-tested: blinding the scanner makes it FAIL on that control instead
of passing with "no references found", which is what a one-sided version
would have done.
· ignores a longer identifier that merely contains the name, so GiBoundedSet.vue
is not read as a reference to BoundedSet.vue.
Both arms mutation-tested and every mutated file restored byte-identical.
Registered in all three places and verified by re-reading the parsed
package.json — the suite insert silently hit the scripts KEY instead of the suite
string on the first attempt, which verifying caught and trusting would not have.
17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.
The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.
CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.
WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
· SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
visibly working, no smooth spin.
· LegendProgressBar's only motion is a hover filter, so it simply stops; the
bar's FILL is geometry, not animation, and still reports its value.
The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.
check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.
Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.
Registered in all three places and verified by re-reading the parsed
package.json.
Two cards, BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2 and
BUILD-ISO3-TABLE-COVERS-ALL-242-COUNTRIES. I measured before building and most
of both already exists: countryName(iso3, lang) is in library/utils, and the
bridge is already 242 entries with an exact correspondence to countries.json —
0 codes missing, 0 orphans, verified in both directions rather than by trusting
the table's own _meta. All 242 resolve in en, ar and zh today.
So what was actually missing is the thing that keeps it true. Writing the table
and keeping it correct are separate acts, and only the first one looks like work
— which is the same reason this repo already has a gate for orphaned gates.
check:country-names asserts both directions of drift (a data code with no
alpha-2, an alpha-2 the data never uses) and that every code resolves to a real
NAME rather than to its own input, which is what Intl returns for a well-formed
code it does not know.
★ IT CARRIES TWO ARMS THAT MUST FAIL. A coverage gate that only counts hits
passes identically on 242 correct entries and on a table where every lookup
silently returns its input, so the last block feeds it an unassigned code and a
malformed one and FAILS if either is reported as resolved. Mutation-tested in
both directions before committing: deleting TCD from the bridge fails with
"1 code(s) with no alpha-2"; pointing TCD at a bogus QQ fails with three
unresolved lookups, one per locale. Table restored byte-identical after.
ar and zh are not decoration: ar is our only RTL locale, and zh uses a script
where an untranslated Latin code is unmistakable on sight.
NOT WIRED INTO THE REGIONS TABLES AND POPUPS, deliberately, against the brief.
All four regions fetches already send &lang=, so the API returns country labels
in the reader's language and summarizeRegions names each country from that
label — those surfaces are already localized. Swapping countryName() in would
replace a correct localized name with a differently-spelled one: Intl's English
differs from our canonical names for 42 of 242 countries ("Cabo Verde" ->
"Cape Verde", "Bonaire, Sint Eustatius, and Saba" -> "Caribbean Netherlands").
I re-measured that 42 rather than trusting the header that claims it. Changing
which name is canonical is a product decision and it would break clone parity
against live doxa.life, so it is not mine to make inside a localization card.
public/README.md's bundle count moves 24 -> 25: generated by bun run tree when
the country-map bundle was added on the parent commit, not hand-edited.
Narrowed by a measurement that arrived after the gate landed. The header and the
package.json description said the ordering was "not the cause" of the grey
Regions tab. True, but far too weak: the ordering is UNCONSTRUCTIBLE today.
region-dashboards.vue:1091 gates the ENTIRE map subtree behind v-if="loading",
and loadRegions assigns points.value inside the try while loading.value = false
sits in the finally. So points are always populated before the element that reads
them is permitted to mount — the map cannot mount with empty points, which is the
one thing this ordering requires.
rega measured it rather than reasoned it: 0 grey in 36 mounts, including a run
with the prayer API held back 20 seconds. The map was constructed AFTER the data
every time (API 44.4s -> construct 45.2s -> addLayer with 175 match arms). The
prediction's precondition never occurred, so it was neither confirmed nor
falsified — it was unreachable.
So this gate guards a FUTURE refactor that drops that v-if data gate. That is a
narrower and more useful claim than "hardening", and it is what the next reader
needs: someone removing the loading gate for a good reason would otherwise
reintroduce a flat-grey fill with nothing to catch it.
Comments and one description string only — no code changed, verified by
filtering the diff to non-comment lines (empty). The gate still PASSES, and
still FAILS when the repaint is stripped, so narrowing the prose did not weaken
the instrument. gates:pre 52/52 on development.
The grey Regions tab stays OPEN and is not this. The untested dimensions are the
engine (all 36 mounts were MapLibre; the Mapbox path reads a vector
country-boundaries source that PRODUCTION-MATRIX:235 records as 403-ing on the
sandbox token), the Plugin Studio preview entry, and what "grey" means — a whole
grey canvas and pale country fills are disjoint bugs.
usePosterLayout has always computed a real layout: the map narrowed by the legend
column and inset by the margins, the title strip and the footer strip. Nothing in
the exporter ever read it. The map was rastered at full page size -- edge to edge,
across the margins and both bands -- and the title, legend and footer were then
composited on top of that image. The legend lay over the map it describes, and
there were no bands at all.
What kept it hidden is that PosterPreview.vue DOES honour layout.map, so the
on-screen preview has always shown the intended design and only the exported file
was wrong. A preview is not evidence for an export.
The obvious fix breaks a different product. country-posters embeds this renderer's
output as a full-sheet <img> inside its own packet page, beneath the packet's own
title, so chrome that is on by default prints a second header and footer within
their page. UPG-100's packet does not call this renderer at all and was never at
risk -- checked rather than assumed. So the composed sheet is requested with
spec.layout === 'poster', and every existing caller, which sets none, keeps what
it had.
That guarantee is stronger than "equivalent": with no layout the renderer passes
the SAME px OBJECT it always did to the rasteriser and composites no sheet, so the
untouched path is unchanged by identity, not merely by value. The check asserts
exactly that, by executing the decision with four real caller shapes -- including
country-posters' A4 at 150 DPI -- rather than reading the source for a promise.
Its second negative test is the ruling's own failure mode: make defaultPosterSpec
set the layout and country-posters is named as silently opted in.
A sheet with no room left for a map after its margins, bands and legend column
falls back to full-bleed and says so, because silence there is the bug above.
Simple Research's export asks for the layout explicitly.
Page 1 of every packet draws the zone map from mapSvg. When the map
fetch fails, the page interpolated an empty string: a title, a
subtitle, a privacy footnote, and nothing where the map belongs. A
failed fetch and a successful render were byte-indistinguishable in
the printed PDF, and the packet read as undeveloped.
It is not a rare edge. The served asset pool ships boundaries, fonts,
images, people-groups, pplr, previews, tables and vendor, and no
posters/ directory at all, because generate-tree gates it behind
posterSources and bundlePosters, both default false. Over live HTTP
against the served tree: previews/sudan.webp 200,
boundaries/world-adm0-web.geojson 200, posters/sudan.html 404. So on a
default build every packet's page 1 has been blank.
The browser path's catch around the poster fetch is right to keep
printing -- a missing map must not cost the reader the people pages --
but degrading and degrading silently are different things, and only
the renderer knows the map is absent when it draws the page. It now
prints one quiet sentence in the map's place.
The sentence is English-only on purpose. t() falls back to English per
key, so the other ten locales print it in English until a translator
supplies theirs, rather than ten invented translations nobody can
check.
The real fix for the blank map is shipping the poster sources in the
asset pool; that is a tree-config change and goes separately.
check-packet-map-missing-loud is two-sided: loud when the map is
missing AND quiet when it is present with the SVG intact, in en/es/ar,
both variants. Shown capable of failing four ways: the silent cover, a
notice that always prints, a notice that displaces the map, and the raw
key printed instead of a sentence.
Page 1 of every UPG-100 prayer packet is the zone map, drawn from
<tree>/assets/posters/<id>.html. The tree shipped no posters/ at all:
generate-tree gates the folder behind posterSources and bundlePosters, both
default false, so the fetch 404'd and the packet, which rightly keeps printing
without its map, printed page 1 blank.
The dx-maps config now sets posterSources: the 28 sources, without the 41 MB of
.webp rasters. Measured: 27 MB raw, 7 MB gzipped. The copies are byte-identical
to the tracked data/posters/*.html, so git stores each blob once and a push adds
no new pack bytes. The code default stays false for other deployments.
check:poster-print-fallback now RUNS by default instead of skipping. Part 1 needs
no browser and runs in every wave: when the config ships sources, the built tree
must carry every data/posters/*.html, byte-identical (a stale copy prints an old
map just as quietly). It FAILs 28 of 28 on the tree before this change and PASSes
after. Part 2 clicks the real print control and is NOT MEASURED (75) outside the
browser slot; with no url it serves this checkout and opens the UPG-100 wall. The
skips that exited 0, which a wave reads as a pass, now exit 75. Registered in
gates:post.
usePosterLayout has always computed a real layout: the map narrowed by the legend
column and inset by the margins, the title strip and the footer strip. Nothing in
the exporter ever read it. The map was rastered at full page size -- edge to edge,
across the margins and both bands -- and the title, legend and footer were then
composited on top of that image. The legend lay over the map it describes, and
there were no bands at all.
What kept it hidden is that PosterPreview.vue DOES honour layout.map, so the
on-screen preview has always shown the intended design and only the exported file
was wrong. A preview is not evidence for an export.
The obvious fix breaks a different product. country-posters embeds this renderer's
output as a full-sheet <img> inside its own packet page, beneath the packet's own
title, so chrome that is on by default prints a second header and footer within
their page. UPG-100's packet does not call this renderer at all and was never at
risk -- checked rather than assumed. So the composed sheet is requested with
spec.layout === 'poster', and every existing caller, which sets none, keeps what
it had.
That guarantee is stronger than "equivalent": with no layout the renderer passes
the SAME px OBJECT it always did to the rasteriser and composites no sheet, so the
untouched path is unchanged by identity, not merely by value. The check asserts
exactly that, by executing the decision with four real caller shapes -- including
country-posters' A4 at 150 DPI -- rather than reading the source for a promise.
Its second negative test is the ruling's own failure mode: make defaultPosterSpec
set the layout and country-posters is named as silently opted in.
A sheet with no room left for a map after its margins, bands and legend column
falls back to full-bleed and says so, because silence there is the bug above.
Simple Research's export asks for the layout explicitly.
RT8 goal (b). Two gates already touch the engine and neither asserts this:
check-leaf-engine-per-provider proves MAPLIBRE purity (zero *.mapbox.com
requests under ?provider=maplibre) and that a map still draws, but never forces
Mapbox; check-provider-switch does load ?provider=mapbox — with a FAKE token, so
the engine LOADING was measured and a tile RENDERING never was, on one leaf.
So "Mapbox works" was unmeasured across the seven production tabs. This measures
it, and refuses to pretend when it cannot.
WHAT IT READS, and why each is the stronger property:
· THE ENGINE off the canvas that drew — .mapboxgl-canvas vs .maplibregl-canvas
— never from which globals exist. A leaf asked for Mapbox that quietly draws
with MapLibre looks identical to success in a screenshot and passes every
paint check; a peer script that merely loaded Mapbox proves nothing.
· THAT IT DREW via queryRenderedFeatures() > 0. A canvas at the right size is
not a map: this repo already had a country map render fully blank while "a
real GL canvas exists at 1200x780" passed.
THREE EXIT STATES, BECAUSE TWO WOULD FORCE A LIE. On this sandbox the Mapbox
/v4/ composite raster endpoint and country-boundaries both 403 — proven outside
the browser with no Referer, with 127.0.0.1 and with localhost, so it is tileset
ENTITLEMENT, not the embed (PRODUCTION-MATRIX:235). Failing on that would
manufacture work for every owner; passing on it would be false. It exits 75 NOT
MEASURED and names the endpoint. No token, or a non-pk. token, is also 75 — a
fake token is exactly the false green this gate exists to refuse.
THE NEGATIVE ARM is in the same file: ?provider=maplibre must draw with MapLibre.
If it does not, the engine check cannot tell the engines apart and every pass
above it is unfalsifiable.
★ TARGETS COME FROM THE MANIFEST, AND SELECTING ON isMap ALONE LOST A TAB.
Measured: it found 6 of the 7 curated tabs. research-map-plus-grid (Simple
Research) is a nested-elements-mfe with isMap:false whose CHILD holds the map, so
it would have been skipped in silence — the exact class of miss this gate is
meant to catch. A composed tab now counts if it is isMap ITSELF or composes a
profile that is: 7 of 7.
Registered in package.json (script + description) and in verify-browser-all's
GATES array — deliberately NOT gates:pre, because it needs the browser seat.
Verified by re-reading the parsed package.json, and check:no-orphan-gates names
only the pre-existing check-gospel-whiteboard.mjs, not this.
HARDENING, NOT THE FIX for the grey Regions tab. That tab has been seen loading
grey on first load and coloured after a reload. This closes one ordering that
can reach a flat-grey fill. On built bundles that ordering did not reproduce the
grey tab (0 of 20 loads grey, the fill present with its expression at addLayer),
so the reported bug stays open; the lead is the dev/HMR re-mount path.
THE ORDERING. regionFillExpression builds a ['match'] from props.points; with
no points it returns a FLAT no-data grey. addLayers SNAPSHOTS that value into
the layer's paint when it creates the layer. applySubject does repaint the fill
correctly, but it guards every write on getLayer() — deliberately — so a change
arriving BEFORE the layer exists is silently dropped and never retried. And on
MapLibre, the DEFAULT engine, the country boundaries are added as an EMPTY
source and populated by a later fetch's setData(), which nothing repainted
after. In that ordering the fill keeps the colour it was born with.
THE CHANGE is one call: repaint after the boundaries setData, which is the first
moment the layer has any features on the MapLibre path and the only repaint
guaranteed to run with both inputs present. applySubject is idempotent and
getLayer-guarded, so it is harmless when the colour was already right — it only
removes the ordering's power to be the last word.
★ TWO THINGS I GOT WRONG FIRST, both caught by the gate rather than by review,
and both recorded because the wrong version was plausible:
1. MY MODEL COULD NOT REPRODUCE THE BUG. The first version's negative control
fired: no arrival order greyed even with no repaint, so it could not have
detected what it existed for. The greying order is not "data before the
layer" — by then the snapshot is already coloured. It is points arriving
AFTER both addLayers and its mount repaint, with the watch's repaint not
landing. Corrected, exactly 1 of 3 orders greys without the fix and none
with it.
2. I BLAMED THE EMPTY SOURCE, WHICH CANNOT CAUSE GREY. An empty source draws
NOTHING; fill-color is an expression over feature properties. A grey
landmass therefore means features ARE present and the expression came back
flat. The repaint after setData is still required, for the narrower reason
above. The gate carries that correction so the wrong reason cannot be
inherited from a comment.
THE GATE'S OWN INSTRUMENT WAS WRONG TWICE TOO. A 400-char window between
setData and the repaint failed on the repaint's own comment. Widening it to
1600 then PASSED when I deleted the real repaint and left an unrelated
applySubject() 1200 chars downstream — measured, not feared. A distance is the
wrong instrument, so the .then() block is now extracted by brace matching and
the repaint required INSIDE it. The first structural attempt anchored on the
nearest '=>' and landed inside the callback body, because `for (const f of ...)`
has one; it anchors on the enclosing '=> {' now. Four arms mutation-tested,
including that decoy, and every mutated file restored byte-identical.
rega's heap snapshots (leak-722ac7789): Simple Research and All People Groups
mounted twice went 54.2 MB to 65.1 MB, with live Vue apps 6 to 8. Every unmounted
application was still there, one copy per mount, and the retainers were all the
same shape -- a window key set during setup and never cleared. The closure keeps
its component, the component keeps its shadow root, and a whole app survives its
own unmount. An element property dies with the element; a window key does not.
The named one was window.__doxaLogoOwner -> Map -> div.rm-map-canvas -> ShadowRoot
-> app, released only when the map's 'remove' event fires. That listener cannot be
the guarantee the global is cleared, because it only runs if remove() is reached;
it is a consequence of the guarantee, not the guarantee itself. useMapInstance now
drops the claim inside destroy(), before removing, so it goes even if remove()
throws part-way.
As for why remove() was never reached: research-map's unmount hook ran ten
teardown steps and called destroy() last, and two of those steps were unguarded
calls into other composables. A single throw anywhere above stranded the map, and
with it the app. I could not reproduce which step threw without a browser, so the
fix does not depend on knowing: each step is isolated and destroy() runs from a
finally, where nothing above it can skip it. Ordering teardown so the most
important step is last, behind nine chances to fail, was the real defect.
The globals themselves are released by the components that set them -- four verify
hooks in research-map, three in the dashboard map panel, the vocabulary hook in
the grid, which had no unmount hook at all. Released as SETS: __rdashPinAt was the
one the snapshot named, but __rdashMap holds the map and retains strictly more, so
clearing only the reported one would have looked fixed and leaked nearly as much.
The deletes are written one per line instead of looped over an array of names, so
that the check can read them. A release a checker cannot see is worth no more than
no release, the day someone removes three of the four.
The check flags only values that actually retain. A warn-once boolean latch and a
page-wide cache keyed off itself are deliberate and must not be reported, or it
becomes noise and gets switched off. It is NOT in gates:pre: 19 sites remain in
other owners' plugins, and this is a fleet pattern rather than one plugin's bug.
useLegendData returns { items, columns, title, activeFilter, setFilter,
totalCount, totalPopulation, progress, fmtPop, fmtCount }. There is no `rows`.
research-map read legend.rows in three places, and because JavaScript answers
undefined rather than complaining, each one failed in a different costume:
- provide('legendRows', legend.rows) provided undefined, and nothing in the repo
injects that key -- the comment above it described a contract no component
implements
- openPoster's fallback, legend?.rows?.value || [], could only ever be [], so on
any tab where the flat legend is empty the poster had no legend rows
- the packet's page-1 slot was rows: legend?.rows?.value || [] with NO second
source, so that poster's legend was empty unconditionally
and useMapPoster skips the legend slot entirely when the row list is empty. So the
missing legend on the exported poster had two independent causes: the renderer
painting it over the map, fixed separately, and this -- an empty array arriving
through a fallback that could never be anything else.
Both poster paths now share one builder, so they cannot drift apart again, and it
flattens children because a prayer legend is a parent plus two child tiers and
printing only the parents describes bands the map does not draw.
Two further calls went the same way: legend.refresh?.() and
legend.build?.(normalizedPeopleGroups), against methods the composable has never
defined. The optional call swallowed both, so two lines that read like steps did
nothing. `items` is a computed and re-derives on its own, so they are gone rather
than repointed.
exp-shepherd found the same misspelling in countries-map, where it threw instead
(NE-17). The check therefore guards the class rather than these lines: it reads
the returned key set from the composable and reports any consumer reading outside
it. It is NOT in gates:pre yet, because it still reports countries-map, which
belongs to another owner.
Writing it turned up two traps worth keeping. Stripping block comments with one
global regex DELETED REAL CODE -- a slash-star inside an ordinary string opens a
comment nobody wrote and the replace runs to the next real terminator -- and the
check confidently reported "no consumers at all" while both sat in the files. And
matching property reads by name flagged every t('legend.colLabel.religion'): an
i18n key spelled like a property access is not a read.
The projection toggle and the zoom toolbar share the top-right corner. The toolbar
was pinned at a literal top:34px, a number that was correct when it was chosen:
the toggle then measured 74x23 and ended at y=31, which this panel's own comment
records as a live measurement. A later accessibility fix gave the toggle
min-height:24px for WCAG 2.5.8. Nothing in this shadow root sets box-sizing
globally -- it appears exactly once, on the legend -- so 24 was a CONTENT height
and the rendered box became 24 plus 8 padding plus 2 border = 34px. Sitting at
top:8 it now ended at y=42 and ran 8px into the toolbar, which has z-index 10
against the toggle's 3 and so drew straight over it.
Nobody did anything careless. A correct fix to one control broke another because
the agreement between them was a magic number living in the other rule, where the
person changing the first had no reason to look.
So the toggle gets box-sizing:border-box, which makes 24 mean twenty-four and
keeps it a legitimate 24px touch target, and the toolbar's offset is now
calc(--rd-proj-top + --rd-proj-h + --rd-ctl-gap). The relationship is stated once,
on the element that owns it, and both the desktop rule and its mobile restatement
read it. Changing the toggle's size now moves the toolbar by construction.
The check computes the toggle's rendered height from its own declarations the way
a browser does, honouring box-sizing, rather than asserting either number --
restating 34 here would have rebuilt the same trap one file further away. Run
against the code as it stood before this commit it reports the overlap and its
exact size. It also requires the toolbar to DERIVE its offset, because a literal
that is correct today is the precise shape of this bug.
Every plugin ships as a self-contained IIFE, so library/utils/embedScript.js is
compiled separately into each one: eleven bundles on main each carry their own
copy. Its cache was a module-scoped const, which made "load exactly once" true
per BUNDLE rather than per page — the opposite of what the file's own header
claimed. Two shells nesting the same child (the Research Gallery and the Plugin
Studio both mounting doxa-research-map) each injected it.
Nothing failed, which is why it survived. The second request came from the HTTP
cache, so no failed request and no extra network row; the bundle then executed in
full a second time — a second Vue, a second vue-i18n, a second pinia, ~375 KiB
gzip of work — and its guarded customElements.define quietly declined at the very
end. Where the two URLs are different versions of one plugin, whichever executed
first owns the tag and the other is discarded in silence.
The cache now lives on globalThis, the only scope that matches the claim, under a
versioned key so a page mixing old and new bundles falls back to a cache each
rather than misreading a shape it does not expect. Entries are keyed by the
resolved href, because the callers do not pass the same string for one file.
A <script> already in the document is adopted, which is what covers bundles built
before this and host pages that write their own tag; that resolves immediately,
and it is safe only because every caller here follows with a wait on the element
itself — the note in the file says so, since a new caller must keep that pairing.
claimTag is a diagnostic, not a guard: by the time a second bundle arrives the
define is long since won, so the thing that was missing was anyone saying so.
The check imports the module twice under different URLs — two module instances
sharing one globalThis, the same shape as two IIFEs on one page. It asserts the
second caller receives the FIRST one's promise, not merely that one script tag
exists: mutation-testing showed the tag count alone is satisfied by the adoption
path, so it would have passed with the original bug restored.