Five physical CSS declarations in the chrome every production map renders, so
under Arabic each of them lands on the wrong side for every map at once:
SemanticTreeLegend's active tab chip drew its border on the left, the share
menu's step list padded from the left, its tab spacing and its auto-spacer both
pushed from the right, and the geocoder's search icon sat at left:7px inside an
input that mirrors. All five become logical properties, which in a left-to-right
reading resolve to exactly what they resolved to before: four insertions against
four deletions in the components, one property each, and the English view cannot
move.
The reason they survived is the more useful half. check-legend-logical-insets
existed and passed, but it read only library/components/legends -- the share menu
is one directory away, and a reader in Arabic does not know which folder a control
came from. So the scan now covers map-controls as well.
Widening it was not enough. It still passed, because its CSS extractor matched a
template literal with the pattern `([^`]*\{[^`]*\}[^`]*)`, which describes a
literal containing ONE brace pair. ShareButton keeps its styles in a 600-line
useShadowStyles literal with hundreds, so the scan captured seven fragments
totalling 7,003 characters of a 629-line file and never saw .share-tab at all. It
was reporting PASS on a directory it was barely reading, which is worse than
having no check: a green gate is read as evidence. Template literals are now
taken whole and kept only when they look like CSS -- 2 blocks, 8,462 characters,
and the rule is finally in scope.
The self-test poisons BOTH directories. Poisoning only a legend would have proved
the check works on the half it always covered, and passed unchanged the day the
wider scan silently narrowed again. Its map-controls arm puts back the exact
declaration this commit removes, and its failure message says what a failure
means.
landing-preconditions verified EVERY bundle whenever vite.doxa-maps-build-config.json changed, but two
of its keys build nothing: dropin (where sync-dropin copies to) and the "//" comment keys. Changing
dropin.dest cost a full verification, about 10 minutes, for no possible staleness. The config now counts
as a build change only when another key differs. Checked on real history both ways: the dropin.dest fix
is not a build change; the posterSources flip is.
Four places in the repo (the registry, the resolver, the packet renderer and a gate's allowance) cited a
research spec that lives outside the repository, so a reader of the code could not follow the reference.
library/lists/README.md now documents the list as built: how a list is chosen, the registry and its five
lists with their measured counts, where membership is generated, the two tiers (narrowing DOXA's rows,
failing closed; IMB rows per map), the data sources and their terms, and the questions still open. The
four citations point at it.
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.
One sha instead of two, per the shepherd: both branches fixed the SAME
regions-list rule, because a gate is useless on a tree where its poison case is
still live. Landing them separately would have applied that fix twice.
★ I HAD CLAIMED THE TWO COPIES WERE IDENTICAL. They were not — I checked before
folding rather than after, and the hashes differed. The CODE line is the same in
both (`cyrillicHeading('.rl-title')`, one occurrence each); only the explanatory
comment differed. I kept the css-dangling wording because it names the arm that
now catches the bug, which is what the next reader needs. The emitted CSS is
unchanged either way — comments are not CSS — and the byte-compare baseline still
passes, which is the assertion that would have caught me if I were wrong.
WHAT THIS SHA CARRIES, both halves verified after combining rather than assumed:
THE MIGRATION (was 4a4bc96fc). The harness stub's `.value` is now the empty
string, so an injected-config read and a locale interpolation both behave as
they do in the browser. Measured components 2 -> 4, no NOT MEASURED left.
region-dashboards is byte-identical (a pure de-duplication); regions-list
changed because its old bytes were the bug, so its baseline was re-taken.
strongMedium() deliberately NOT adopted — the helper emits bare `strong{...}`
where region-dashboards scopes `.rd-root strong{...}`, and swapping would widen
the rule.
THE GATE (was 023ca2d08). check:shadow-css-parses now fails on a selector that
never receives a block, gated on `endsLiteral` — the LAST fragment — rather than
on `complete`, which is false for nearly every sheet here and therefore
suppressed the very bug the arm was written for. Four self-tests live in the
file: the exact regions-list string is caught, a last rule WITH a block is not,
a trailing comment is not, whitespace is not.
Re-verified on the combined tree: ported-globals 4/4 byte-identical, shadow-css
80/80, the poison case still produces exactly 1 dangling hit when reintroduced,
gates:pre 56/56, and the broken form is absent from the shipped minified bundle.
Railway, DigitalOcean and `docker build` use a root Dockerfile with no setting, and it is
Coolify's and Dokploy's default path. The file moves; nothing is copied.
- VOLUME /data is gone. Railway rejects the line, and a named volume is needed anyway
(VOLUME alone gives each new container a fresh anonymous one). The platforms mount /data.
- The baked PocketBase gate's comment named the old path. It now says the repo-root
Dockerfile, and the gate's SHA-256 pin moves with it (2987055b... -> 2eceef03...). The
hook's code is unchanged: the exact RUN passes `sha256sum -c`, and the old block still
passes its old pin.
- .dockerignore excludes everything: the Dockerfile copies nothing from the build context,
so no platform sends the whole repository to the builder.
- compose.yaml: the same image with its /data volume and memory limit; settings from .env.
- deployment/cloud/README.md gains "Where it runs": Coolify, Dokploy, Railway, a server with
Docker, Kubernetes, and why not DigitalOcean App Platform (no persistent volumes).
DEPLOY.md and two comments follow the move.
upg100's harness recorded my two files as NOT MEASURED because its stubs threw on
them. Both were FIDELITY gaps — the stub modelled states that cannot occur — so
they are fixed by making the stub more like the browser, never by relaxing a
comparison:
· `.value` was undefined, so a component reading an INJECTED ref threw
"Cannot read properties of undefined" (region-dashboards.vue:199,
cfg.value.regionSlug — ProfileLoader always provides that object).
· the stub threw on ANY string coercion, because a Proxy over a function has no
primitive conversion. That alone produced regions-list's "Cannot convert
object to primitive value".
`.value` is now the EMPTY STRING, which is the one shape that serves both real
cases honestly: a locale ref's .value IS a string, so `${locale.value || 'en'}`
correctly yields 'en'; and reading a property off '' returns undefined instead of
throwing, so an injected-config read falls through its own `|| ''` exactly as in
the browser. Symbol.toPrimitive makes any remaining stub render empty.
Measured components: 2 -> 4, with no NOT MEASURED left.
★ AND THE MIGRATION FOUND A REAL BUG, which is the part worth reading.
regions-list's Cyrillic heading rule ended with a trailing comma and NO
declaration block, so the concatenation emitted:
[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,regions-list
A selector list with nothing to apply — the Russian heading font was silently
never applied — AND it swallowed useShadowStyles' style-id argument as CSS,
leaving that sheet unlabelled. Braces stayed balanced, so
check:shadow-css-parses could not see it, and it passed 80/80 both before and
after. cyrillicHeading('.rl-title') replaces it and emits a real rule.
SO THE BYTE-COMPARE SPLITS, deliberately, and I did not paper over it:
region-dashboards.vue IDENTICAL — cyrillicHeading('.rd-title','.rd-h2')
emits exactly the literal it replaced, verified
character-for-character before the swap. A pure
de-duplication.
regions-list.vue CHANGED, and the change IS the fix. The old bytes were
the bug, so its baseline is re-taken rather than
preserved. Preserving it would have pinned a broken
rule forever, which is the one thing a byte-identical
gate must not be used for.
NOT MIGRATED, on purpose: strongMedium(). The helper emits bare `strong{...}`
while region-dashboards emits `.rd-root strong{...}` — a narrower selector.
Swapping it would widen the rule to every <strong> in the shadow root, a
behaviour change disguised as a de-duplication. scrollbarGutter() is likewise
left inline: both files carry it inside a larger .rd-scroll/.rl-scroll rule, not
as a standalone declaration the helper could replace.
Both files now gated (owned:true) instead of recorded-only, so a future edit that
changes their emitted CSS fails rather than being noted.
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.
It asserted the variant is forwarded with /printPacket\(zone, \{ ... variant/, so renaming the argument
to titled (the zone carrying its localized title) failed it while the variant was still forwarded. Any
first argument now matches; a planted call without the variant still fails it.
Page 1 of an Arabic packet lifts the English 3x4 poster, and the
poster inherited the page's dir="rtl": English punctuation jumped to
line starts, the header's key and value columns collided, names ran
into their numbers, and four text blocks were pushed off the sheet's
left edge (rendered and measured in print emulation). The Chinese
packet, which is ltr, was intact.
The lifted sheet's wrapper now pins dir="ltr". The packet's own title
stays rtl; the relabelled Arabic country names are single runs, so they
keep their order.
check-packet-a1-structure asserts the pin on both kinds of sheet; it
fails without it.
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 Deaf zone printed "Deaf (affinity bloc A017)" as its title - on
page 1, at the head of every profile page and in the document's own
title. That is the legend's internal label: the unified Deaf row is an
affinity bloc spanning every region, so there is no country to name it
and zone-title.js declined it, leaving the label to print.
An affinity-bloc zone has a name of its own even though it has no
country. zone-title.js now reads it from the packet strings, keyed by
bloc (zone_affinity_A017: "Deaf people groups", in all eleven
languages), so another affinity bloc a list adds is named with one
string and no code. With no strings it still declines and the label
stays - it never invents a name. Both print paths now pass the
language's strings, English per key, the same fallback the renderer
applies.
check-zone-title-tier-a covers the unified Deaf zone, which it never
saw (it enumerates clusters.json entries, where Deaf is two older
lists): the translated name in en/es/ar, never internal text, and a
decline without strings. It now tests with the real packet strings,
which carry the multi-country joiner, so a broken one-country guard
would generate a title and be 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.
Measured all 29 zones in en/es/ar with a real country-name supplier
before wiring anything, and the result split cleanly.
A one-country zone generates a correct title in every locale at zero
translation cost: Sudan / Sudán / السودان.
A multi-country zone generates a title WORSE than the hand-written
label it would replace, in English too, so this was never a
localization trade-off:
"Middle East Core" would become "Iran + Yemen + 11 more"
"Scattered / Diaspora" would become "Andorra + Cocos (Keeling)
Islands + 2 more"
and no maxParts rescues it -- 2, 3, 4, 13 just walks toward a full
13-country enumeration. A multi-country zone has a name that is not a
list of its members, so no assembly of country names can produce it.
The generator is correct; the approach does not fit that tier.
The guard is in zone-title.js, not at the caller, because six product
call sites supply this across three surfaces (the legend three times,
posters, packets). A caller-side test would be repeated six times and
would drift the first time one was missed, silently regressing one
surface while the others stayed right.
Tier B now keeps its label through the same null-means-keep-yours path
tier C already used, so this widens an existing contract rather than
adding a new one.
check-zone-title-tier-a.mjs is two-sided on purpose: a gate that only
checked "the 3 generate" would pass a build that also regressed the
other 26. It asserts the 3 produce non-code titles that differ across
locales, AND that every other entry returns null so its label survives
byte-identical -- by value, not by count, since "26 declined" would
pass even if the wrong 26 declined.
Shown capable of failing four ways: removing the guard (the forbidden
regression), declining tier A too, a supplier returning raw codes, and
a supplier ignoring locale. The third exits 75 NOT MEASURED rather than
failing, because a broken supplier makes every zone decline and would
otherwise look like a clean pass.
The app's Chinese is Simplified, and so were 39 keys of the packet's zh
block. The other 18 were Traditional - page 1's subtitle, the list
header, the card labels, and the whole prayer wheel and all twelve
prayer points - so a Chinese packet switched script partway through.
The block had been extended by matching the neighbouring line, which
was itself the drift; the missing-map sentence added in 74e5c4b0d
followed that same neighbour and is corrected here too.
Converted with a real converter (zhconv, MediaWiki's tables), not a
hand-picked character list: it converts characters (為 to 为, 禱 to 祷)
and mainland vocabulary (列印 to 打印), and a hand list had found only
17 of the 18 keys. A second pass changes nothing, so the block is
Simplified by the converter's own definition. 祂 is kept. The block
carries no licensed scripture - Mark 5 falls back to English there - so
only prayer prose and labels changed.
check-i18n-parity's zh arm walked the app catalogues and never the
packet's, which is how 18 keys drifted unseen. It now walks the packet
block as well; pointed at the block before this commit it fails with 41
values, and passes after. Its character set remains a floor, as it says.
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.
Page 1 of a prayer packet is a lightweight vertical one-pager showing
the zone's geography and every pin with its numbered name - not the
poster. Until now the packet lifted the 3x4 poster (1500x2000, its own
title, facts table, vision columns, networks and closing verse), shrank
it to 62% and pasted it under the packet's title: a small poster on a
page, with the English poster prose baked into the artwork.
FORMAT=packet is a new sheet format: 1500x1941, the packet's own page,
with the top band left EMPTY for the title the packet draws in the
reader's language, a slim foot, and everything between given to the map
and its label columns - about 1,600px of height against the letter
sheet's ~1,200, so the names get the larger type the sparse posters use.
It omits all the poster furniture, and its root <svg> says
data-sheet="map-only" so the packet can tell it from a poster.
Paper formats no longer overwrite the poster. Every format used to write
out/posters/<zone>.html, so FORMAT=letter silently replaced the 3x4 the
wall and the site serve. packet and letter sheets are now
<zone>.packet.html and <zone>.letter.html, with their own manifest;
3x4 and 3x3 keep the bare name, which every consumer fetches.
publish-html.mjs read everything before ".html" as the zone id and so
skipped every paper sheet as an unknown zone; it now strips the suffix.
The two fail-loud guards (header overflow, verse off the sheet) stay
armed for posters and stand down only for a sheet that does not draw
the furniture they measure.
Proof that posters are untouched: the default 3x4 output for sudan,
asia-india-kerala, deaf and west-africa is byte-identical before and
after, generated with POSTER_DATE pinned. The first attempt was NOT
identical - an HTML comment added inside the SVG template literal is
emitted into every poster - so the notes live in JS comments instead.
check-zone-census counted paper sheets as posters (it would read 56
for 28 zones); it now counts posters and reports paper sheets apart.
The legend offers 28 zones; every place that turned a picked id back
into a zone spread clusters.json's three geographic buckets, which is
27. The missing one is Deaf, whose two deaf lists the legend collapses
into a single affinity-bloc row. So in the browser, silently:
- the Prayer packet for Deaf returned on "if (!zone) return";
- the Deaf poster titled itself by its slug;
- a selection containing Deaf dropped it in .filter(Boolean);
- the packet count read 27 while 28 packets exist, and that count
prints on page 1 and at the tip of page 2's triangle.
The packet fetch also lacked rop1, the field the Deaf selector reads,
so even a found Deaf zone would have resolved to nobody.
The fix is one enumeration. legend-tree.js now exports DEAF_SELECTOR
(the unified row's selector, which exists nowhere in clusters.json) and
zonesWithSelectors(clusters): the legend's leaves with their selectors
attached. The plugin's printableZones() keeps the 27 catalogue objects
exactly as they were and adds only what the catalogue lacks, so the
packet, poster, selection and count all see the same 28. rop1 joins
both packet fetches.
The three hand-typed copies of the Deaf literal (the map, the poster
generator, the offline packet generator) now import the one
definition; the offline generator's own id-to-selector table is
replaced by zonesWithSelectors. The copies left are frozen snapshots and
proof scripts, which keep independent literals on purpose.
The offline sweep gate's coverage had the same 27: it never required a
Deaf packet. It now counts through the legend.
Verified: the live API carries exactly 123 A017 rows, matching the
legend's Deaf count; the default 3x4 posters for sudan, kerala, deaf
and west-africa are byte-identical before and after.
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.
Rings that zoom apart, as a flag, so either answer to row 130 is one line at
the call site. DEFAULT IS 'fit' — this changes nothing today.
'fit' keeps every pin at least minSep apart at the chart's own scale and reports
what will not fit. That is the right behaviour for a PRINTED sheet, where there
is no zoom and a dot a reader cannot resolve is a dot that is not there.
'fill-all' places all of them. Separation is in map units, so it grows with zoom:
measured on the shipped table, the worst ring is ring 1 (1,190 groups in the
smallest circle) separating to 8px on screen at z=4.45, and ring 8 (1,932) at
z=3.50. The GROUP label tier already starts at 5.2, so every pin is clickable
before the names appear. At the start zoom the band reads as a dense arc of
colour rather than countable dots, which is the intended appearance — and the
reason a caller in this mode must caption the true total rather than a count
that changes with zoom.
The row count had to follow the POPULATION here, not the pin size. Keeping the
'fit' row count would have packed 1,932 pins into the two rows a band allows at
full size, and they would have sat almost on top of each other along the arc —
the single-line defect again, one level down. Ring 8 uses 14 rows in fill-all
against 2 in fit.
Real table: fit places 1,851 of 7,296, fill-all places 7,296 of 7,296, both with
zero pins outside their band. Six new assertions, and the row-growth arm is
poison-tested: making fill-all reuse the fit row count fails it at "2 rows vs 2".
Found by running the SHIPPED table through the layout instead of the uniform
sample the module was built against. The real near-neighbours data is heavily
skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it
bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was
faithfully reported, which is exactly why no assertion caught it: all 16 asked
"is the overflow reported?" and none asked "is the CAPACITY right?".
The capacity model counted one circle of pins at the band's innermost radius. A
band has depth, so it holds several concentric rows, each with its own
circumference. Counting them is a ~2x correction at ringGap 24 and more at
larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's
geometry and every under-full ring now places everything it is given.
Placement had to follow: a hashed radius with one angular step was fine while a
ring held a dozen pins and wrong once it held hundreds, because two pins could
take nearly the same angle at nearly the same radius while the capacity sum
insisted there was room. Pins are now dealt round-robin into rows, each row
evenly spaced across the usable arc, staggered a quarter slot so adjacent rows
do not line up radially and read as spokes.
Two bugs found on the way, both by the gate failing honestly:
- the stagger first added half a step to every odd row, which pushed that row's
last pin past the end of the arc and into the label's keep-out — 7 overlapping.
Staggering WITHIN the slot keeps every pin inside its own slot.
- the shrink arm tested at 200 groups, which only crowded a ring while capacity
was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row
table, and still reaches the pin floor and reports.
The new capacity arm asserts a RATIO against the single-row count, not a magic
number: its first version hardcoded ">400" from the component's ringGap of 22
while the gate builds radii at 24 — the code was right and my threshold was
guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
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.
country-map (the previous commit) moves RegionCountryMap.vue from regions-dashboard/src to
library/components/maps so a page can mount the country map alone. check:region-fill-repaint read the
old path and crashed on the move; it reads the library file now and still PASSes, with the repaint
after setData intact (the move carried the hardening that landed in 608e8ec29). check:universal-props
held the file's fixed en-US population format by its old per-bundle path; the same held decision now
sits on the library file for every bundle that imports it (regions-dashboard and country-map), which
the allowlist permits only for library/. One comment still pointed at src/useRegionsI18n.js.
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.
The page-1 notice for a missing map existed only in English, which
turns check:packet-strings-parity red: the packet catalogue must be
whole in every language, and English-by-choice is not one of its
declared exceptions (only the licensed Mark 5 passage is).
All eleven blocks now carry the sentence, reusing each block's own
page-1 wording for "prayer list" so the notice reads like the line
above it. The English says "prayer list" rather than "zone": zone is an
internal term, and the page itself calls it a prayer list. The Chinese
block is Traditional script, so the sentence is too, with the Taiwan
term for printing.
The loud gate's note described the old English-only state; it now
reports which locales fall back, or that none do.
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.
The joining page, DEPLOY and the laptop sync default moved to development in the
commit before this one. This finishes the move and closes what a fresh clone's
friend test found.
- landing-preconditions diffs against origin/development when no landing branch
is exported, instead of main.
- CLOUD-DEVELOPMENT-ENVIRONMENT, the plugin-designer skill (SKILL.md, workflow,
glossary) and the shared dev server's notes land on and follow development;
the skill says never to push to main.
- The sync fetches with the laptop's own credentials. git runs with prompts off,
so on a clone with no credential helper it failed every interval with a bare
"fetch-failed / remote: Unauthorized", which read as a broken server. That case
is now its own state, needs-credentials: the log (once, not every tick) and
/api/dev-sync say "dev-sync needs git credentials for <remote>". The joining
page and .env.example say so, and that a fresh clone starts on main and must
switch to development before the sync can fast-forward it.
Measured in scratch repos: the private remote with no helper gives
needs-credentials and one warning over two ticks; a local remote with no
DX_SYNC_BRANCH follows development and fast-forwards; a missing remote stays
fetch-failed.
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.
The joining page now teaches laptop (the workshop), development (the table everyone lands on, followed by the
shared dev server) and main (production: /cdn, the websites and every partner snippet load it; never pushed to
directly). Its commands pull and push development. Schema is made on a laptop, never in the shared server's
PocketBase screen, whose automatic migration files are lost at the next restart.
DEPLOY: the cloud server follows development and publishes main at /cdn (PRODUCTION_BRANCH=main); publishing
merges development into main; EMBED_BASE_URL makes snippets absolute; /cdn revalidates within 5 minutes. The
drop-in example pointed at a deleted branch and now names research-gallery-drop-in, with a link to running all
three DX-life ways on a laptop.
Laptop sync (DX_SYNC_REMOTE) follows development by default, so teammates' landings arrive before they are
published.
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.
251606460 dropped each component's window hooks on unmount, but deleted them
unconditionally, and that is wrong as soon as two of the same component share a
page -- which research-map-plus-grid and the Big Picture composite both arrange.
B mounts and overwrites A's hooks; A unmounts and deletes them; B is still on
screen with its verify hooks gone. Not a leak, and not visible to a reader: the
cost lands on rega, as a hook reported missing on a map that is perfectly
healthy, which is a more expensive thing to chase than this is to write.
people-groups-grid already compared identity before releasing. research-map (4
hooks) and the dashboard map panel (3) did not, so the commit was uneven with
itself; gospel's d3597f3d1 settled on the same identity-checked pattern, and one
pattern across the fleet is what lets the check enforce it later.
Each hook is now held in a local as it is installed -- window.__x = _hookX = (…)
-- declared outside the block that assigns it, because a const in there is
invisible to onBeforeUnmount. The release compares before deleting, one key per
line, so the check can read both halves.
The check reports how many releases are identity-checked and enforces it under
STRICT_IDENTITY=1, which is off only because other owners have not adopted the
pattern yet. Flipping it later is a smaller change than re-deriving this.
Two things it taught me while I wrote it. Enforcement first flagged the grid,
which WAS identity-checked -- the comparison simply sat on the line above the
delete, and looking only at the delete's own line dictated formatting instead of
recognising correct code; the window now covers the lines leading up to it. And
the pattern is tested for BEHAVIOUR, not presence: two hooks installed in turn,
the first unmount must leave the second working, the second must leave nothing.
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.
check-gospel-set-charts read library/components/charts/BoundedSet.vue and
CenteredSetTarget.vue to prove neither grew a `seed` prop. Nothing imports
either: the sets are drawn by gospel-internet's GiBoundedSetMap.vue and
GiCenteredSetMap.vue. So the arm guarded dead code — a seed could have appeared
in the shipping components and this gate would have stayed green.
Retargeted at the two whiteboard maps. Their parents take no props at all (they
read useBoardState), so a seed could only ever arrive in these two files.
Why it matters: position is hashed from each group's own id, so two printings of
one packet are identical. A seed silently rescrambles every dot while the data is
unchanged — unrecoverable on a printed artefact.
Poison-tested rather than assumed: adding `seed` to GiBoundedSetMap fails the
gate by name ("seed,groups") and exit 1; removing it returns PASS. The vacuity
control reads `groups` from the same live file.
Found by regions while removing the dead library components — an import census
cannot see a filename that appears as a STRING in a gate, which is why deleting
them would have gone red on main with no import to explain it.
The composite's first "Show on map" did nothing and only a second click
selected the zone: the list pane dispatched an event at a map pane whose
listener attaches in onMounted, and the pane mounts lazily. That is
fixed on main already -- this is the regression gate it never had.
The fix is three mechanisms, so the gate asserts them separately rather
than asking one "does it work" question that would go green on two of
three. Delivery: the request is written as an attribute before the event
is dispatched, because an element dispatch has no replay and an
attribute is state that survives element upgrade. Arrival: the pane
reads that attribute on mount and observes it afterwards, covering
requests that predate the pane and requests that follow it. Effect: an
arrived request that cannot apply -- because the legend node is not
there yet, or because onLegendSelect refused while the tapestry layer
was down -- is retained and drained at the readiness signal, with the
drain re-checking which request it is draining so a newer selection
cannot be overwritten by an older one.
Mutation-tested per layer, and one mutant escaped first time: there are
TWO retain sites, node-not-found and applied-but-no-selection, and a
single pattern match stayed green while one of them was deleted. They
fail at different layers -- arrival before data versus arrival before
readiness -- so the gate now counts them.
Source-level by design: the race is a lifecycle ordering between two
custom elements, which a DOM-less runner cannot stage. The rendered
check is the browser seat's; what this pins is that none of the three
mechanisms quietly disappears.