Commit graph

811 commits

Author SHA1 Message Date
Doxa Maps
fc3b64bbe6 Migrate the regions pages onto portedGlobals, and fix a Cyrillic rule that was inert
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.
2026-09-29 02:35:35 +00:00
Doxa Maps
24f965b6a4 A gated bundle builds outside public/, and a gate keeps it out of the tree
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.
2026-09-29 02:35:35 +00:00
Doxa Maps
dcd1c13f55 The app answers each map's own ? and drops the one at the top centre
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.
2026-09-29 02:35:35 +00:00
Doxa Maps
22658256ba check:selection-kinds finds the packet call by its shape, not by the zone variable's name
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.
2026-09-29 02:16:34 +00:00
Doxa Maps
c8dcb02e97 Keep the map artwork left-to-right inside an Arabic packet
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.
2026-09-29 02:16:34 +00:00
Doxa Maps
88991641cf The offline packet generator runs again, and a gate now runs 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".
2026-09-29 02:16:34 +00:00
Doxa Maps
87c59e3a1f Name the map's countries on page 1 in the packet's language
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.
2026-09-29 02:16:33 +00:00
Doxa Maps
6af71bce45 Name the Deaf packet in its reader's language, not by its internal label
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.
2026-09-29 02:16:32 +00:00
Doxa Maps
bc422bb678 Each packet's % evangelical page states its own list's share
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.
2026-09-29 02:16:31 +00:00
Doxa Maps
b2ad4607ee Title each packet in its reader's language where the title can be generated
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.
2026-09-29 02:16:30 +00:00
Doxa Maps
a4330484a4 Generated zone titles ship for one-country zones only
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.
2026-09-29 02:16:29 +00:00
Doxa Maps
bad51cef2b Print the Chinese prayer packet in one script
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.
2026-09-29 02:16:29 +00:00
Doxa Maps
2367c2ef64 Build the prayer packet page by page to its specified structure
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.
2026-09-29 02:16:28 +00:00
Doxa Maps
c121c63834 The map template draws a map-only vertical page for the prayer packet
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.
2026-09-29 02:16:27 +00:00
Doxa Maps
502d7107e3 Every zone a reader can pick resolves to its groups, Deaf included
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.
2026-09-29 02:16:27 +00:00
Doxa Maps
a40fa017b4 The set charts fit their box, carry a cross, and draw the target — combined
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.
2026-09-29 02:11:28 +00:00
Doxa Maps
de2ec6fe46 bandFill mode: 'fill-all' draws every group and lets the zoom separate them
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".
2026-09-29 01:38:08 +00:00
Doxa Maps
b3238db361 A band holds concentric rows, so it holds four times what I was counting
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.
2026-09-29 01:38:08 +00:00
Doxa Maps
819051c08e A ring of bands, as pure functions: rings in, positions out
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".
2026-09-29 01:38:08 +00:00
Doxa Maps
443b163682 Share the ported _globals.scss rules through one module
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.
2026-09-29 01:38:08 +00:00
Doxa Maps
e2ac092277 RegionCountryMap moved to the library, and its gate, its numerals allowance and a comment follow it
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.
2026-09-29 01:38:08 +00:00
Doxa Maps
c5c01deada Delete 7 unreachable library components, and gate the thing a census cannot see
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.
2026-09-29 01:38:07 +00:00
Doxa Maps
36f2818b37 Every animating library component now honours prefers-reduced-motion itself
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.
2026-09-29 01:38:07 +00:00
Doxa Maps
1d3a929d51 Gate the country-name join, and record that the wiring was already done
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.
2026-09-29 01:38:07 +00:00
Doxa Maps
d9d1b33d30 Say what check:region-fill-repaint actually guards now: a refactor, not a live path
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.
2026-09-29 01:38:06 +00:00
Doxa Maps
9ab4710423 The poster's legend was painted over the map, unless you ask otherwise
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.
2026-09-29 00:59:14 +00:00
Doxa Maps
646c23cc0e Put the missing-map sentence in every packet language
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.
2026-09-29 00:59:14 +00:00
Doxa Maps
ef6000c5d7 Say so on page 1 when a prayer packet's map is missing
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.
2026-09-29 00:59:14 +00:00
Doxa Maps
1ca78d6d5d A laptop that follows the table says when it needs credentials, and the last main defaults say development
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.
2026-09-29 00:59:14 +00:00
Doxa Maps
e57f64b64b The prayer packet prints its map: the dx-maps tree ships the 28 poster sources
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.
2026-09-29 00:59:13 +00:00
Doxa Maps
490dfd1f8c The table is development and main is production: the joining page, DEPLOY and the laptop sync follow it
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.
2026-09-29 00:59:13 +00:00
Doxa Maps
7d1ed8a4fc Assert that MAPBOX drew, which nothing measured
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.
2026-09-29 00:26:56 +00:00
Doxa Maps
608e8ec294 Repaint the region fill once its boundaries load, and gate every arrival order
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.
2026-09-29 00:26:55 +00:00
Doxa Maps
64716c0081 The legend data-contract comment describes the failure, not who would hit it 2026-09-29 00:06:53 +00:00
Doxa Maps
3ea48ebb42 Release only the hooks this instance installed
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.
2026-09-29 00:06:53 +00:00
Doxa Maps
1eae8ed80d Every unmounted map stayed in memory, held by one line on window
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.
2026-09-29 00:06:52 +00:00
Doxa Maps
63276767cf The poster credited Mapbox for tiles OpenFreeMap had served
The footer's attribution is a licence statement, so it has to be true rather than
merely present. posterDefaults ships "(c) Mapbox (c) OpenStreetMap" and any caller
that did not override it printed exactly that -- including country-posters, whose
maps run on the fleet's MapLibre default, where Mapbox serves nothing at all and
OpenFreeMap, OpenMapTiles and OpenStreetMap are the three parties whose licences
actually apply. research-map had a hand-written pair for the two providers, which
was right for research-map and helped nobody else.

The obvious approach is to read the style's sources. Checked against the live
style rather than assumed, and it does not work: tiles.openfreemap.org/styles/
liberty declares two sources and NEITHER carries an attribution field. The credit
lives in the TileJSON the vector source points at, which the engine fetches and
merges into the SOURCE OBJECT once it loads. The style is the request and the
source is what came back, and only the second one knows. map-attribution.js reads
the live source first and falls back to any style-declared value.

The TileJSON value is HTML with anchors and a &copy; entity, and a poster footer
is text, so it is flattened rather than printed raw. Repeated credits collapse.
When nothing can be read -- no map, a style that throws before it has loaded -- it
returns empty and the caller keeps its own string, because a blank licence line is
the one outcome that is never acceptable.

The check uses the real TileJSON string as its fixture. A tidy invented style
would have passed while the real map printed the wrong credit, and its first
negative test is exactly that: reading the style alone returns nothing for
OpenFreeMap.

Cut directly on main rather than on the branch this began life in, so it does not
wait behind a poster-layout change it has nothing to do with. A wrong tile credit
is a licence problem; the layout question it was sitting behind is not.
2026-09-29 00:06:52 +00:00
Doxa Maps
fa14fedfdd The poster asked for legend rows from a key the composable never had
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.
2026-09-29 00:06:52 +00:00
Doxa Maps
0147b6c2c6 The touch-target fix is what pushed the toggle into the zoom buttons
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.
2026-09-29 00:06:52 +00:00
Doxa Maps
813a52ebbe One page loaded the same bundle twice and nothing said so
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.
2026-09-29 00:06:51 +00:00
Doxa Maps
7b42754485 The seed guarantee was asserted against two files nothing renders
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.
2026-09-28 23:43:44 +00:00
Doxa Maps
b993bb094b Pin the three layers that make Show on map work on the first click
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.
2026-09-28 23:23:57 +00:00
Doxa Maps
1fb6d6890a packets-all asserts the triangle per zone
The funnel page vanished from all 28 packets during the group-set work
and nothing failed: funnelPage omits when its data is null, and the
builder supplying that data returns null on any internal error. The two
silences compose into a page that simply is not there.

It was caught by a probe looking for the tip's "1 of 28" text, which is
luck rather than method. Asserting presence per zone makes the two cases
distinguishable: "every packet lost one page" shows as 28 failures in
one column, while "this packet has no data for it" would show as one.

28 of 28 now report map, funnel, groups and sets with no failures.
2026-09-28 23:23:57 +00:00
Doxa Maps
c54bde307f CLDR plural selection, and the "N countries / N regions" keys it was blocking
vue-i18n v9 pluralizes by an English-shaped INDEX and applies it to every
locale. Measured on a real instance, authoring 'a | b | c':

  ru  n=0 -> the ONE form,  n=2 -> the MANY form     CLDR: 0=many, 2=few
  ar  n=3, 11, 100 -> ALL the dual form              CLDR: few, many, other

That is confidently wrong grammar that looks translated, which is worse than
the single fixed form the regions summary shipped until now. upg100 proposed a
plural key and was correctly refused on this exact ground
(upg-100-list.vue:3006) — "real plural support needs a pluralRules map at
createI18n". The refusal's premise was the missing map, so this adds it.

THE PRECONDITION SWEEP (dx-shepherd), before installing anything: every
catalogue value containing " | " across all 11 locales, and every call site
passing a plural choice.

  pipe keys:    wall.prayedCount, wall.havePrayed — and ONLY in en. Every other
                locale ships a pipe-free label form ("Помолились: {n}").
  call sites:   exactly two, both in wall-of-people-groups
                (CommentsDialog.vue:36, wallCanvas.js:198).

  BEFORE/AFTER, 2 keys x 11 locales x n = 0,1,2,3,11,100:
                132 renders compared, 0 CHANGED.

Zero changed because the map is SCOPED TO EXCLUDE en, which is the one locale
holding pipe strings today. en is also the case dx-shepherd's assertion (3) is
about: vue-i18n's 3-form default is zero|one|many while CLDR for en is
one|other, so an en string written "none | one | {n}" would change meaning at
n=0. Leaving en out means it cannot.

★ THE BUG I SHIPPED INTO MY OWN FIRST DRAFT, caught by reading the rendered
Russian rather than by any assertion: the index was taken against the fixed
zero|one|two|few|many|other list, which is only correct for a locale that HAS
all six. ru has {one, few, many, other} — no zero, no two — so 'few' sat at
global index 3 while the authored string holds it at 1, and n=2/n=3 rendered
"2 стран" instead of "2 страны". ARABIC HID IT COMPLETELY, being the one locale
that does have all six, so its two indexes coincide. The index is now computed
against each locale's OWN category subset, and ru/ro are authored in that order.

  ar  0 لا دول · 1 دولة واحدة · 2 دولتان · 3 دول · 11 دولة · 100 دولة
  ru  0 стран · 1 страна · 2 страны · 3 страны · 11 стран · 100 стран
  (regionCount likewise; all six categories distinct in ar, all three in ru)

check:plural-forms carries studio's three assertions, each mutation-tested to
fail: FORM COUNT (a 2-form ru string fails against its 4 categories), the
tStatic PATH (reading a pipe key through tStatic fails — tStatic does no
selection at all, so the page would show the literal "a | b | c"), and ZERO (a
5-form ar string fails because ar has an explicit zero category). Every mutated
file restored byte-identical afterwards.

The regions shim gains tc(key, n). t() resolves through tm()+rt(), and rt()
takes a message only — there is nowhere to pass the choice — so a pipe string
would come back whole. tc goes through vue-i18n's own t(key, named, choice).

Registered in all three places and VERIFIED by re-reading the parsed
package.json, not by trusting the replace: my last gate registration silently
no-opped on two of three anchors and studio caught it at the landing.
2026-09-28 23:23:56 +00:00
Doxa Maps
ee3a121ae9 That header described a bug that had already been fixed
check-embedded-rtl opens with "TWO DEFECTS", in the present tense, describing
library/i18n/index.js reflecting RTL at construction time and writing
document.documentElement.dir unconditionally. Both were fixed. The header was
not.

It cost a real wrong answer today: I read it as a description of current code
and reported a live library defect on that basis, when main already guards the
documentElement write behind window.MAP_OWNS_PAGE === true, and the plugin's own
direction has moved to ProfileLoader, which computes dirAttr from effectiveLocale
and binds it on .profile-loader. effectiveLocale falls back through lang.value,
which is the profile-config channel -- the very path the header says never
arrives.

So the section is now marked as history, dated, and written in the past tense,
with a pointer to the two places to look before believing either defect is live
again. What the check asserts is unchanged and stated plainly at the end: the
plugin's own root carries direction, the partner's page must not, which is why
it reads .profile-loader inside the shadow root and records partnerHtmlDir
separately.

The trap is worth naming in the file rather than only in a commit message,
because the next reader is likely to be someone interpreting a FAILURE of this
gate, which is the worst moment to be handed a stale account of the code. A
gate's header is evidence of what was true when it was written.

Comment-only: 33 insertions, 9 deletions, no executable line touched.
2026-09-28 23:13:47 +00:00
Doxa Maps
b7cd91d3e3 /cdn: a publish reaches browsers in minutes. Moving URLs revalidate after 5 min with an ETag and 304s; hashed names are immutable 2026-09-28 23:13:47 +00:00
Doxa Maps
56e65f3280 check:packets-all grows a headless half that fails by zone name
The gate delegated everything to the browser check, which is exit 75 --
NOT MEASURED -- on every seat but one. So on most machines it proved
nothing about coverage, and earlier today exactly one zone of 28 was
printable with no gate saying so.

The headless half needs no DOM: coverage is a file listing against
clusters.json, the map is markup in the first sheet, and a group count
is a substring count. Per zone it asserts a packet exists, page 1
actually carries the lifted map (an <img or <svg -- the exact thing that
was missing when all 28 printed a blank page 1), and the group count is
above zero, then prints the full 28-row table so a green run still shows
what it measured. The browser half keeps the rendered layer.

Mutation-tested both ways, and the first map mutant was WRONG rather
than the check: stripping <img> tags left laos green because the lifted
poster is one inline <svg> -- the check knew the file better than the
mutant did. A precise mutant (replacing the svg inside the first sheet)
fails naming the zone, and a deleted packet fails coverage by name.
2026-09-28 23:13:47 +00:00
Doxa Maps
34010ce6d8 The grid's "controls off" preset did nothing, and nothing said so
rega RQ20: the driven page asks for show-search:false and show-filters:false and
renders the search box and the filters bar anyway, with 0 console errors. There
were no errors because nothing failed. The key never arrived:

  · the preset writes KEBAB into profile-config — "show-search": "false"
  · ProfileLoader PROVIDES that object; it does not bind its keys as props
  · the profile reads props.showSearch (CAMEL), which keeps its default of true

Three correct-looking layers and no join between them. A preset key that does
nothing looks exactly like a preset key that works, which is why the fix ships
with a gate rather than a comment.

The grid now reads these flags off the config as well as the prop, in the same
prop -> cfg ladder researchUrl beside them already used, and accepts BOTH
spellings: someone writing profile-config by hand reaches for the kebab spelling
because that is what the docs show on the tag, while the prop is camel.
Accepting one silently is what made this inert. An explicit prop still wins.
Proved on five cases including the exact preset, an explicit prop overriding the
config, and config:true keeping the control on.

check:instance-config-keys asserts every preset key reaches the profile that
renders it. Writing it was the interesting part, and it was WRONG TWICE:

  1. First version matched only `cfg.` and reported NINE FALSE POSITIVES across
     four plugins — big-picture reads config.value.accent, doxa-simple-map reads
     profileConfig?.value?.tabs. The config object's variable name is each
     profile's own business; matching on it measured my naming habit, not the
     contract.
  2. Widening it to "any accessor" then matched `props.showSearch` — the exact
     line that does NOT work — and the gate went GREEN on the defect it exists
     for. props is the one object that cannot carry a profile-config key, so it
     is now masked out before the match.

Mutation-tested after that: reverting the fix to props-only makes it fail again,
and the grid file is restored byte-identical. A gate I have not watched fail is
not evidence, and this one passed for the wrong reason once already.

The four bundles that embed the grid are rebuilt, so the fix is in the shipped
.js and not only in the source rega cannot run.
2026-09-28 22:25:30 +00:00
Doxa Maps
0856980d58 The freshness record's filename hashes its scope
Scoping freshness to a library change's consumers makes the scope a long bundle list, and the
record file named after it exceeded the filesystem's name limit (ENAMETOOLONG) after a PASS.
2026-09-28 22:25:14 +00:00
Doxa Maps
89940b105f EMBED_BASE_URL overrides the committed embedBaseUrl for a host's own tree build
A host that builds its own tree (maps-dev's /cdn build inherits the container env) can bake
absolute embed snippet URLs without a committed config change, the same pattern as
DOXA_SYNC_PATHS. The value is the base without the tree name; a trailing slash is trimmed.
check-embed-targets-exist reads the same effective base. Unset, the tree is byte-identical.
2026-09-28 21:53:40 +00:00