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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
The previous commit added the script and its description but did not put it in
gates:pre, so it passed only when run by hand. That is the exact failure this
project has been naming all week, committed by the person who wrote the gate for
it. gates:pre now reads PASS 22 of 22 and names check:packet-charts at 781ms in
its own output.
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.
The gallery pins plugins with min-height:0 (sr) and gospel-internet's .gb moved its floor to :host
(on main), so no plugin asserts a floor a short pane could not hold. From here it guards against
the second scrollbar coming back.
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.
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.
describeGroup returned an empty string against the shipping packet-strings.json because none of
its keys existed, while its check passed on its own fixture strings. desc_where_* says where a
group lives and what it speaks, dropping whichever clause has no data; desc_gsec_0..6 says how
evangelical it is. Each locale's under-2% sentences reuse that locale's funnel_unreached wording,
so a packet does not define the threshold two ways on two pages. English from the UPG-100 owner.
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.
zone-regions.json was missing madhya-pradesh, uttarakhand and west-bengal. All
three are in clusters.json AND groupings.json — printable, grouped zones with no
region — so their groups reached a reader only through the vertical legend's
unassigned bucket, which is honest but wrong for a list picker.
★ THE FOUR NAMES IN THE REPORT WERE NOT ZONE IDS, and following them literally
would have created rows that match nothing. The report said guizhou, xinjiang,
bihar, madhya-pradesh; the canonical ids are fully qualified
(asia-china-guizhou-province, asia-india-madhya-pradesh, …), and the bare
`bihar` that exists is an admin-to-list.json KEY whose value IS the zone id.
Only ONE of the four (madhya-pradesh) was a zone at all; the other three are
absent from clusters.json and go to the zone-universe card as a product
question, not a data edit.
★ AND THE REPORTED SET WAS INCOMPLETE. Checking the PROPERTY — which printable
zones lack a region — rather than the four names found uttarakhand and
west-bengal by the identical two-file test. Second time today an enumeration
handed over was short: the wagf fetch was 6 reported / 8 real. regions-plugin's
gap number could never have contained these, because it is admin-to-list VALUES
minus zone-regions KEYS and none of them is an admin-to-list value — their
output now states it is a lower bound.
deaf-old-world and deaf-new-world-asia-pacific stay unfiled by design: the
"deaf": "exempt" umbrella key covers them (dx-shepherd).
Lands ATOMICALLY with regions' f3c050ae, which updates the pinned gap to
[guizhou, xinjiang, bihar] and asserts these three STAY filed — filing is as
revertible as any data edit. Verified the pair before sending: their gate
against this data exits 0.
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.
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.
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.
Both are derived files that had drifted: an import count for utils/html.js, and
regions-dashboard's exemplar is region-dashboards now, not regions-list.
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.
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.