Commit graph

742 commits

Author SHA1 Message Date
Doxa Maps
84e1e0cf42 The poster sliced its rows in degrees, and Mercator charges more for a degree up north
DRV1650-SR-POSTER-EXPORT, the "too much Antarctica" half. The big-poster renderer
stitches a grid of tiles, each drawn into an EQUAL-HEIGHT slot of the output image, and
it chose each row's latitude band by dividing the degree span evenly:

    latStep = (ne.lat - sw.lat) / rows

Screen height is proportional to ln(tan(pi/4 + lat/2)), not to latitude, so equal DEGREE
bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is
37.0% of the true rendered height and was being painted into 25% of the poster; the next
19.7% into 25%; the bottom 25.2% into 25%. Every row is squeezed or stretched, which is
the wrong aspect, and the bands where a degree buys the most pixels are the most
over-represented -- the Antarctica the Driver saw.

Sliced in Mercator y instead, every row is 25.0%, exactly, because that IS the screen
coordinate.

★ THIRD PLACE TODAY FOR THE SAME MISTAKE. A whiteboard chart nearly shipped with a linear
scale into degrees; a measurement harness nearly reported a false threshold by correcting
the wrong axis; and this has been shipping. Treating latitude as a screen coordinate is
the error, the conversion is one line, and it now lives in library/maps/mercator-slice.js
rather than being rediscovered a fourth time.

check-mercator-slice asserts the PROPERTY across five view/row combinations -- every band
an equal share of rendered height, contiguous, covering the span, north-first -- and its
CONTROL is the old code: degree-linear slicing must FAIL the same assertion, which it does
by 12.0 points. Without that control the test would pass on a tree where nothing changed.
A -90..90 view clamps at the Mercator limit instead of going infinite.

NOT FIXED HERE, because it is a product decision and not a defect: the export also asks
for an 8.5x11 PORTRAIT sheet (research-map.vue:1410), so a landscape map is letterboxed
onto a tall page. That is a deliberate default, it is the other half of the wrong vertical aspect, and changing the poster's default shape is a product decision, not a fix.
2026-09-28 17:53:38 +00:00
Doxa Maps
4b45285b7d Hold the packet's ring names to the screen's, and adopt its exact words
The agreed ring vocabulary now lives in two catalogues in eleven locales
each -- gospelInternet.json for the screen legend, packet-strings.json for
the printed sheet -- and nothing held them together. They legitimately
want different SHAPES (the screen renders the axes as standalone labels,
the packet composes them into one string per ring), so neither can import
the other; but the WORDS must match.

The failure that makes this worth a gate is quiet: one cell is provisional
on the Driver's row 89, and it appears in rings 1, 4 and 7. Answer it in
one file and not the other and the sheet and the screen state different
things about the same ring, each correct in isolation, with no error
anywhere. Only a reader holding both would notice.

Measuring first showed 36 of 77 cells already differed, in two classes.
Most were grammar -- the packet's culture terms follow a comma
mid-sentence, so they are lowercase -- but some were genuinely different
words: "Same family" against "Same language family", German "verwandte"
against "Nahe", Hindi "वही जन" against "वही जनसमूह". Those are drift, and
the screen's wording wins because it is what readers already see. The
packet now adopts every term verbatim, lowercasing only the first
character of the culture half, which is a no-op in zh/ar/hi and leaves
German common nouns capitalised.

That adoption caught a bug in my own assertion: I had hardcoded ring 7-9
as "Same family" from CenteredSetTarget.vue's header, while the shipping
string is "Same language family". A summary in a comment is a description
of the data, never a second source of it.

The gate also refuses to pass when it compared nothing -- rename a key on
either side and every lookup returns undefined, every pair is skipped, and
a silent check reports success. Mutation-tested both ways: answering row
89 on the screen only, and renaming a packet key.
2026-09-28 17:53:37 +00:00
Doxa Maps
fccf6ee2fe Name the rings on the packet's centered set
The figure printed rings without saying what they mean -- honest but thin.
It now names each one with the vocabulary gospel and exp settled, the same
words the screen legend renders, so a printed sheet cannot disagree with
the screen.

COMPOSED FROM band x culture, NOT A FLAT 1..10 TABLE, and that is the
whole design decision. "Same people" in column 1 is provisional on the
Driver's row 89 -- and it appears in rings 1, 4 AND 7. A ten-string table
would need three edits kept in step, which is exactly how two of them end
up saying different things. Composed, it is one string
(ring_culture_same), and if he picks "culture" the ring numbering, the
columns and the colours are all untouched.

Ring 10 stands alone ("Nearby only") because it is not a band-and-culture
reading at all -- it is what is left when no linguistic relation was
found. The gate asserts that separately, and a mutant that composes it
like the other nine fails.

ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure
is a glossary, not a legend, and a named ring with no dot on it invites
the reader to hunt for something that is not there. ringTally's per-ring
counts drive the list, so it states what this zone actually contains.

Mutation-tested: a wrong band order, listing all ten rings regardless of
count, and composing ring 10 like the others each fail with their own
message. Eight new strings x eleven locales; packet-strings-parity green.
2026-09-28 17:53:37 +00:00
Doxa Maps
3c1d513cc3 The packet gets its bounded and centered sets page
Brief A1 back matter: after the profiles, before the wheel, full detail
only. The lightweight packet is map, triangle, profiles, wheel, points --
these two figures are the part a reader sits with rather than carries.

The layouts are regions' library/components/charts modules, imported
rather than injected: unlike describeGroup's country resolver they have
zero imports and run under plain node, and packet-render already imports
scatterCells from that directory. Verified by importing and CALLING them,
not by reading them. They return data, so the SVG is ours.

Gospel's non-negotiable is carried through and checked rather than
trusted: the bounded set keeps a deliberate gap either side of the
boundary because a dot ON the line is the one thing a bounded set cannot
express, and print has no hover to disambiguate it. Measured on the real
module with 40 groups -- inside max 85.3 of 92 allowed, outside min 119.8
of 112 required. The figure scales through the viewBox, so the gap lives
in coordinates the scaling cannot touch; shrink the figure, never the gap.

UNKNOWN POINTS ARE STATED, NOT PLOTTED. layoutBoundedSet gives them no
x/y, which is the module being right: `reached` undefined means we do not
know which side they belong on, so there is no honest place to draw them.
My first version mapped them through the same dot() helper and crashed on
p.x.toFixed -- a better outcome than silently drawing them at (0,0), which
would have put "we don't know" inside the circle. They go in the tally,
because on a printed page a category the reader cannot see reads as zero.

check-packet-variants' sheet delta goes 2 -> 3, updated in this commit
rather than relaxed into a range. It also now SUPPLIES the sets fixture:
setsPage returns '' without data, so a build() that omitted `sets`
exercised the filter for every page except the newest one, and the gate
sat green through the whole rewrite while asserting a delta that had
become wrong. A fixture missing an input silently narrows what the whole
file covers.

Seven strings x eleven locales; packet-strings-parity green. Gospel's own
ring names live in gospelInternet.json, a plugin catalogue, so there is no
collision with these.
2026-09-28 17:53:37 +00:00
Doxa Maps
e5dd83dad9 check:composites-mount records elements defined through the shared element helper
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
It captured defineCustomElement only in plugin entry files; since the entries build their element
through library/elements/defineMapElement.js, it recorded nothing and refused every composite as
'defines neither this component nor ProfileLoader'.
2026-09-28 17:52:51 +00:00
Doxa Maps
a25e0a6f99 Eleven plugin entries import the element helper they call
The element-bootstrap migration called defineMapElement in eleven entry files that never
imported it. A build does not fail on an unbound name; the element does, the moment it loads,
with 'defineMapElement is not defined'. The landing precondition caught it by importing each
profile. check:element-bootstrap now fails when an entry calls the helper without importing it.
2026-09-28 17:40:35 +00:00
Doxa Maps
3d16a1d62c Shipped comments describe the behaviour instead of quoting a person
Seven comments added in this batch quoted the reporter word for word. Each now states the
behaviour it guards.
2026-09-28 17:40:35 +00:00
Doxa Maps
05dca6213a check:i18n-parity fails on a Traditional-only character in the zh catalogue
The character set (322, from the UPG-100 owner's census, minus two shared by both scripts) is a
floor, and the PASS line says so; a t2s converter would make it exact if one is ever installed.
2026-09-28 17:40:35 +00:00
Doxa Maps
ff5aceb58f A country tapped on the map now reaches the prayer list
On mobile, selecting countries and pressing Prayer list reported zero UPGs and would not generate a prayer list.

selectedTerritories holds three kinds -- 'zone' (a legend row), 'adm0' (a
country tapped on the map) and 'adm1' (an India state / China province) --
and selectedLists() kept only 'zone'. So a map selection resolved to an
empty list and selectionAsList threw "nothing selected", while the HUD,
which sums t.count straight off the selection, showed a correct non-zero
total. Two sources of truth for one selection is why it read as a nonsense
error rather than an empty state: the app said 87 and the generator said
zero.

The adm0 key already carries the ISO3, which IS country_code on a
people-group row, so a tapped country becomes a country_codes selector the
evaluator already handles. No new selector type, and a mixed selection
unions and de-duplicates for free. The cover label now names the countries
too -- built from lists alone it was empty, and reduce() on an empty array
throws, so the packet would have died on its cover even after the selector
was fixed.

adm1 cannot resolve and that is a data fact, not an oversight: a
people-group row has no province field and the evaluator has no adm1 case.
Folding it into the country union would print a packet for the whole
COUNTRY while the reader had selected one STATE -- a plausible packet that
is quietly wrong. So it is refused BY NAME, which "nothing selected" was
not: that is a lie the reader can see is false with their selection
highlighted on the map.

Also, nothing showed that the action was running: batchBusy only
DISABLED the buttons, and a disabled button is visually indistinguishable
from an unresponsive one, so on a phone you tap, see nothing change, and
tap again. busyAction names which action is running so only that button
relabels, and it is cleared in every finally -- otherwise a failed print
leaves the button reading "Working..." forever.

check-selection-kinds.mjs reads the kinds OUT of resolveTerritory rather
than listing them, because listing them is how this survived: 'adm0'
existed in the resolver and nowhere in the consumer, and a test written
from the same mental list as the consumer would have missed it too.
Mutation-tested: reverting the guard, dropping the selector, and leaving
busyAction uncleared each fail with their own message.
2026-09-28 17:40:35 +00:00
Doxa Maps
5c9c1fd36b Two maps placed their share menu correctly because they were named in a string literal
DRV1650-SHARE-MENU-MOBILE-OFFSCREEN. On mobile the share/embed menu opens off-screen to
the right on Cities with Internet and Gospel Resources, while Simple Research and Prayer
Progress are correct.

THE SPLIT IS NOT A PLUGIN DIFFERENCE. MapControlMenu resolved the area it places inside
with closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]') -- three
hardcoded plugin class names. doxa-simple-map and research-map are on that list. Everyone
else resolved `|| null`, and place() answers a null area by returning an EMPTY STYLE,
which leaves .mcm{position:absolute} at its STATIC position: below-and-right of a button
already at the top right. Off the page.

So the two maps that worked worked because they were named in a string in another
component, and the bug was decided by a literal rather than by geometry. It is also the
SAME failure the 2026-09-22 dashboard fix documents twenty lines below -- not "a wrong
side was chosen" but "nothing chose anything" -- returning through a different door.

MEASURED: cities-internet, gospel-resources, key-cities and upg-neighbor carry no
map-area class and no data-map-area, so FOUR plugins were unanchored. The Driver reported
two. A fix aimed at the two he named would have left the other two, and a gate written
around them would have passed.

The named area is still preferred, because it is a real map's walls and a menu must not
outgrow them. When a plugin declares none, the anchor falls back to the toolbar's own
offsetParent -- the containing block this menu is absolutely positioned against, so
placing against it is correct by definition rather than by registration. A plugin that
adopts a class later simply gets the better anchor; nothing has to be registered anywhere
for a menu to be on screen.

check-map-menu-anchor asserts the PROPERTY rather than the list: that the resolution
cannot produce null from a real toolbar, that place() still guards a genuinely absent
one, and that a declared map area is still preferred over the fallback. Negative-tested:
restoring the allowlist-only resolution turns it red.

NEEDS EYES: this is rendered geometry and I work headless. What I can prove is that the
anchor is no longer decided by a name; whether the menu now sits correctly at 390px on
all four is rega's to measure.
2026-09-28 17:40:34 +00:00
Doxa Maps
3d7ad038a9 A relative share link works when you click it, which is why nobody caught it
DRV1650-SR-EMBED-URL-RUNTIME-ORIGIN. The Driver copied
`research-map-plus-grid/research-map-plus-grid/index.html` from a page at
http://127.0.0.1:5192/, a link that works on no other site.

resolveShareUrl's tier 1 returns its input VERBATIM, correctly: a host handing over a
whole url has already decided. So a composite that BUILDS a tier-1 url takes on tier 2's
job, and both of mine wrote the same thing:

    base ? base + '/' + leaf : leaf

On any host where the tree root cannot be derived from the script url — a dev server, an
ESM build, a tree served from a path that does not match `<name>/<name>.js` — base is ''
and the else branch hands tier 1 a bare relative path, which it then returns unresolved.

★ IT LOOKS RIGHT IN THE ONE PLACE ANYONE CHECKS. A relative share link WORKS WHEN
CLICKED, because the browser resolves it against the page you copied it from. It is the
EMBED that breaks — an <iframe src="…"> on a partner's page resolves against THEIRS — so
the failure is invisible exactly where it is tested and visible only where it ships.
research-map-plus-grid.vue:174 even documents the requirement ("It MUST be absolute,
which is the non-obvious part") four lines above the branch that violates it.

absoluteEmbedUrl() is tier 2's resolution made available to tier-1 builders: the
configured base when known, otherwise resolved against document.baseURI, which yields the
runtime origin. Never relative in a browser. The ladder is unchanged; only its last rung
is.

The gate has both arms, because either alone would rot: the RESOLVER must be absolute
with no base (the failing case, not the passing one), and no plugin may rebuild the
`: leaf` fallback. Negative-tested: removing the origin fallback, and re-introducing the
ternary in a plugin, each turn it red.

"Copy link didn't work" is most likely the same defect rather than a second one —
ShareButton's copy already falls back from navigator.clipboard to execCommand for
insecure contexts, so the copy almost certainly succeeded and produced a useless URL. If
it still fails after this, it is a separate bug and needs a browser to see.
2026-09-28 17:40:34 +00:00
Doxa Maps
fb769b3e86 The language tree becomes anyone's, and the numbers nobody wrote down get written down
Driver brief §B. gospel-internet's GiTree had NO props and NO emits: it read a board
composable and three module singletons, which is what made it gospel's rather than
anybody's. Everything it reached for is now a prop, and the parts worth testing are no
longer inside an SFC a gate cannot run.

WHAT IS ACTUALLY IN THE COMPONENT: wiring, 160 lines. The drawing is the whiteboard
tiered-points layer, the surface is whiteboard-map.js (moved out of gospel's plugin --
67 lines whose every import already came from library, so it was never theirs), the
hierarchy is language-tree-model.js and the camera is frame-target.js. Four modules,
four headless gates. An SFC cannot be executed by a check, so it should hold as little
as possible.

THREE BEHAVIOURS PINNED BECAUSE THEY ARE EASY TO STATE WRONGLY:

FRAME ON SELECT, NOT ON CHANGE. Clearing does not re-frame; the camera stays where the
reader left it. "It frames when the selection changes" is the obvious phrasing and it is
wrong in exactly the case that annoys people -- being yanked somewhere after deselecting.

A SELECTION THAT ARRIVES BEFORE THE MAP IS REPLAYED. GiTree survived this by ordering:
its host set the node through a composable that waited for data, so the value always
landed after the map. As a PROP the ordering belongs to the host, and a prop whose effect
depends on WHEN you set it is a trap -- a shared deep link would work or not depending on
load timing, which nobody could report. ready replays whatever `selected` is.

HOVER CANNOT MOVE THE CAMERA, structurally rather than by convention: hover sets a filter
on its own layer and there is no camera call on that path to remove later.

AND THE CONSTANTS. gospel's maintainer inherited GiTree and had to read intent out of
structure -- there is no "this took measuring" note anywhere in it. Asked which line they
would flag, they named frame(). So padding 60 (the outermost nodes carry labels that
extend past the point), maxZoom 8 (a two-language family must not fill the screen with
its own neighbourhood gone), duration 900, and the floor zoom that stops framing one node
from zooming the reader OUT are now stated with their reasons and asserted.
Two branch rules with them, both of which a tidy-up would erase: reduced motion is a
DIFFERENT CALL (jumpTo, not easeTo with duration 0 -- the engines do not treat those as
one), and a single point CENTRES rather than fitting a degenerate box, because a
one-language family is ordinary and fitBounds on no extent zooms to nothing.

THE ROLLUP IS OVER THE READER'S SCOPE. "A family shows the worst colour below it" is only
true relative to what is currently on the board: a family that is red overall can be
green inside a filter. Handing the rollup a whole subtree makes the colours quietly
disagree with the board -- no error, no blank, just a wrong fact about a real people
group. Asserted in both directions, and colorFor stays injectable because nothing in the
drawing depends on the rollup being worst-of.

THE CLICK TOGGLE MOVED TO THE HOST, and that is a change rather than a codification:
GiTree cleared the selection when you re-clicked the selected node, and that toggle lived
in the component's own handler. A host that wants it must now write it. In exchange the
chart holds no selection state.

gospel's UI is not wired and their boardEngine.js is untouched on main; they adopt on
their own schedule.
2026-09-28 17:40:34 +00:00
Doxa Maps
d3ee0bb6ec The whiteboard is a layer of tiered points; only the edges were ever a tree
Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom,
label priority and tiling from the map engine instead of re-implementing them in SVG.

READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The
parent->child links are sourced from linkFC; the points, the selection and hover rings,
the dish behind a cluster and EVERY label layer come from one point collection and know
nothing about trees. gospel re-read that independently and found the same split, which
is the only reason I trust it -- "the seam is generic" is the claim that should never
travel on one reader.

So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose
features carry id, label, tier and priority, and gives each TIER its own symbol layer
with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group
names in": the engine is told which labels may exist at which zoom and resolves the
collisions itself. Paint expressions pass through unread -- the layer has no opinion
about what a colour means, which is what keeps it reusable. Selection and hover are
FILTERED layers, so hover costs one setFilter and structurally cannot move a camera:
there is nothing in here that could.

AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong.
gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC
RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x
at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the
labels a reader compares are. library/maps/whiteboard-projection.js is the inverse
conversion, EXTRACTED from build-language-tree.mjs rather than written, because the
second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996
of the width at the centre to 0.6794 at the edge; that shrink IS the correction the
renderer then cancels.

TWO TESTS, EACH GUARDING A DIFFERENT RISK.
check:whiteboard-projection reproduces the old private implementation VERBATIM as a
reference and compares over 1,734 grid points EXACTLY -- the language tree is a
build-time artifact, so a rounding shift would move 4,000 coordinates and look like
nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND
that a linear scale FAILS that same assertion, because an assertion the defect can pass
is measuring nothing.
check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers,
priority by population, and NO EDGES. The absent links layer is the evidence -- if the
whiteboard draws this chart with no line layer anywhere, edges are separable in fact
rather than in my reading. The map is a recording fake, so the assertions are about what
the module DOES; a regex over the source would pass on a module that never called
addLayer.

Negative-tested: making the projection linear, changing its rounding, dropping the label
priority, or giving every tier the same minzoom each turn one of them red.

gospel's UI is NOT wired -- their component is theirs to write, later, against a module
that now exists. Their boardEngine.js stays where it is until the chart lands.
2026-09-28 17:40:34 +00:00
Doxa Maps
5ab4af2e44 Five plugins were silently monolingual, because the bootstrap was copied sixteen times
Goal (c), and it turned into a goal (d) fix. Every profile element is
defineCustomElement(ProfileLoader, { configureApp }), and the configureApp has four
steps. The fourth, app.provide('appI18n', i18n), is the one that gets dropped.

ProfileLoader injects that instance (ProfileLoader.vue:176) to drive its locale from
profile-config. Without the provide the watcher holds null and the drive NEVER RUNS --
while setRuntimeLocale() on the next line still fires, so tStatic strings DO follow the
host. The element renders a convincing MIXTURE of the reader's language and English, and
nothing throws, because the t() shim falls back to its key. It reads as "a few strings
not translated yet".

SHIPPING THAT TODAY: country-posters, key-cities, gospel-resources, cities-internet and
my-upg-100-list. regions-plugin censused all 19 entry files independently and found the
same five and no sixth. The measurement is regions-dashboard's, from when they hit it:
a Spanish catalogue present, es set, and every t() string still English.

All sixteen entries now call library/elements/defineMapElement.js, so the five are fixed
by construction rather than by five separate edits. Behaviour is otherwise identical --
same options, same element names, same per-plugin extras. wall-of-people-groups needed
the helper to hand its pinia back, because it takes a store off its own instance and a
helper that hid pinia would have given it a second one: two stores and a silent state
split.

★ AND THE CENSUS THAT RECOMMENDED THIS SAW FOUR OF SIXTEEN. My detector hashed function
BODIES; eleven sites are not `function buildXElement()` at all, they are
`const El = defineCustomElement(...)`. I wrote "every number here is a lower bound" in
the census as a caveat and it was the headline within the hour -- a 4x undercount of the
item it recommended. So this gate greps for the SHAPE, not for a known list: a check
written around the four that matched would have passed the twelve that did not.

check-element-bootstrap has two arms. No entry may build a profile element by hand, and
the helper itself must provide appI18n -- PLANTED as a negative case, so the regression
cannot return by editing library instead of a plugin. It also fails if it finds fewer
than ten entries, because a detection that stops matching reports the same green as a
tree with no problems.

THE PLANT CAUGHT TWO BUGS IN THIS GATE BEFORE IT CAUGHT ANYTHING ELSE. The predicate
matched the helper's DOCSTRING, so it would have passed a helper that only mentions the
provide in prose -- comments are stripped now, because a check that reads comments is
reading the author's intentions rather than the program. And the plant itself used /m
instead of /gm, so it deleted the documentation and left the code, then reported that it
could not detect an absence that was not there.
2026-09-28 17:40:34 +00:00
Doxa Maps
aedc517cec The table row is a border-box, and the offline packet gets its description
Two fixes from measuring what I had only modelled.

1. THE ROW RENDERED 81px AGAINST A 73.25px MODEL. rega measured it: no
box-sizing, so the declared height was the CONTENT box and the 6px padding
plus 1px border were added outside it. Because `top` still advanced by the
modelled height, every row overlapped its successor by the difference. The
stack still fitted the sheet -- it ended 8px inside the 60px bottom margin
instead of on it -- so nothing clipped, nothing ran off the page, and no
headless check could see it. box-sizing:border-box makes the declared
height the rendered height. The ladder still fits: the content box is
66.25px and a name plus two lines at the default 17px needs 61.8px.

2. THE OFFLINE GENERATOR WOULD HAVE PRINTED A ONE-CLAUSE DESCRIPTION. Its
fetch never asked for primary_language or imb_gsec -- measured on the
cached records, 0 of 2107 carried either, while country_code_label was
present on all 2107. So the browser packet printed two full sentences and
the offline packet printed "The Amahuaca live in Peru." from the same data.
Same defect class as the comment already above that field list: it reads as
missing DATA and is missing FIELDS.

The generator now injects the describer too, deliberately WITHOUT a country
resolver: library/utils/countryName.js cannot be imported under node at all
(it imports JSON without an import attribute -- measured), which is the
constraint that made this seam an injection. It is not needed, because
describeGroup falls back to country_code_label and every cached record has
one. Verified end to end: 28 packets, 63 sentences on the first page, zero
fallbacks, country localized correctly.

The gate now budgets against the CONTENT box rather than the declared
height, asserts the DEFAULT 17px step fits and not merely the smallest, and
pins box-sizing in the source -- mutation-tested: removing border-box fails
with its own message.
2026-09-28 16:49:01 +00:00
Doxa Maps
4a99453cda The packet's list becomes the profile TABLE: picture, description, three boxes
Brief A1 item 1. The 2x10 card grid becomes 20 full-width rows, each one
picture | generated localized description | three prayer checkboxes.

PER_PAGE stays 20, so this changes the layout SHAPE and not the
pagination -- which is what lets check-packet-render's dropped-groups
assertion keep passing unrelaxed. That assertion refuses a +/-1 tolerance
because slack is exactly where a lost list page hides, so a rewrite
needing it loosened would be the wrong rewrite.

The description column is ~1036px. An entire card in the old grid was
683px, so the sentence alone is 50% wider than a whole card was; that is
the point, since a generated sentence needs width a name and two metadata
lines did not.

The geometry closes with ZERO SLACK -- 20 rows plus 19 gutters fill the
sheet to exactly the bottom margin. That is the finding, not the
reassurance: a description wrapping to a third line overflows by ~16px.
Hence FIT BEFORE CLAMP: step 17 -> 15 -> 14px, and truncate at a WHOLE
WORD only as a last resort, because a mid-word cut reads as a rendering
fault rather than an editorial one -- especially inside a people-group's
name, where a reader cannot tell it from a misspelling.

Deterministic by character count, never a DOM measure, because this module
runs under Node for the offline generator as well as in the browser. The
0.52em advance is deliberately wider than Georgia's true ~0.50 so a locale
wider than English is absorbed by the estimate rather than by an overflow.
Measured against the real catalogue, every shipped locale fits at full
size -- German is longest at 178 chars against a 234 budget -- so the
ladder is insurance, not routine.

describeGroup is INJECTED, not imported: its country resolver reaches a
Vite-only alias and imports JSON without an attribute, which would make
every `node internal/check-*.mjs` unrunnable. usePosterPrints composes it
on the browser path and tolerates its absence, so a packet that cannot
resolve a describer still prints, degrading to the metadata line.

check-profile-table.mjs was mutation-tested, and two of my own assertions
were dead. "The rows close on the bottom margin" is a TAUTOLOGY -- the row
height is derived by dividing the page by PER_PAGE, so it holds for any
row count; a 21-row mutant sailed through it. It is replaced by the real
constraint, that a row stays tall enough for its content (fires at 24
rows). And the word-aware check fed 'word word word', where a mid-word cut
is indistinguishable from a clean one; with distinct words it now names the
broken token.
2026-09-28 16:49:01 +00:00
Doxa Maps
288bbf278a Assert that no ring value makes a group vanish
The bounded half already asserts that its total is conserved; the centered half
did not. It pins the three states as separate, and separateness is not
completeness: a group with a ring nobody anticipated could fall out of placed,
centre and unplaced alike and simply not exist on the page.

That is the failure that looks like a smaller dataset rather than an error. An
empty chart gets noticed; a chart quietly missing eleven groups does not, and
eleven is exactly how many carry ring -1 today.

Checked with the awkward values rather than the tidy ones: out of range high and
low, missing, fractional, and a numeric string. All seven are accounted for, the
out-of-range pair land in unplaced rather than being clamped to ring 1 or 10,
and a fractional ring is unplaced rather than rounded onto a neighbour it does
not belong to.

Falsified before trusting it: a filter that silently drops non-integer rings
turns the arm red at 5 of 7. The negative control also pins that the count moves
with the input, so it is not counting a constant.
2026-09-28 16:49:01 +00:00
Doxa Maps
00e505404c The gallery pane was ported from a working one, two lines out of three
rega's census refutes the min-height diagnosis I gave, and the arithmetic is what
settles it. Inside section.dms-pane the CONTENT measures exactly the viewport: 900 in a
794px pane at 1440x900, 844 in a 712px pane at 390x844. The overflow is not "about the
DX-life header", it IS the header -- 106 and 132 px -- and it happens on 12 of 16 tabs
INCLUDING plugins that assert no floor of their own, which is what proves the floor is
not theirs.

library/ProfileLoader.vue:336 floors EVERY profile at
`min-height: var(--profile-min-h, 100dvh)`. That measures against the VIEWPORT while the
pane measures against its HOST, so wherever anything sits above the gallery the profile
is taller than the pane by exactly that chrome, and .dms-pane's overflow-y:auto scrolls
the whole plugin -- tab strip included. ProfileLoader documents the trap and documents
the only lever: the floor is inside the child's SHADOW ROOT, where no host-page selector
reaches, so an inherited custom property is all a host has. `--profile-min-h: 0`.

IT IS TWO THIRDS OF A FIX, NOT A NEW DEFECT. .vtl-host and plugin-studio's .ps-pane
already set it. .dms-pane was copied from .vtl-host -- its own comment says "porting the
same proven shape" -- and took the first two lines: fill the host, own the overflow. The
third, release the child's viewport floor, is the one that makes the other two safe.

So the gate now asserts the THREE AS ONE SHAPE: a rule that is absolutely-filling AND
scrolling must release the floor. It also FAILS if it cannot find such a rule at all,
because a gate that silently stops finding its subject is the failure I spent this
morning removing from check:i18n-keys-exist -- a check that matches nothing reports the
same green as a check that matches everything.

Negative-tested: removing the opt-out from either pane fails it, and changing the pane's
shape so the gate cannot recognise it fails it too rather than passing blind.

MY DIAGNOSIS WAS SECOND-ORDER AND I SHOULD SAY SO PLAINLY. The min-height floors were
real and worth fixing, but they only bite when a plugin's own floor exceeds the pane.
This bites on every tab at every size. I reasoned from source to the first mechanism
that explained the one symptom I had been shown, and the tall-desktop arm -- the one I
asked rega to run precisely because it would refute me -- is what separated them.
2026-09-28 16:49:01 +00:00
Doxa Maps
c7edd82f9c Promote the set layouts to library, as one implementation
gospel's boundedSet and centeredSet move to library/components/charts, and every
importer follows in the same commit: gospel-internet's two components and the
gate. One implementation, no window in which someone edits the wrong copy.
gospel chose this over copying, for the reason the shared scatter already showed.

Two print-safe components come with them, adapted from gospel's rather than
written fresh: theirs fetch and hold board state, these take joined groups and
every label as props, so the packet and the board draw the same figure from the
same code. gospel's already inked their tallies and used no hover, so the
adaptation is the data path and the labels, not the drawing.

The labels had to change, and not for style: gospel's read T('gospelInternet.…'),
and a gospelInternet-namespaced key cannot resolve for another area's consumer,
so the packet would have rendered the English fallback in every locale. Labels
are props now.

Three invariants gospel gave me that the comments did not carry are pinned here:

No seed prop. Position is hashed from the group's own id, so a group lands in the
same place on every render, forever — two printings of one packet are identical
and a reader comparing sheets sees real movement rather than reshuffling. I had
specced a seed; it would have silently rescrambled every dot while the data was
unchanged, which on paper cannot be corrected. Asserted through the parser, and
adding one back turns it red.

Every ring is listed, empty ones included. Under a filter several of the nine go
to zero, and a decoder that omits its empty rows stops being a decoder: a reader
cannot tell "none here" from "this ring does not exist".

And an arrow is a claim about a relationship, so a group with no recorded
neighbour gets none rather than a default one toward the centre.

The unknown-is-counted rule is now in the module header where the next reader
meets it first, at gospel's request: a printed page has no hover, so a category
the reader cannot see is one they will assume is zero.
2026-09-28 16:49:01 +00:00
Doxa Maps
5b51153b82 A life-calendar anchor outside the grid says so instead of drawing nothing
By the regions owner. An anchor week past the grid, negative or fractional passed an isFinite
guard and matched no cell, which on paper reads the same as the deliberate no-anchor default.
anchorPlacement() returns a reason, LifeCalendar prints it, and check:packet-charts covers six
edge cases while pinning that no anchor asked for stays silent.
2026-09-28 16:49:01 +00:00
Doxa Maps
642fc15310 Prayer List gets the cards' caret, and starts printing the packet
The Driver asked for the SAME caret the cards already have on the Prayer
List, with two entries -- Full detail and Lightweight -- and said plainly
not to destroy the print options that exist. So nothing is merged, nothing
is removed, and the Poster is untouched.

There was no dropdown COMPONENT to reuse: the cards' caret is an inline
disclosure, one ref plus a sibling tape of plain buttons, deliberately not
a <select> because a native dropdown collides with other dropdowns on the
host page. So the PATTERN is generalized rather than copied. deckOpenFor
now holds a compound `${id}:${which}` key, which keeps "opening one closes
the others" free -- copy/paste would have given two open-states, and two
tapes could then sit open at once and overlap in the grid gap.

While wiring it, a real defect surfaced: THE WALL'S PRAYER LIST BUTTON HAS
NEVER PRINTED A PACKET. downloadPacket calls printList, a columnar table of
name/country/population/religion. The packet -- map, life calendar,
%-evangelical grid, triangle, profiles, wheel, points -- is printPacket,
which every other surface calls and this wall never did. That is a direct
cause of the packet "not following the design that was asked for" here: no
change to the renderer could reach a button that was not calling it.
printList stays exported for any surface that still wants the list.

The two packets are a FILTER over the existing page array, not a second
code path, so they cannot drift apart in layout, i18n, RTL or print scale.
The filter is keyed and never indexed -- an index filter reads identically
today and drops the wrong page the first time the order changes, and that
order is the Driver's. listPages is deliberately outside the filter: a
variant may drop a FIXED page, never one whose count depends on the data,
because that drops people silently. An unknown variant falls back to full.

internal/check-packet-variants.mjs pins all of it, and was mutation-tested
rather than trusted: dropping list pages, reverting to printList, adding a
second open-state ref, and typoing a variant id each fail with their own
message.
2026-09-28 16:49:01 +00:00
Doxa Maps
df42fe7438 Fold the zone-tree assertions in, and stop the check dying before it reports
One file for one plugin's zone data: the join and the tree over the same zones
were two scripts only because the first was already queued for landing.

The tree section pins the id namespaces. The legend emits one bare string on one
channel, and the zone and region id spaces share exactly one name, so
un-namespaced that string cannot say which row was clicked. The fixture gives
that region two zones on purpose — today's data gives it one, so both readings
return the same set and a check built on the real table would pass without ever
exercising the difference. It would be measuring the data rather than the code.

Poisoning the namespace exposed a worse problem than the one being guarded. An
unguarded lookup threw when the region id stopped existing, so the file died at
the third assertion and the seven after it never ran — including the one the
section exists for. The crash still exited 1, so it read exactly like the check
catching the bug. Guarded, five arms now go red by name instead of one stack
trace. A check that dies reports less than a check that fails.

The summary arm is labelled as the weakest rather than the strongest. Under the
poison the two readings still differ, both wrong, so it stays green while the
specific lengths go red. It states the property; it does not guard it.
2026-09-28 16:47:05 +00:00
Doxa Maps
a7011c77ee An overlay that cannot be placed must not be drawn at Infinity
The funnel computed each bracket with Math.min and Math.max over the step
indices an overlay touches. On an empty list those return Infinity and
-Infinity, so the bracket rendered at y=Infinity with height -Infinity: an
invalid rect that SVG discards without a word. The overlay vanished and took its
groups with it — no error, no gap, just a chart missing a band nobody knew to
look for.

An overlay is unplaceable when its `within` is missing, empty, or names a step
that does not exist, and all three are easy to write. `within` is the unusual
half of the API, so an overlay written as { id, label, groups } looks complete
and is silently undrawable, and a typo in a step id deletes a band rather than
failing.

bracketSpan now returns null for all three, the component lists them in ink
instead of dropping them, and the arithmetic moved out of the template where it
could be asserted. An overlay with no known relationship to the steps must not
be drawn, because a drawn bracket asserts a span.

Found by reading the template rather than by a test, which is worth recording:
these components are in L61 and had never been rendered once. The negative
control pins the cause — Math.min of an empty span really is Infinity — so the
assertions rest on the measured behaviour and not on my account of it.
2026-09-28 15:52:47 +00:00
Doxa Maps
d7b6a631b6 Move zone-regions.json + groupings.json to data/tables/, the shared asset pool
GOSPEL'S LEGEND RENDERS IN THE BROWSER and needs these zones at runtime; a
build-time import path would not reach the bundle. data/tables/ is the pool the
tree copies into public/dx-maps/assets/tables/, which is how near-neighbors.json
already reaches a browser, so this makes the zone data a single source the same
way (dx-shepherd, 2026-09-28).

admin-to-list.json deliberately does NOT move: it waits on the Driver's row 87,
the "zones fold" regeneration.

★ TWO ROOTS IN internal/check-list-selection.mjs, NOT ONE. Its readJson helper
reads all three files through a single DATA constant, and only two moved.
Collapsing them to one root silently breaks whichever file is not where you
pointed — so the helper now routes admin-to-list.json to the plugin dir and the
other two to data/tables/, with the reason in the comment.

★ THE ASSET-POOL COPY IS MANUAL, AND IT HAS ALREADY DRIFTED. There is no
automated copier: `git grep` finds no rule in vite.config.js or internal/ that
writes public/dx-maps/assets/tables/, and exp's rings note says the same ("only
the tree copies tables"). Measured before copying: data/tables has 309 json
files, the asset pool 304 — and the five missing were groupings.json,
zone-regions.json AND THREE THAT PREDATE THIS COMMIT (index.json,
open-doors.json, platform-reachability.json). So these two would have joined an
existing gap rather than created one. Both are copied here and verified with
cmp; the other three are reported separately, not silently fixed in a move
commit.

Paths verified to resolve, not assumed: P(GROUPINGS) and the REGIONS join both
resolve to existing files. Gates green after the move: check:list-selection,
check:selector-fields, check:describe-group, region-hue-proof.
2026-09-28 15:52:47 +00:00
Doxa Maps
1aa9a0ac99 A composite embedded bare dropped tokenUrl, and passed because a ProfileLoader sat above it
PM-tok, PRODUCTION-MATRIX.md:285. `tokenUrl` names a token SERVICE a host points at
instead of pasting a key: the element asks that URL when it was handed no token. Only
ProfileLoader ever read it. A pane composite bakes its children in rather than mounting
them through ProfileLoader, so a composite embedded BARE -- one tag on a partner's page,
which is the entire point of a composite -- dropped a tokenUrl-only config on the floor.

It worked in every place anyone looked, because a ProfileLoader elsewhere on the page
fetched and published the token: the composite was passing an exam somebody else sat.
And the failure wears the wrong face. No token is "No Mapbox access token provided",
0 tiles, no canvas -- identical to a missing `tk`, which IS threaded -- so the natural
reading is that the host mistyped their config rather than that a knob is unread.

The rule lives in library/composables, not in each composite, because it has already
been copied once (ProfileLoader.vue:132) and a third and fourth copy is how one of them
later learns to ask ALWAYS -- which a token service billed per call notices before we do.
The pure decision is split into host-token-url.js with NO framework import, because a
fresh worktree has no node_modules and a module importing vue is unloadable by the gate
that proves it right. Same shape as describeGroup keeping countryName injectable.

check-composite-token-url takes its expectation from ProfileLoader -- the contract a
composite REPLACES -- not from the composites it audits, so it cannot pass by
construction; it exercises the decision rather than grepping for it; and it finds its
subjects by a PROPERTY, a production bundle already forwarding `tk: hostToken`, so a
third composite is covered the day it is written. stack-demo has the same shape and is
excluded by its STAGE, not by name, so promoting it pulls it in automatically.

Negative-tested 5 ways. The fifth found a bug in the gate itself: /tokenUrl/ also matches
`tokenUrlX`, so the check meant to notice the contract being renamed could not fail. It
is \btokenUrl\b now. A guard that cannot fail is the one nobody ever finds by reading.
2026-09-28 15:52:47 +00:00
Doxa Maps
6518fad1bb The language tree's layout becomes library's; its gospel classification does not
Driver brief §B, BUILD-LIBRARY-CHART-LANGUAGE-TREE: promote gospel-internet's language
tree so a fork can build one from its OWN rows.

gospel's scripts/build-language-tree.mjs does two jobs in one file. The generic half is
the radial layout, the circle packing and the inverse Web Mercator projection. The other
half reads gospel-internet.json, ebible-index.json, gospel-resource-links.json and FCBH
verse scopes to classify each language g/y/r, and emits a second, purely gospel output.
Moving the file whole would have put eBible and FCBH inside library/, and a fork
building from its own roster has no eBible index -- so the literal move is the one shape
that would NOT deliver the reuse the brief asks for.

So the classification stays with its owner and arrives as `statusOf`, returning an
OPAQUE string. Nothing here knows what 'g' means; a consumer colouring by speaker count
substitutes one function instead of forking a data pipeline.

The status is written on LEAVES ONLY, which is not an omission. Rolling a parent's
colour up from its descendants is a RUNTIME decision the chart makes against the
reader's current scope -- and the scope changes as the reader filters, so baking it
would freeze one answer into the data. gospel confirmed the rollup is policy rather
than geometry: nothing in the drawing depends on a parent being the worst of its
children.

The measured constants keep their reasons: the enclosing circle is the tree's real
extent plus room for one label (sized by the deepest ring, packing wasted a third of the
board); the poster layout drops dialects (2,653 dialect slots squeezed the 858 languages
until ~40% of names could not be printed); and the share of the Mercator square is a
parameter with a measured default, because it is what decides how far a reader can zoom
out below the opening view.

check-language-tree-layout asserts INVARIANTS rather than a golden file -- a snapshot of
4,000 coordinates fails on every legitimate change and says nothing about which property
broke. Family circles do not overlap, every roster language is reachable, a missing one
warns rather than vanishing, the drawing stays inside its extent budget, the poster
layout excludes dialects, the status letter is carried through unexamined, and the same
input builds the same tree twice.

gospel's own script is untouched: they switch to this and drop their copy in the single
owner-land already planned, so nothing of theirs breaks in between.
2026-09-28 15:52:47 +00:00
Doxa Maps
18bcaec446 Number({value:"1"}) is NaN, so the whole reachedness sentence vanished
The live API sends imb_gsec as { value, label }, not a number. describeGroup read it
with Number(), got NaN, matched no GSEC level, and dropped the level sentence — not an
error, not a fallback, simply absent, so the row described a people group nobody has a
reachedness figure for. upg100 hit it and fixed their own normalizer; that fixes their
caller and nobody else's, and every other consumer handing this function a RAW API
record lost the sentence the same silent way.

The unwrap belongs at the READ, because this function's contract is "an API or
normalized row" and both spellings are inside that contract. pick() now flattens
{ value, label } to its value, exactly as normalize.js:22 does.

THE LANGUAGE CLAUSE WAS GOING THE SAME WAY, FOR THE SAME REASON, and fixing only GSEC
would have left it. On a raw record the language NAME is at primary_language.label; the
field itself holds an ISO code. The existing read deliberately refuses that code -- it
would print "and speak abf" -- so a raw record simply had no language and fell to the
country-only sentence. labelOf() takes the display half of the same pair, and ONLY for
a nested object: consulting a flattened primary_language would print the code, which is
the defect the refusal exists to prevent. Same for country_code, whose nested label is
also the English fall-back when no resolver is injected.

A raw record now produces a string identical to the normalized one, which is the
assertion that makes the rest of it hard to regress.

Negative-tested: removing the unwrap, dropping either nested-label read, or letting
labelOf read bare strings (so the code prints) each turn it red; restored, green.
2026-09-28 15:52:47 +00:00
Doxa Maps
42b4c8f16b Two readers of one private field, and nothing was holding them together
internal/lib/facet-walk.mjs's __srcFeatures (injected into the page by
check:facet-changes-map) and library/maps/pin-selection.js's readSourceFeatures (the
packet and zoomToLegendRow) answer the same question about the same private GeoJSON
field. They are two copies because one must be a string evaluated in a browser and the
other an ES module, so they cannot be one function -- but nothing tested that they
still agreed, and they had already stopped.

THE CROSS-CHECK FOUND A DIVERGENCE ON ITS FIRST RUN, which is the argument for it. A
source may hold a single Feature rather than a FeatureCollection; both engines accept
that. The product's reader handled it and the gate's did not, so the gate answered
"cannot measure" about a payload it could read perfectly well. Nobody reading either
file would have noticed: each is correct on its own terms. __srcFeatures now handles
it and the two agree.

ONE DIFFERENCE IS DELIBERATE AND IS NOW PINNED AS SUCH RATHER THAN WAIVED: on a
url-backed source the gate returns null while the product returns [] and warns. Both
are right for their caller -- a gate whose job is to tell "the map drew nothing" from
"I could not look" needs a third answer, while a selection returning null would break
the Set arithmetic every caller does on it. Asserted in both directions, so neither
side can quietly adopt the other's and call it a fix.

AND NEITHER MAY BE SILENT ABOUT A SHAPE IT DOES NOT KNOW. maplibre has a THIRD _data
shape: updateData() stores { updateable } (maplibre-gl-dev.js:49001). No product code
calls updateData today, so this is a landmine rather than a bug -- and it is armed the
moment someone adopts partial updates for performance, at which point the product
reader would have gone back to returning [] in silence: "the map drew nothing", stated
as fact about a payload nobody recognised, one line below the url warning that exists
to prevent exactly that. Both readers now name the unrecognised shape by its keys, and
a fixture holds it, so adopting updateData() trips a test instead of blanking a map.

Negative-tested: dropping the single-Feature case, silencing either reader, or making
the gate adopt the product's url answer each turn it red; restored, 14/14 green.
check:pin-selection still passes, so the product reader is unchanged where it matters.
2026-09-28 15:52:46 +00:00
Doxa Maps
91ae35195c check:describe-group's catalogue arm asserts each GSEC level's own sentence, not only a non-empty string
By the UPG-100 owner. A nested imb_gsec (NaN level) dropped the level sentence while the
where-clause still rendered, and a non-empty check passed that. Each level's output must now be
longer than the where-clause alone.
2026-09-28 15:01:20 +00:00
Doxa Maps
628edf4457 The three packet charts, and two constraints that live in a prop
VisionFunnel, LifeCalendar and ShareCalendar, all props-driven, no hardcoded
list, print first: fixed px on the packet's 816x1056 canvas, nothing revealed by
hovering, and the packet's own ink rather than a new palette, so a reader cannot
tell which page came from the component and which from the renderer.

The funnel draws steps as stacked trapezoids and overlays as brackets BESIDE
them, never inside. Being inside a step is precisely the claim an overlay cannot
make: 362 of DOXA's 2,107 groups are not frontier at all. It asks
overlayPlacement whether an overlay is a slice rather than deciding, and prints
each overlay's parts as text, because a bracket nobody can hover over explains
nothing. Given steps that do not nest, or that mix publishers, it refuses and
prints why instead of drawing a plausible picture.

It is also not mirrored under RTL, which is worth saying because the obvious
reading of "mirror it" is wrong here: the triangle is vertically directional, so
a horizontal flip reverses nothing meaningful and inverts any text inside it.
What mirrors is the label and bracket side, so those use logical properties.

Two decisions now live in prop declarations, and a default is how either dies
quietly. ShareCalendar refuses to name its own metric: the shipped figure is the
evangelical share while the brief says "% Christian", and one default would
reintroduce the confusion this project already retracted a P0 over, with nothing
looking wrong. LifeCalendar's anchor defaults to null because the packet prints
for a reader we do not know and shading their life is a guess presented as fact.
Both are asserted through the SFC and JS parsers rather than a grep, since a
regex cannot tokenise JavaScript, and a sibling prop that does carry a default
is the negative control proving the arm is not vacuous. Adding
default: '% Christian' turns it red, which I checked rather than assumed.
2026-09-28 15:01:19 +00:00
Doxa Maps
4f73d83438 One scatter implementation, and the gate that could have caught the bar
packet-render's lifeGrid already held a carefully reasoned shading: deterministic
so a packet reprints identically, and scattered because a contiguous run of 32
fills most of row 1 of 52 and prints as a solid bar across the header, which is
the ordinary chart the life grid exists to avoid. The message is rarity, and the
eye has to hunt for the 32 among 5,200.

That logic is now in packetCharts.js and packet-render imports it, so the
printed packet and the reusable chart cannot drift into two answers. The move is
behaviour-preserving: I ran the extracted function against the pre-refactor code
over 49 seed and count combinations with zero differences. That probe was
one-off, so the durable half is a golden fingerprint here — the exact cells for
one seed. If the algorithm is ever tidied, those change and every packet already
printed stops being reproducible.

The assertion that matters most is the one that did not exist when the bar
shipped. The existing check asserts the shaded COUNT is between 0 and 5,200,
which is equally true of a bar and of a scatter: a count cannot distinguish a
distribution, so no amount of that gate passing was ever evidence. rega's eyes
caught it, and nothing else could have. Now the spread is asserted — 32 cells
landing on 27 different rows — with the contiguous block as the negative
control, so the arm can fail in exactly the shape it exists to reject.

The rounding flag upg100 asked to keep reachable stays on the return value, so
the packet can print the honest caption when a share is too small to fill one
cell rather than inking one and asserting "some".
2026-09-28 15:01:19 +00:00
Doxa Maps
8c84838c78 Packet chart arithmetic, and the three ways it was quietly wrong
These numbers print. A packet is handed to a reader on paper: no hover, no
tooltip, no correction, no redeploy. So the arithmetic lives in its own module
and is asserted against the shipped data rather than a fixture.

DOXA IS NOT A SLICE. Of its 2,107 groups, 2,082 are unreached and 1,745 are
frontier, so 25 are not unreached at all and 362 are not frontier. That is one
group in six. A chart drawing DOXA as a fourth band nested inside frontier
misstates 17.2% of the list with a clean shape and no error of any kind, which
is why steps and overlays are different props and an overlay reports whether it
is a slice rather than being assumed to be one. upg100 asked for the numbers to
reach the caption as text too, because a printed band nobody can hover over
explains nothing.

Three bugs I wrote, each caught by an assertion rather than by review:

The publisher warning sat in the wrong function. I put "Joshua Project counts
16,426 where IMB counts 12,379" into the nesting error, and the test proved the
claim false: 16,426 on top of 12,379 is perfectly monotonic. The two are not the
same kind of count, and no comparison of magnitudes can see that, so it needed a
separate check reading what the numbers are OF. An error message naming a cause
its own code cannot detect sends the next reader to look for the wrong thing.

A percent read as a fraction filled 3,189 cells of 5,200 instead of 32. My
heuristic was "above 1 means percent", and pctEvangelical is 0.6133, which is a
valid fraction and a valid percent with nothing in the value to separate them.
The refusal I added catches a percent of 1 or more and provably cannot catch
ours, and every share this packet prints is below 1% because the subject is
unreached peoples. So the guard is not the protection, the name is:
fractionFromPercent states the unit in the source line. Both halves are
asserted, including the half that does not work, because a guard believed to
cover more than it does is the worse failure.

And the rounding flags were untested until the test used values that could
reach them. 0.6133% of 5,200 is 31.9 cells; the reader sees 32 and cannot see
31.9, so the exact value goes in the caption and a share too small to fill one
cell is flagged rather than drawn as one.
2026-09-28 15:01:19 +00:00
Doxa Maps
c99efff8a6 check:describe-group renders from the real catalogue too, and runs in gates:pre
Catalogue arm by the UPG-100 owner: one row per GSEC level through describeGroup against the
shipping packet-strings.json, in en and ar, failing on any empty sentence. The existing arms test
the function on fixture strings; this one tests that the catalogue can feed it. It lands with the
ten keys, so it arrives green.
2026-09-28 15:01:19 +00:00
Doxa Maps
9fddcb9901 min-height clamps height, so the pane could not hold the plugin it was pinning
The Driver's least favourite bug: on DX Life's research page a capability-4 plugin
with tabs at the top had a second, element-level scrollbar, and scrolling it scrolled
the TAB HEADER out of view.

doxa-maps-spa pins each fixed-viewport plugin with position:absolute;inset:0;height:
100% and its own comment states the invariant: "fills the pane exactly, so
scrollHeight===clientHeight and overflow:auto paints NO scrollbar." CSS never granted
that. A used height is CLAMPED between min-height and max-height, so a child's own
min-height BEATS the height:100% the shell sets -- it is a floor, not a preference.
Wherever the pane was shorter than that floor the pinned element grew taller than the
pane it was meant to fill, .dms-slot grew with it, and .dms-pane's overflow-y:auto
became a real scroll container wrapping the WHOLE plugin, tab strip included. That is
the second scrollbar: not the plugin's content scroller, the PANE scrolling the plugin
bodily. The invariant was written as a description and was only ever a hope.

The shell now pins with min-height:0. An inline style outranks a child's :host rule,
so that one property restores the invariant for every fixed-viewport plugin at once
without editing any of them. It is NOT added to the scroll-stack branch, where
natural height is the design.

A floor set INSIDE a child's shadow root is out of reach from the shell, and two of
mine had one. research-dashboard's .rd-shell and big-picture's .bp-root now say
min-height:inherit, deferring to :host -- which the gallery pins to 0, and which a
naive page leaves at its own floor, so the collapse guard those floors exist for is
kept rather than deleted. big-picture's 480px MOVED UP to :host rather than being
dropped: :host had no floor there, so inheriting would have quietly resolved to 0
everywhere and thrown away the black-screen guard its own comment calls load-bearing.

check-one-scroll-container asserts both halves, because either alone rots: the shell
still pins with min-height:0, and no plugin grows a NEW floor inside its shadow root.
The second half is what matters later -- a fixed shell plus a plugin that adds
min-height:700px next month is this bug again and nobody would connect it.

The rule is a CONTRADICTION IN ONE DECLARATION, not a large min-height by itself: an
element saying height:100% ("I fill whatever holds me") while refusing to be shorter
than N. My first version flagged any big min-height and caught gospel-resources'
.gd-map, a map deliberately sized height:80svh that scrolls inside its root's own
content scroller -- the one scrollbar a plugin is allowed. Flagging it would have sent
its owner after a non-bug, which is worse than not having the gate.

Negative-tested: removing min-height:0 from the shell, and reverting either .rd-shell
or .bp-root to a constant, each add a failure; restored, only the known one remains.

RED ON PURPOSE, and deliberately NOT in gates:pre. gospel-internet's .gb still carries
min-height:640px and that fix is its owner's. Wiring a red gate into gates:pre would
block the fleet on someone else's file, so the gates:pre entry belongs in the same
landing that fixes .gb -- until then this gate is the work item, afterwards the guard.

Source only: doxa-maps-spa, research-dashboard and big-picture need rebuilding, which
is the build owner's to do.
2026-09-28 15:01:19 +00:00
Doxa Maps
93c3cd6aa1 Move the pinned gap, and assert the filing that closed it
upg100 filed asia-india-madhya-pradesh and this check went red, which is the arm
working. They reported it instead of deleting my expectation to make their own
change green, and they were right that doing so is how a guard gets hollowed
out. dx-shepherd ruled the two land together, which is the only ordering where
neither of us is red in between.

THIS COMMIT IS RED ON MAIN BY ITSELF. It expects three unfiled zones and today's
data has four. It goes green with upg100's data change and not before, so the
two must land in the same landing.

The gap pin drops to guizhou, xinjiang and bihar. Filing is the good direction
but it is exactly as revertible as any other data edit, so the three newly filed
India zones now get an arm of their own: drop a key from zone-regions and those
groups quietly leave the region tree again, with the bucket the only thing still
carrying them. Two of the three were never visible to the gap arms at all, so
without this they could be unfiled with nothing to say so.

Also recorded what the gap number actually counts. It is admin-to-list's values,
which is not every printable zone, so it is a lower bound rather than a census.
upg100 proved that costs something: checking the property instead of the four
names they were handed, they found four more unfiled zones, none of them an
admin-to-list value, so none of them ever appeared in this number. A count that
is quietly a lower bound reads exactly like a complete one.

And the fixture arm no longer hardcodes the number four. The gap changes; a
literal there turns every legitimate data fix into a confusing red in an arm
that is not even about the data. It counts the pin instead.
2026-09-28 15:01:19 +00:00
Doxa Maps
fe9fe34530 Set an alarm on the zone/region id collision, which data currently hides
gospel found a latent ambiguity in the emit contract while wiring, and checked
it instead of building past it. selectedId emits one bare string with no kind,
and region ids and zone ids share a namespace. Re-measured here rather than
taken on report: 33 zone ids, 7 region ids, and the intersection is exactly
europe, whose region is itself. So the string "europe" cannot tell a consumer
whether the Europe region row was clicked, meaning every European zone, or the
Europe zone leaf, meaning that one.

It is not live today, and only for a reason that is a coincidence rather than a
design: region europe contains exactly one zone, so both readings give the same
set. It goes live the day a second European zone is filed, and then it fails the
quiet way this whole file exists to catch — a smaller result, no error, no empty
state.

So this is an alarm, not a fix. The consumer builds the tree, so only the
consumer knows which rows are regions, and gospel is namespacing theirs. Two
arms: the colliding set is pinned and says re-derive if it changes, and each
colliding id's region must still contain exactly one zone. The second fails with
the instruction rather than the symptom, naming the zones and telling whoever
hits it to namespace their region rows.

Both falsified before trusting them: filing a second European zone fires the
ambiguity arm with both zones named, and adding a second colliding id fires the
drift arm with both ids listed. Data restored, clean diff.

The same note is on selectedId, because the next consumer to build a tree from
this zone data inherits the collision, and the doc as written invited it.
2026-09-28 15:01:19 +00:00
Doxa Maps
4b7f65204f Collect the gospel set-chart layout check in gates:pre
The check itself shipped with the plugin modules it covers, but the line that
runs it existed only in a message, which is not a place code lives. So it sat in
internal/ and would never have run — the same failure it was written to prevent,
one layer up.

This is the whole registration in one commit: the file, the script entry and the
word in the gates:pre list. It imports boundedSet.js and centeredSet.js, so it
must land after the plugin commits that add them; landing it first fails
collection and takes gates:pre red for everyone.
2026-09-28 15:01:19 +00:00
Doxa Maps
6b6c745dbe Make the zone-join assertions travel, and record what each guard buys
Both joins were verified while they were written, and that verification lived in
a terminal. A probe proves something once; only a registered check keeps
proving it. Collected by gates:pre, where it runs as check:upg-zone-joins.

Both defects it guards are silent. Neither throws, neither renders an error, and
both return a well-formed answer that is merely smaller than the truth: admin
names whose diacritics are not folded join 514 of 1009 rows instead of 1009, and
records passed in the pre-normalize { value, label } shape match nothing at all,
so every zone reports as having no groups.

Each arm is checked in both directions, and the negative arms are the point. The
check records that the fold is worth 495 rows and that the object shape resolves
zero zones, so a later reader asking whether a guard is still needed gets a
number rather than a green tick. It also fails if the unfolded path ever stops
losing rows, because at that moment the assertion would no longer be able to
fail and would stop being evidence.
2026-09-28 15:01:19 +00:00
Doxa Maps
bd37adacdd check:manifest-covers knows a desktop-only bundle is not in the web tree
rudder-voice targets desktop and widget, so generate-tree skips it through isWebBuilt(); this
check did not, and reported the deliberate absence as a missing plugin. It reads the same rule now.
2026-09-28 14:53:59 +00:00
Doxa Maps
f45624915f check:frozen-labels holds a composed bundle to the stage of the page that shows it
Written by the UPG-100 plugin owner. It now takes the stricter of a file's own profile stage and
its bundle's effective stage (internal/lib/effective-stage.mjs), so an experimental bundle that a
production page composes is checked as production, and the output says why.
2026-09-28 14:53:59 +00:00
Doxa Maps
76fe9ddf28 Test the cycle safety the header only claimed
The module said a cycle terminates rather than hanging. That was prose. The
manifest has six self-composing edges today, through the bundle/profile form,
and upg100 measured that a naive recursive walk hangs on it -- so the claim was
load-bearing and untested, which is the same gap as an untested fallback.

The fixture now carries a self-composing bundle and a mutual two-cycle. If the
closure ever regresses to recursion this test does not fail, it hangs, which is
a louder signal than a red assertion.
2026-09-28 14:53:59 +00:00
Doxa Maps
4b0751998d Hold composed bundles to the bar their readers are actually on
A gate scoped by a bundle's own declared stage misses what a production reader
sees. Two production leaves compose three bundles that declare themselves
experimental, so the code on those pages was held to the experimental bar --
not by anyone's decision, but because the scope was computed from the wrong end
of the composition.

Effective stage: production if it declares production, or if anything
production-for-gating composes it, transitively. The graph's edges come in two
shapes, a bare bundle name and a bundle/profile pair, and reading only one
silently halves it.

It lives in its own module because check:frozen-labels needs the same rule.
Two gates with their own idea of "production" would disagree by passing a file
the other fails, with neither obviously wrong.

The scan goes from seven plugins to ten, nineteen files to twenty-nine, and
from 178 to 274. The 96 are not new: they are English on production pages that
no gate was measuring. The report names which plugins are in scope only by
composition, and why, since that is the first question a reader will have.
2026-09-28 14:53:58 +00:00
Doxa Maps
29c9551d30 check:selector-fields in gates:pre: a packet fetch must carry every field its selectors read
Written by the UPG-100 plugin owner. It reads each selector's fields from the evaluator itself,
so it cannot encode a wrong list of zones. It fails on the fetch before cdc788fbf, which left
eight zones' packets empty, and passes after it; both land in this push.
2026-09-28 13:56:53 +00:00
Doxa Maps
88fe7509d0 The packet was praying over the numbers the page loaded with, not the live ones
rega RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend
row said 699. The legend row is right and the packet was wrong.

A pin's prayer count arrives TWICE. The GeoJSON property is whatever was true when
the page loaded; from then on polling writes the current count with setFeatureState,
and prayer-progress.js:94 colours from ['feature-state','peoplePraying'] once polling
is on. So the lit pins a reader counts are feature-state, and pin-selection.js had no
'feature-state' case at all -- it resolved to undefined, to-number defaulted it to 0,
and the comparison selected only the pins whose FROZEN property happened to be above
zero. 50 of them. The other 649 people groups had been prayed for and the packet left
them out, which is the one thing a prayer packet must not get wrong.

resolveExpr and matchesExpression now take the state alongside the properties, and
selectedPinIds reads it per pin off the MAP rather than off the feature -- the source
still holds the GeoJSON it loaded, setFeatureState never touches it. The source is
added with promoteId:'uniqueId', so the id feature-state is keyed by is the same
props[idKey] the selection already uses.

Degrading is deliberate in both directions: before the first poll there is no state
and property expressions still select, so an early packet is thinner and never empty;
getFeatureState throws on an unknown source, so it is guarded and a bad source loses
the selection rather than aborting the print.

ALSO, rega RQ3(a), a candidate and not yet a confirmed cause. prewarmRegionPaint
wrapped any non-feature-reading paint value in a case to make it data-driven,
including a zoom interpolate -- I argued for that explicitly. useMapLayers.js:319
already held the rule and I had not read it: zoom interpolation is kept by putting the
case in the interpolate OUTPUTS, and "the reverse (zoom nested inside a 'case') is what
breaks the layer, so don't do that." That reverse is the shape Mapbox classifies as
composite before reaching for an interpolation factor the expression cannot supply,
which is the class of the interpolationFactor page error. The wrap now skips zoom
expressions too. A missed pre-warm costs a frame; an illegal paint costs the layer.
None of the three properties it touches is a zoom interpolate at boot, so this is not
proof -- it removes the only writer that can build that shape from a runtime value.

check-pin-selection asserts the live count wins over the stale property, that the
stale property alone selects nothing, that a map with no feature-state still selects
on properties, and that a throwing getFeatureState degrades. Negative-tested:
removing the case, not reading the state, and dropping the throw-guard each turn it
red; restored, green.
2026-09-28 13:56:22 +00:00
Doxa Maps
926cc1fe89 Measure the zone gap in the shipped data, not only in the fixture
Every assertion here ran against a fixture. That proves the module behaves and
proves the fixture models a gap; it does not prove the gap still exists in the
data we ship. So the negative control could have stayed green forever while the
bucket quietly became dead code — or while a fifth zone fell out of the region
tree and nobody heard. Evidence that lives somewhere other than the thing it
describes has already stopped being evidence, and looks identical.

Three arms now read the real files. The gap between the region tree's source and
the joinable set is measured, and it is exactly the four this check names:
guizhou, xinjiang, bihar, madhya-pradesh. xinjiang is asserted specifically —
joinable in admin-to-list, absent from zone-regions, so its hundred groups are
reachable only through the bucket.

Two of the arms fail ON PURPOSE rather than going quiet. If every joinable zone
becomes filed, the check refuses to pass and says to re-derive the bucket or
delete it with its component, because an arm guarding nothing is not a pass. If
the gap moves in either direction it prints both sets and names the zone that
changed: one added is groups that just became unreachable, one removed means the
fixture is stale.

Both were falsified before being trusted. Filing all four turns the inert guard
red; dropping gujarat from the region tree turns the drift arm red with gujarat
named. Data restored, clean diff.

This deliberately does not resolve the three disagreeing lists — 33 filed, 12
joinable, 14 grouped, disagreeing in both directions. That is a data-ownership
question, and deciding it here would be the silent join we have refused. The
check only records the difference so it cannot drift unannounced.
2026-09-28 13:56:06 +00:00
Doxa Maps
98065a34ab The packet's generated description, and the language name it prints was index-ordered
describeGroup(group, locale, opts) -> one localized sentence pair for a people
group's profile row: where they live, what they speak, and how reached they are.
Pure, exported, no refs or fetch; strings and the country resolver are injected.

The level is phrased from GSEC, a number, never from GSECDescription, which is
English prose the API hands us -- interpolating it would leave the widest column
of every non-English packet in English.

THE LANGUAGE FIELD WAS BOTH WRONG FIELDS AT ONCE.

primary_language is in normalize.js's NESTED_FIELDS, so the API's { value, label }
is flattened to its VALUE: an ISO 639-3 code. Reading it prints "and speak abf".
The name is in primary_language_label, and it arrives INDEX-ORDERED -- "Arabic,
Shihhi" for what a reader calls "Shihhi Arabic". 725 of the 12,379 rows carry an
inverted label, one row in seventeen.

The un-inversion is not a second copy: useLanguageFamilyLegendData already built
one to make a lookup key, so the split moves to library/utils/languageLabel.js and
both call it. Doing that surfaced why the two uses are not the same use -- 13 real
names carry a comma INSIDE a parenthetical ("Boano (Indonesia, Central Sulawesi)"),
and a naive split reverses those into "Central Sulawesi) Boano (Indonesia". As a
lookup key that garbage simply misses and the next candidate is tried; printed in a
sentence there is no such safety net. The shared split is top-level-only, and a
trailing parenthetical is hoisted past the reversal so "Naga, Tangkhul (India)"
reads "Tangkhul Naga (India)".

The legend's own behaviour is unchanged, checked rather than assumed: old and new
lookup compared over all 3,498 labels in langFamilyByLanguage plus every
LanguageName in the dataset, 0 differences.

countryName.js is NOT imported. It resolves only through the Vite '@map/' alias and
imports two JSON files without an import attribute -- fine in a bundle, fatal to a
node gate. Importing it would make this module unloadable by the test that proves it
right in eleven languages, so the renderer passes it in, and a missing injection
degrades down countryName.js's own ladder (localized -> English label -> alpha-3)
rather than blanking the clause.

check-describe-group asserts every GSEC level, each missing field, both locales,
truncation, the inverted label, the parenthetical that must not be shredded, and
that a bare language code is dropped rather than printed. Negative-tested: reading
primary_language, skipping the un-inversion, a naive split, and dropping the
parenthetical hoist each turn it red; restored, it is green.

Not yet wired into the renderer and not yet in gates:pre -- register with
"check:describe-group": "node internal/check-describe-group.mjs".
2026-09-28 13:56:06 +00:00
Doxa Maps
4a80af39b6 Pin what the vertical legend's bucket buys, and register the check
Six more assertions, and a package.json entry so gates:pre actually collects
this instead of it only ever passing when I run it by hand.

Two facts the legend contract left implicit, both of them the quiet-wrong-answer
shape rather than a crash:

Ticking every REGION does not select every ZONE. The four unfiled zones have no
region by definition, so a reader who selects all seven regions still does not
have xinjiang and its hundred groups — and nothing on screen says so, the total
is just smaller. The bucket makes those zones reachable; it does not make them
reachable by a region sweep. A select-all must therefore sweep zones, not
regions, and this fails if one ever sweeps regions.

A zone whose members do not resolve must render EMPTY, not disappear. Members
resolve for sixteen of thirty-three today; five await a field and nine have no
selector anywhere. A tree that hides what it cannot join tells the reader the
zone does not exist, when the truth is only that we cannot currently join it.
Presence is by id, never by member count.

Both arms were falsified before being trusted: filtering zero-member children,
and mislabelling the bucket as an ordinary region row, each turn four of these
green. The negative control still carries the rest — it asserts the unaugmented
tree really does drop xinjiang, so the bucket is demonstrably load-bearing
rather than decoration.
2026-09-28 13:56:05 +00:00
Doxa Maps
363aaeb11d Land the vertical legend's check with the component
The thirteen assertions were in a _qa-cycle probe, which this repo gitignores.
They ran green on my disk and would not have landed with the component, so the
code was about to ship untested while I reported the tests as evidence for it.
studio caught it while staging.

That is the failure this very file guards against: a green result measured
somewhere other than where it matters. A probe proves something once; a
registered check keeps proving it.

Same assertions, registered path, relative import corrected. It needs a
package.json entry to join gates:post, which is studio's to add.
2026-09-28 13:55:52 +00:00
Doxa Maps
87c562d553 Report suppressed strings per island, largest first
The deliberate bucket was one flat number, and it is the one place where
over-claiming costs nothing and shows nothing: a marker covering seventy-odd
lines and a one-line span read identically in a total. gospel went looking for
an over-broad island on their own card, suspected it, and found twenty-seven
strings behind a single attribute -- including the prose that says the figures
mean "the pools searched" rather than "nothing exists".

Each suppressed string now names the island that suppressed it, and the report
groups by island, largest first, in strings and in lines. A large island is not
automatically wrong, but it is the shape an over-broad one has, and nobody
should have to suspect it first.

It finds the one gospel measured by hand, and a larger one they had not: eighty
-four lines and twenty strings behind another single attribute in the same file.

Inheritance down the subtree is unchanged; it is correct. What changes is that
the output can now be read.
2026-09-28 13:37:44 +00:00
Doxa Maps
dbbcd5bc45 check:frozen-labels in gates:post: a label resolved once never re-resolves on a locale switch
Written by the UPG-100 plugin owner. It parses templates and scripts and flags a translator
result stored at module or setup scope, or tStatic inside a computed. A production profile or
library/ fails; an experimental profile is reported. It found the search header's eight labels,
fixed in the commit before this, so it arrives green.
2026-09-28 13:37:44 +00:00