Commit graph

2582 commits

Author SHA1 Message Date
Doxa Maps
21ebb4f264 Stop the ring legend pushing the centered-set chart off the page
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The legend was stacked below the chart in a centred column. On its own it is
taller than the pane — a 360px card with a nine-cell grid and four footer lines —
so the figure was pushed out of view entirely and the panel overflowed its host:
the legend covered the row beneath it and the caption drew over the page tabs.
The chart was not visible at all at 1440x900.

It is now a row: figure left, legend right, which is also the side placement the
legend was asked for. Every flex child takes min-height and min-width 0, because
a flex item's default minimum is its content size — without that an item never
shrinks below its natural height and the container overflows however little room
it has. That default is what turned a tall card into an unbounded one. Under
900px it returns to a column and scrolls rather than clipping.

Both charts now clear the centre tab strip, which is absolutely positioned near
the top and was covering the top of the bounded set's dots and all of the
centered set's caption. The bounded set also takes overflow:hidden, so a tall
caption can never do to it what the legend did to its neighbour.

Nothing about the counts changes. The page reads 2,082 placed where a slug-only
join predicts 2,074: the difference is the name-and-country fallback recovering
eight of the sixteen groups the neighbour table carries no slug for, and both
readings still sum to the full 2,107.
2026-09-28 17:11:13 +00:00
Doxa Maps
bf08bc4464 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: all-people-groups
big-picture
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-maps-spa
doxa-research-map
doxa-simple-map
gospel-internet
gospel-resources
key-cities
people-groups-grid
prayer-walk-planner
research-dashboard; also rebuilt: regions-dashboard my-upg-100-list. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 16:58:27 +00:00
Doxa Maps
298c71c83b The packet-variant control is translated: three button keys in the other ten locales
The Prayer List caret landed its labels in English only, so check:i18n-parity failed.
2026-09-28 16:49:31 +00:00
Doxa Maps
bf204caca7 Give the set charts and the ring legend their strings, in all eleven locales
Thirty-nine keys the panels and the ring legend already name existed in no
catalogue, so every locale rendered the English written at the call site. The
fallback is what kept it readable and therefore silent — a missing key degrades
to correct English rather than to a visible key name, which is the whole reason
nobody noticed.

The English is byte-identical to the fallback it replaces, asserted mechanically
against the components rather than by eye: translating existing wording is a
product decision, changing it is not, and a catalogue entry that quietly
reworded a call site would be the second kind wearing the first's clothes. The
ring legend's English was read out of the component for the same reason, not
copied from the message describing it.

Each script carries its own punctuation rather than English punctuation in
another alphabet: the Devanagari danda, the Chinese full-width colon and stop,
the French space before a colon, the Arabic comma and Arabic-Indic digits.

The vocabulary is the fixed part. Reached and unreached are terms of art with
settled renderings, the two chart names are Hiebert's bounded set and centered
set, and the first culture column says "same people" rather than "same culture"
because the rule behind it is the same ethne in another country — a stronger
claim than the brief's wording, and the label should not overstate or understate
what was measured. That one word is on the Driver's sheet; if he keeps his,
three cells change and nothing else.
2026-09-28 16:49:01 +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
ca5ce4acd5 check:profile-table runs in gates:pre 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
62f8f9483c Record the agreed ring vocabulary, without making it a default
The ten ring names settled between gospel and exp, derived from exp's ringFor()
rather than hand-numbered so the numbering cannot drift from the code that
produces it. The screen legend already renders these words, so a printed sheet
using them cannot disagree with the screen.

Written as vocabulary in the header a caller reads, and deliberately NOT as a
prop default. One cell is still open on the Driver's row 89 — "people" versus
the brief's "culture" in column 1 — and if he keeps his, exactly three cells
change, 1, 4 and 7, and nothing else: not the numbering, not the columns, not
the colours.

A default would bake a provisional word into every consumer that forgot to pass
one, and would make this component the thing that decided a Driver question.
Same reason ShareCalendar refuses to name its own metric.
2026-09-28 16:49:01 +00:00
Doxa Maps
663b73781d Warn the caller that groupings.label is a definition, not a name
Measured on the published file: those labels run 7 to 206 characters and the
long ones are the zone's member list, not its name. Tibet's is a ten-part list
of provinces. Only two of the fourteen carry no member list at all.

Used as a heading on a printed sheet that sets a 206-character title, and paper
has no truncation-with-tooltip to rescue it. The name a reader wants is the
humanised slug, uniform by construction and about 34 characters at worst, with
the label carried separately as the definition it actually is.

This component asserts nothing about the words in labels.rings, so the whole
hazard lives at the call site — which is why the warning belongs in the file the
caller reads rather than in a message they will not see. gospel found it.
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
05ceeb2289 check:i18n-keys-exist and check:packet-variants run in gates:pre
i18n-keys-exist is green now that the panel keys and legend.noList exist, so a key named in code
without a catalogue entry can no longer land silently. packet-variants arrived unregistered.
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
009d73b44e The share button had to look smaller; its tap target did not have to shrink with it
rega's 3c3bde71 fixed a real defect: ShareButton is the map toolbar's 40px round
control plus a 2px ring, so it measured 44px inside a 38px bar and overflowed it. At
28px + ring it matches the search box, which is what the Driver asked for.

The side effect is that the TAP TARGET went to 32px, and this repo states its own floor
twice -- DataTable.vue:294, "44px is the accessible tap target; anything smaller is a
miss on a phone", and LegendDesktop.vue:124 sizes rows to keep it. Nothing in the change
said otherwise; the target simply followed the drawing, which is what tap targets do
unless someone stops them.

A transparent box centred on the button restores it without moving a pixel: absolutely
positioned so it changes no layout, and part of the button so it takes the same clicks.
The visual stays exactly as the Driver approved.

44 WIDE, 38 TALL, AND THE 6px IS DELIBERATE. The bar is 38px, so a 44px-tall target
would hang 3px over the filters row and start taking taps meant for a filter -- one miss
traded for another. 44x38 is the largest target that costs nothing else. Closing the
last 6px needs a taller bar, and the Driver has just decided that axis in the other
direction, so it is asked rather than assumed.
2026-09-28 16:49:01 +00:00
Doxa Maps
379509c5c2 The factory stopped being a one-liner, so a whole bundle stopped being looked at
check:i18n-keys-exist reported PASS over two planted poison keys in gospel-internet,
three rounds running, and over 12 real keys its new panels name that exist in no
locale. Reproduced before changing anything: with both poisons on disk the gate found
1 missing key; it now finds 15 on that same tree — the 2 poisons plus the 12.

TWO CAUSES, EITHER ALONE ENOUGH TO HIDE EVERYTHING.

factoriesIn() demanded the exact shape `export function useX() { return useOptionalT('ns') }`.
useGiT is no longer that. It became a WRAPPER -- `const t = useOptionalT()` and a
returned arrow adding an English-fallback argument -- which is ordinary refactoring and
moved the bundle outside this gate: useGiT was not a factory, so `const T = useGiT()`
was not an alias, so every T('…') was not a call site. Discovery now also accepts an
exported function whose body BUILDS a translator, reading a namespace only when the
call states one literally.

And when a namespace could not be read, `if (ns === null) continue` dropped the binding
in silence. That is the line that turned "I cannot see this" into "there is nothing to
see", and it is why the bundle passed by having nothing looked at. Note what it is NOT:
useOptionalT('') gives ns === '', an author DECLARING no namespace, which was already
handled and counted. Empty is a decision; null was this gate's own ignorance.

Such a binding is now registered for the FULL-KEY pass anyway -- a fully dotted key is
checkable whatever the namespace is, which is what catches both poisons -- while the
BARE calls it genuinely cannot qualify are counted, listed by binding, and FAIL. The
gate states which translators it could not resolve instead of scoring them clean.

Both poisons are pinned as permanent fixtures HERE rather than left in gospel's tree,
so the proof does not depend on another agent keeping deliberately-broken code on main.
The fixture asserts the four things that were independently broken: a wrapper factory is
discovered, no namespace is guessed for one that states none, its binding still reaches
the full-key pass, and a bare key behind it is recorded rather than skipped.

Negative-tested: restoring the silent `continue`, or narrowing discovery back to
one-liners, each fails the self-test; restored, it passes.

NOT wired into gates:pre and left on demand, as it already was. It reports 13 real
missing keys on today's main -- 12 of gospel's panel keys plus a pre-existing
legend.noList -- and those are their owners' to add, not this gate's to hide.
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
e1d0bc203f Read the zone tables from the asset pool, and name zones from their slugs
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
L62 moved zone-regions.json and groupings.json into data/tables/, so the tree
copies them into the asset pool and both plugins read one file. This fetches
them rather than importing: a relative import into another plugin's folder is
refused, and a per-plugin copy is the drift the single-source move exists to
prevent.

groupings.label is a definition, not a name, and using it as one would have made
the legend unreadable. Measured on the published file, the labels run from 7 to
206 characters and the long ones are the zone's member list — Tibet's names ten
provinces and three countries. Exactly one of the fourteen is a plain name. So
the tree names a zone from its slug, which is uniform by construction, and the
label is carried separately as the definition it actually is, for somewhere with
room to say what a zone contains.

The tree is built and counted; nothing filters on it yet. Mapping a group to a
zone still runs through the older partition, which places 69 groups in the wrong
printable zone, and that is the Driver's open question.
2026-09-28 16:30:40 +00:00
Doxa Maps
48a83929f5 Rebuild the served tree after rebasing onto main
Generated output, rebuilt from the rebased source rather than merged: the
bundle first, then the hashes.
2026-09-28 16:30:40 +00:00
Doxa Maps
5e0295add8 Punctuate the set-chart counts in one place, not the reader's browser 2026-09-28 16:30:40 +00:00
Doxa Maps
04ac124cb7 Mount the ring legend, and dim the rings it is not talking about 2026-09-28 16:30:40 +00:00
Doxa Maps
f49c81cec6 Gospel × Internet: a ring legend that decodes the centered-set target, every ring named, counted and explained 2026-09-28 16:30:40 +00:00
Doxa Maps
0b81a1636d Put the cross at the centre of the bounded set 2026-09-28 16:30:39 +00:00
Doxa Maps
39cba76c75 UPG Neighbor: each row carries its DX slug, and every DX group ships, so a DX page joins by the key it holds
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 16:28:04 +00:00
Doxa Maps
a0aa4df171 Stop the dimmed pins reading as white rings, and lift the chooser map
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
rega shot the hover dim at 1440 and called the non-hovered pins pale rings on
the pastel fills. That is what they were, and the cause is a property that was
never set: circle-opacity dims the FILL only, and circle-stroke-opacity is a
separate property defaulting to 1. So a pin dimmed to 0.25 kept a fully opaque
1px white stroke, and at the dimmed radius that ring is most of the pin.

Worth saying plainly, because the obvious fix was to keep the stroke opaque
while dimming the fill: that was already the behaviour, and it was the cause
rather than the cure. The stroke now dims with the fill, so a non-hovered pin
recedes as a dot instead of becoming an outline. The fill dims less as well,
0.25 to 0.45, and the dimmed radius goes 2.5 to 3 at low zoom for the same
reason the resting state was raised earlier: a stroke is a fixed px cost, so at
radius 2.5 the coloured core carrying the region's identity is about 1.5px.
dx-life's two modes keep an opaque stroke; only the chooser's dim is ours.

The property is set in both places. A paint property added at layer creation but
not re-applied where the mode is re-applied never updates on hover, which is the
bug this file already carries a warning about.

The map below the fold, measured at 1440 with the container at 1200: the map's
top sits at about 583px and the map is 780px tall, because min-height beats what
16/9 asks for at that width. A reader sees roughly its top 190px. Tightening the
three gaps above it and dropping the outsized title one step recovers about
99px, so about 286px shows on load. It does not put the whole map above the fold
and nothing leaving the map 780px tall can; that needs the map bounded by
viewport height, which changes its rendered size and is on the Driver's sheet.
Scoped to the all-regions page and to 1200px and up, because the region and
country pages are the clone and their rhythm is measured against dx-life.

Rebuilt on current main rather than merged, so the shared manifests carry every
other bundle's current hash and only this bundle's moved: four lines, one
integrity plus the three script tags that embed it.
2026-09-28 16:24:36 +00:00
Doxa Maps
ef9cc92d20 UPG-100: imb_gsec arrives nested, so the packet lost half of every description
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The API returns imb_gsec as { value:'1', label:'…' }. It was missing from
NESTED_FIELDS, so describeGroup did Number({value:'1'}) -> NaN, the level
never matched, and the reachedness sentence was dropped for every group --
silently, because dropping a clause is the deliberate design for a missing
field. Every profile description shipped at half length.

Measured through the real normalizer and real catalogue: nested -> one
sentence, flattened -> two.
2026-09-28 16:09:12 +00:00
Doxa Maps
7939e328a1 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: cities-internet
country-posters
dashboard-plus-grid
doxa-research-map
doxa-simple-map
gospel-internet
gospel-resources
my-upg-100-list
pplr-data-maps
research-dashboard
research-map-plus-grid. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 15:59:46 +00:00
Doxa Maps
b13dcfdb4d zone-regions.json keeps the three India zones through the move to data/tables
The move was authored from a copy that predated fe54c303, so madhya-pradesh, uttarakhand and
west-bengal lost their region again and check:list-selection named all three. Restored from main.
2026-09-28 15:52:47 +00:00
Doxa Maps
b33006f028 check:composite-token-url and check:language-tree-layout run in gates:pre
Both arrived as files with no script entry, so nothing ran them. Both are headless and fast.
2026-09-28 15:52:47 +00:00
Doxa Maps
42d5895321 Author legend.noList, which existed in no catalogue at all
ListSelectorLegend renders "No list loaded" when it has no tree yet, reading
legend.noList. That key was never authored, so every locale fell through to the
English fallback: eleven languages showing English in the one place the legend
admits it has nothing to show.

Added to all eleven catalogues. The string is a full sentence rather than a
label, so it keeps its terminal punctuation in each script, including the Hindi
danda and the Chinese full-width stop.

Proved in both directions rather than only after: check:i18n-keys-exist FAILS
with exactly one key when the catalogues are stashed away, and PASSES with them,
and check:i18n-parity reports no locale regressed and no translation debt.

My defect, from my own component, found by exp.
2026-09-28 15:52:47 +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
722ac77892 Join the set charts on a key both sides actually carry
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Both set charts reported "not drawn: 2,104". The join read g.country, and on
these rows country is the country's NAME while the neighbour table keys on ISO
3166 alpha-3, so the pair never matched — and because the rop3 half is absent
from these rows too, nothing matched at all. Measured against the shipped tables
with real names and real codes: the name path resolves 0 of 12,379 rows as
written, and 6,850 once the ISO code is used.

It survived my own verification because I measured the join with the province
table standing in for the dashboard's rows, and those rows carry iso3 rather
than country. The stand-in satisfied the key I had chosen, so the measurement
confirmed the code against a shape the real caller does not have. A proxy has to
match the caller in the fields the code reads, or it is only testing itself.

Three keys now, strongest first. Slug is the only one that is a single field on
both sides and unique by construction, since the table's build refuses to emit
two rows sharing one; rop3 with the ISO code is a compound key these rows may
not carry; name with the ISO code depends on two sources spelling a people the
same way, so it stays the last resort rather than the first guess. A row
carrying only a name is still better joined than dropped.

The slug column is not in the table yet. Each key is indexed only when its field
is present, so today the slug map is empty and every lookup falls through —
verified against the shipped table: 0 slug keys, no entry keyed on a missing
value, and a slug lookup returns undefined rather than colliding. That makes
this safe to land before or after the column arrives.
2026-09-28 15:43:47 +00:00
Doxa Maps
bfd1b78b4e Rudder is thin-client by default; the robot voice is an experimental opt-in (High performance mode)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Rudder is built for a thin client: by default the whole budget goes to speech-to-text and nothing of the
robot voice runs or even loads.

- Settings → Robot voice is marked "experimental". Ticking it first shows a warning (in the page, not a
  browser dialog the desktop webview may block): it runs an extra voice engine, uses more CPU and ~150 MB
  while it speaks, and can slow voice-to-text on a smaller laptop or shared dev server. "Turn on anyway"
  switches to High performance mode; the settings show the current mode.
- Engine: POST /api/rudder/mode {robot} switches thin-client ↔ high performance, and refuses the robot when
  the machine has under ~500 MB available (/proc/meminfo) — a voice tool must never crash someone's laptop.
  /api/rudder/tts refuses in thin-client mode, so an old saved setting can't start the robot either. In
  thin-client mode Piper would yield the CPU; in high performance mode it runs at normal priority.
- The voice list loads only when the robot voice is turned on; a robot voice left on re-enters high
  performance mode on the next visit (the engine re-checks memory).
- Verified on the engine: starts thin-client; speaking refused (409); on → high-performance; speaks; off →
  thin-client; speaking refused again.
2026-09-28 15:35:51 +00:00
Doxa Maps
2bff270734 Rudder keeps streaming on a thin client: reconnect a broken speech stream, say when it falls behind
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Optimising the default for an average laptop, not more CPU (the speech model is hard-wired to 4 threads in
NeMo-Speech.cpp, session.cpp:911 — already a thin-client budget).

- RECONNECT: a live recording ended with "speech server connection lost: … broken pipe" when the stream to
  the speech server broke. Now the engine opens a new stream and keeps going; the words still in flight on
  the old one are finalised so the box keeps them; up to 5 attempts, then a plain message. Verified against
  a fake speech server that drops the first stream after 3 s: 6 words finalised, reconnected in 2 ms, 14 more
  words streamed, clean Stop, 0 data races (go -race).
- FALLING BEHIND: when the mic clearly carries speech but no words have come back for 10 s (the server is
  starved of CPU — the stream used to freeze with no message), say so once; it clears when words resume.
  Verified with a fake server that returns no words: warned after 10 s.
- The robot voice (Piper) runs at nice 10 on Linux, so its ~1 s burst per sentence never makes the words
  you are speaking fall behind.
2026-09-28 15:31:19 +00:00
Doxa Maps
3c3bde71e4 Big Picture: the share button fits its 38px header instead of overflowing it
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The Driver: the share button at the top right of the Big Picture dashboard is too large. Measured: the
research-dashboard topbar is 38px tall, but ShareButton is the map toolbar's 40px round button (36px under
768px) plus a 2px rainbow ring, so 44px, taller than the bar it sits in, and clipped top and bottom.

In this bar only (.rd-topbar), the button is 28px and its icon 15px: with the ring it is 32px, the height of the
bar's own search box, centred with 5px above and below. Measured after, at 1440 and 390, on the Big Picture
(dashboard-plus-grid) and research-dashboard leaves: the bar stays 38px; the title, search box and Align button
keep their sizes. Map toolbars keep their 40px share button. Rebuilt research-dashboard + dashboard-plus-grid,
tree regenerated.
2026-09-28 15:27:03 +00:00
Doxa Maps
246b3d46e1 Rebuild country-posters, and regenerate the tree
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-09-28 15:18:35 +00:00
Doxa Maps
736c418284 Country Posters: Export poster is a toolbar button every reader sees, not a drawer item behind a hidden opener 2026-09-28 15:18:17 +00:00
Doxa Maps
2b164954e4 Build the region-to-zone tree the legend renders, with region ids namespaced
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Pure, and it takes the zone tables as arguments rather than importing them: they
belong to another plugin and a relative import into its folder is refused. They
reach the browser as a published table instead, so the caller fetches and passes
them in. That also makes this testable without fixture files.

Region ids carry a prefix, and that is load-bearing rather than tidy. The legend
emits one bare string on one channel — a leaf emits its zone id, a region row
emits the region's — and the two id spaces overlap: of 33 zone ids and 7 region
ids the intersection is exactly "europe", whose region is itself. Un-namespaced,
that string cannot say whether it means the region or the leaf. It is invisible
only because region europe currently holds exactly one zone, which is a fact
about today's data rather than a property of the design; the day a second
European zone is filed, clicking the region would filter to one zone instead of
several with no error. The component needs no change — it emits whatever id the
tree gives it.

There is deliberately no "select all" case. Zones absent from the region table
have no region, so no expansion over regions can reach them; a sweep has to
enumerate zones, and offering a region-based all would quietly return a subset.
2026-09-28 15:13:45 +00:00
Doxa Maps
fe54c303e0 Rebuild the consumers main left stale (build owner, README rule 5)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
check:composite-freshness found stale on main: big-picture
country-posters
dashboard-plus-grid
doxa-maps-spa
doxa-research-map
key-cities
my-upg-100-list
regions-dashboard
research-dashboard. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-28 15:08:43 +00:00
Doxa Maps
0bf8b88ff0 Regenerate the library index: the three packet charts and the items that landed uncatalogued 2026-09-28 15:01:26 +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
a764a89e3b packet fixtures carry the description's two fields
The four packet fixtures predate primary_language_label and imb_gsec, which the
packet fetch has carried since L59. describeGroup degrades by DROPPING CLAUSES
rather than blanking — sr's deliberate and correct design — so on fixture data
it renders "The X live in Y." with no language and no reachedness sentence, and
does so without error.

That makes the OFFLINE render (PACKET_RECORDS=fixtures/…, which is how rega
verified NEEDS-EYES row 83) diverge from the live browser packet in exactly the
new column: the verification path would pass on something the product does not
print. Same Node-vs-browser divergence as the %evangelical page, pointing the
other way.

Regenerated from the live API (2,107 rows), matched by slug: 21/21, 20/20, 1/1,
43/43. Row counts and order are unchanged, so the 20/21/1/43 edge cases keep
their meaning and check-packet-render's `ceil(cards / PER_PAGE)` arithmetic is
untouched.

★ STORED POST-normalizeRecords, NOT AS THE API RETURNS IT. Both fields arrive
nested — primary_language is { value:'abf', label:'Abai Sungai' } and imb_gsec
is { value:'1', label:'Less than 2% Evangelical…' }. A fixture holding the raw
shape would have faithfully reproduced the bug that 3b644893 fixes, so the
fixture would look right and the packet still print half a description.

HONEST LIMIT: the DOXA set is 225 at GSEC 0, 1,852 at 1, 23 at 2, 4 at 4, 1 at
6, 2 null — so fixtures drawn from real data exercise levels 0 and 1 only, and
levels 3 and 5 do not occur at all. Per-level coverage belongs in the unit test
with synthetic rows, which is where it already is; the fixtures' job is to match
what the fetch delivers.
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