- repo/auto-signin: carried by repo/handoff's 6df2da9c9 (merged above), replayed with its entry.sh conflict against V2 and DEV_SERVER resolved; UNVERIFIED, default off.
- repo/plugin-designer-skill: on development with edits: cde106a55 landed as f66650462, and the other four are in skills/plugin-designer/SKILL.md sections 2, 5, 8 and 9-10 in newer wording.
- repo/gospel-number-revert: abandoned. The numerals hold became the LOC-ENUS warn in check:universal-props, pending the numerals decision, and gospel has since rewritten the files it touches.
- repo/land-shots, repo/land-shots4: superseded by later README screenshot retakes on development (6e5c0874d, 09-27 09:58Z, and after).
check:composite-freshness found stale on main: none. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
With it on, oauth2-proxy skips its button page. For a user already signed in to the provider the sign-in is
then redirects only, and the provider's no-framing headers never apply, so a framed Studio signs itself in.
UNVERIFIED: never run live in a framed dashboard (written 2026-09-28 04:32Z, not landed until the 14:38Z merge-all order).
Checked only statically: bash -n passes, and the argument switch yields --skip-provider-button for 1 and nothing for unset or 0 under set -u.
Replayed onto development: AUTO is now defined inside the OIDC gate block (ACCESS != public), next to the ALLOW list, and passed before --upstream="$ROOT".
Default off, so no running container changes.
"Not yet" still listed the desktop build with the engine as a Tauri sidecar.
On Linux it builds and runs; what is still not done is macOS and Windows
(the engine cross-compiles for both and has never run there).
Output 3 showed only the map app as a desktop app. Rudder is the desktop
example the README describes, and it has now been built with
`bun run desktop:build rudder-voice` and run on Linux: a screenshot of the
running app goes under the map app's, with one line on how to build it.
The PNG carries no text chunks (the original recorded its creation time
and timezone); the pixels are unchanged.
HELD since 2026-09-29 01:32Z for a condition from the Driver whose message was cut off ("before you add it to the readme you nee"). It is merged now on the 14:38Z merge-all order. Drop this commit if that condition is not met.
verify-browser-all's rule is that every gate listed there was run before it was
listed, so this waited for a real run rather than being registered on the
strength of the code reading correctly.
Measured, on the browser seat: the positive arm PASSES 9/9, and the mutation arm
-- the maplibre selector inside bottomControlInset broken to a name that matches
nothing -- FAILS on cases 1 and 5, the two whose pages carry only a maplibre
attribution. Cases 2, 3, 6 and 7 pass under the mutation by design: they assert
the inset is 0, so a selector that stops matching cannot make them fail. They
guard the opposite error, reserving space that is not there.
Registered in all three places: the script, its //scripts description, and
verify-browser-all's list. Deliberately NOT in gates:pre -- every assertion rests
on getBoundingClientRect, so it needs a browser and exits 75 NOT MEASURED
without a slot, which is never a pass.
check:popup-pan-controls proves the ARITHMETIC: given a band height, the pan
clears it. Nothing proves the band height is real. If bottomControlInset returns
0 -- a selector typo, an engine renaming its control classes, a control moving
outside the container -- then panPlanFor is handed bottomInset 0, behaves exactly
as it did before the fix, and check:popup-pan-controls still passes every case
because its arithmetic is still correct. The bug would be back with both gates
green. That is the failure this guards.
Seven cases against a real DOM, running the SHIPPED bottomControlInset read out
of the SFC (it is not exported), never a re-typed copy: a visible attribution
reserves a band EQUAL to what the DOM independently says, not merely non-zero,
which a stub returning 1 would satisfy; no controls reserves exactly 0; a
zero-sized control reserves nothing, since counting it would reserve the whole
map and refuse every popup; the taller of two corners wins, because the pin hint
is bottom-left while the attribution is bottom-right; a wrapped attribution
reserves more than one line, which is what a hard-coded constant gets wrong at
390; a control outside the container is ignored; a null container returns 0
rather than throwing.
It needs a real browser: every assertion rests on getBoundingClientRect, and no
DOM library here computes layout, so a headless stand-in would return zeros and
pass vacuously.
NOT REGISTERED. Browsers are rega's seat, so this has not been run, and
verify-browser-all's rule is that every gate listed there was run before it was
listed. It is registered only after rega's PASS.
Its population was a hand list of 19 names, and it had drifted: the PRODUCTION country-map was never on
it, so its LIST-FETCH (the header's list prop never applied) passed unseen, and big-picture fetched the
list correctly with nothing to catch a regression. The population is now every plugin-designs/<b>/
with an index.js, minus an EXCLUDED map with a reason per entry (empty today), so a new bundle is
measured the day it lands: 28 bundles instead of 19.
Two fail-open paths now fail. `--only <name>` naming no measured bundle printed "PASS: 0 bundles"; it
exits 2 and lists what is measured. A listed bundle whose folder moved was skipped by an existsSync;
the population is derived from the folders, and an EXCLUDED entry naming no bundle is a stale decision
and also exits 2.
Measured over the whole tree, only two bypasses surfaced. country-map's LIST-FETCH is fixed by regions'
commit just before this one (same round, as it must be: this alone goes red). demo-map's LIST-DSM gets a
narrow ALLOW with its reason: it is scaffolding that demonstrates the raw DataSourceManager path, off
both reader shelves, and its other props are still measured. PASS: 28 bundles, 9 self-tested rules.
country-map.vue fetched /api/people-groups/list and mapped the posts straight to
points, without applyPeopleGroupList(). Every other consumer of that endpoint
calls it -- DataSourceManager, useDashboardData, useCsvExport, peopleGroupRecords
and the people-groups grid. So a host that asked for one curated list got the
WHOLE endpoint drawn on its country map, and nothing said so: the pins looked
like data rather than like the wrong list.
The list is applied to the raw posts, before they become points, which is where
every other consumer applies it. That is what keeps this element and a dashboard
showing the same country from disagreeing about which groups are in it -- the
same reason the country filter beside it is client-side.
The default list returns the rows untouched and makes no fetch, so an
unconfigured host pays nothing. A list whose membership cannot be loaded yields
NO groups rather than the wrong ones: applyPeopleGroupList's own contract, and
the safer failure for a prayer surface.
Measured three ways, with a temporary copy of check:universal-props that adds
country-map to its BUNDLES list (the reason the gate never saw this: 19 bundles
listed, this one absent):
- RED on development: BYPASS country-map LIST-FETCH country-map.vue:98, exit 1;
- GREEN here: 20 bundles read engine, token, locale and list through the
library, exit 0, the gate's 9 planted-bypass self-tests still caught;
- a real list value changes the drawn count, through the shipped
applyPeopleGroupList and the served membership files: Nepal draws 34 points
on the default list, 29 on "frontier", 0 on "frontier-500k".
Rebuilt, tree regenerated, SRI refreshed. The list-membership URL is present in
the built bundle; the manifest diff is integrity hashes only, 0 other lines.
The file walk read internal/ only, and the chain arm counts package.json NAMES, so a plugin-local
check-*.mjs that nobody registered was invisible to both: on 2026-09-29 six were run by nothing
(dx-shepherd's census; exp's four have since been registered). Every tracked gate-shaped file under
plugin-designs/ or library/ must now be run by PATH from a script or another file's code, carry
@one-shot, or sit in the shrink-only `files` baseline for its owner.
Two rules keep it honest. Comments are stripped before searching, since a file mentioned in prose is
not run by it. And a basename counts only where no other gate file shares it:
library/scripts/research/verify-map-paint.mjs (199 lines, run by nothing) shares its name with
internal/verify-map-paint.mjs (342 lines, what verify:map-paint runs), so a name match let one file's
registration vouch for the other. Self-tested on planted cases (unregistered, comment-only, shared
basename). On the real tree it finds 4, now baselined for their owners:
library/scripts/research/verify-map-paint.mjs likely a stale copy
plugin-designs/doxa-research-map/scripts/check-reduced-motion-jumps.mjs
plugin-designs/gospel-resources/scripts/verify-translator-org-links.mjs a data tool? @one-shot or register
plugin-designs/research-map-plus-grid/scripts/check-ui-strings-have-keys.mjs
The chain arm's writer now keeps every key of the shared baseline file.
The row meta joined its first three languages with English commas and cut the rest silently, so a truncated list read as complete. It now goes through formatList (Intl.ListFormat) as a conjunction whose LAST item states the cut, from keyCities.list.moreLanguages ("{n} more", landed in R29): "Hindi, Urdu, Bengali, and 2 more" / "Hindi وUrdu وBengali و2 أخرى" / "Hindi、Urdu、Bengali和另外2种". Three or fewer languages show as they are. Not 'unit': in zh it joins names with no separator, and in ar and hi it still adds a conjunction (studio, measured).
join(', ') is English list grammar. The two user-facing sites now go through the library's formatList (Intl.ListFormat, R29), as conjunctions because each is the FULL list:
- PPLR's Rosetta popup, "Not yet cross-mapped in PPLR: {ids}";
- UPG Neighbor's bridge-language cards, "Believers in: {list}".
Key Cities' shortened language list follows once its "{n} more" key (keyCities.list.moreLanguages) is on development. The joins that stay are not reader lists: a thrown error's details, the bridge-languages CSV columns, and a gate's console line.
Two reader-visible lists were joined with a hardcoded ', ': the ADM1 names
in the selection error ('no people-group data for {names}') and the
selected territories in the batch HUD. Both use formatList now (R29), so
de reads 'Gujarat, Rajasthan und Odisha', zh 'Gujarat、Rajasthan和Odisha',
ar 'Gujarat وRajasthan وOdisha'. The locale is passed from the i18n ref
(packetLang), not left to getRuntimeLocale, so the HUD's list re-renders
on a runtime language switch. formatClipboard's join stays: its only
caller is a test handle.
dashboard-plus-grid.vue imports research-dashboard.vue directly, so the list
formatter reaches a second bundle through that import and the composite was
left holding a dashboard that no longer matched its source.
+362 bytes, the same delta as the dashboard's own bundle -- which is what
identifies this as the same change arriving by a shared import rather than
unrelated drift. The formatter appears once in these bytes too, with the two
list types at the counts the source asks for.
Found by the landing precondition, not by inspection: it reported the composite
stale on a commit that named none of its files. The first reading of that was
that it could not be mine. It is: a composite that imports a plugin's component
is downstream of every change to it, and a range that touches no file of the
composite can still be the reason it is stale.
The list formatter is a library function the bundle had no reference to before,
so the source change does not reach a reader until the bundle carries it.
+362 bytes. Confirmed in the built output rather than inferred from the delta:
Intl.ListFormat appears exactly once, which is the single formatter
implementation and not a copy per call site, and the two list types appear at
the counts the source asks for. A byte delta alone cannot tell a shipped
conversion from a duplicated helper; counting the shipped strings can.
The tree is regenerated, not only re-hashed. fix:sri rewrote the integrity in
each leaf page and in the manifest's embed snippet, but NOT in the Mapbox
variant of that snippet beside it, which the tree generates and fix:sri does
not touch. The landing precondition refused the commit for exactly that: a
manifest that regenerating the tree would change. After the regeneration the
current bundle hash appears in all three places it belongs, and check:sri
passes across all 65 leaves.
Three places joined names with ", ", which is a list only in English. Arabic
separates with a word, Chinese uses a different mark entirely and no comma at
any position, and German and French put a word before the last item. The
separator and the final connector are the grammar, so no catalogue key can fix
them from outside.
Two of the three carried a second, worse error. A multi-value filter holds
ALTERNATIVES -- the bus matches a row if ANY of its values matches, in
useSelectionBus with some() and in the data table with includes() -- so the
chip means "Islam or Hinduism". Joined with a comma it reads as "and" in every
language with an explicit connector, which states the opposite of what the
filter does. Those two become a disjunction.
The third is the screen-reader live region, which names the facets currently
applied. Those hold together and narrow the set in turn, so it is a
conjunction. It is also the one string here that some readers rely on entirely,
having no chips to glance at.
The composable is the single owner: every surface that turns a filter into
words comes through it, so the chip bar, the research-filter chips and the CSV
provenance line now say the same thing in the same language.
Left alone deliberately: five joins in this plugin's gate scripts. Those are
failure messages read in a terminal by whoever runs the gate, not text shown to
a reader, and formatting them per locale would help nobody.
Source only. This needs the bundle rebuilt before it ships.
Regenerated on this base rather than carried over: BUNDLE=<name> bun x vite
build for each, then bun run tree and fix:sri.
Three integrity hashes move, one per bundle. The MapLibre and Mapbox CDN pins
are byte-identical on both sides, and the only bundle paths in any changed
manifest line are ./cities-internet/, ./gospel-internet/ and ./gospel-resources/.
Verified in the BUILT output, not only in source: the shipped
cities-internet.js carries position:relative on :host, which is the declaration
the partner-page overlap depends on.
formatList (R29) replaces join(', ') at the two sites that a reader sees. The
separator was never a missing key — no key can fix punctuation between
interpolated values — which is why both survived every i18n gate.
connectivity-map.vue:365, CONJUNCTION. The Indian states with no boundary are
ALL missing one. The names land inside a translated sentence at :595, and the ar
message reads "لا توجد بعد حدود ولاية لـ {names}: …", so English commas were
being printed inside Arabic prose.
GiCombos.vue:49, DISJUNCTION, decided from the matcher and not from what reads
better: useSelectionBus.js:299 tests cur.includes(value) and toggleFilterValue
adds to the list, so two values on one facet WIDEN the result set. "Africa and
Asia" would state the opposite of what the filter does. This changes the English
too — "Africa, Asia" becomes "Africa or Asia" — which is a gain, because a comma
silently reads as ALL.
Measured, the same three names and two facet values through each type:
en "Ladakh, Puducherry, and Chandigarh" "Africa or Asia"
ar "Ladakh وPuducherry وChandigarh" "Africa أو Asia"
zh "Ladakh、Puducherry和Chandigarh" "Africa或Asia"
was "Ladakh, Puducherry, Chandigarh" "Africa, Asia"
ar joins with و and no comma at all; zh uses 、. Neither is reachable by keying.
Not converted, and deliberately: useBoard.js:126 is a console.warn for
developers, and rudder-voice.vue:971 is in a component with zero i18n imports and
no catalogue area — English by construction, so it converts with the rest of
Rudder if Rudder is ever localized.
Source only. The rebuilds are regenerated after this rebases onto landed R29.
73bc270bc gave :host a height and stopped the ELEMENT overflowing. It did not
stop the MAP, because it never made the host a containing block.
.gm is position:absolute;inset:0, which resolves against the nearest POSITIONED
ancestor. :host had display:block and a height but no position, so on a partner
page .gm resolved against whatever the PARTNER had positioned and the map sized
itself to that. rega measured it in the hostbox run on ca07fc3c8: the element
correct at 420px inside a 420px box, the canvas inside it still 900px, and the
"Tap a coloured dot" pill and the attribution painting 478px below the box —
538px in a 360px box — over the partner's own text, whose top sits at y=464/404.
position:relative on :host is the whole fix, and it is the house pattern:
prayer-walk-planner.vue:628 carries the identical shape with an absolute child.
Worth recording, because the first fix passed every check: a host box that is
the right size proves nothing about what is drawn inside it. Only a partner-page
shot with content BELOW the box can see this, which is why rega's hostbox run
found it and my source reading did not.
In ACCESS=public mode the guard refused a gated bundle's source and its /__leaf/ pages, but not
/__plugin-gallery/registry.json. That file is the raw discovery list for the smoke tools, and it still
names key-cities, its tag and its two profiles to any visitor. No data, but a gated plugin must not reach a
public surface. The Studio reads only manifest.json, which already leaves gated bundles out, so the
registry is now refused in public mode as well. Dev mode is unchanged, and the smoke tools that read the
registry run against a dev server.
Measured with the cloud config run locally:
- before: public mode served registry.json (200) naming key-cities;
- after: public mode answers 404, including with a query string and a percent-encoded name;
- manifest.json and / still answer 200 in public mode, and dev mode still serves registry.json.
check:composite-freshness found stale on main: none. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
ApproxPinNote moved from useOptionalT to useOptionalI18n (which wraps it) so its formatList names follow the sentence's own locale. The per-site register still asserted the old helper and failed. The row now names useOptionalI18n, as SemanticTreeLegend, PosterWall and all-people-groups already do, and still asserts the site passes its own APPROX_FALLBACK.
The popup auto-pan had no gate. Its founding bug was invisible to the obvious
one: measured at 390 on a 366x732 map, the pan fitted the popup inside the
container, the cut really was zero, and a real click on Full Profile still did
nothing, because the top element at the button's centre was
div.maplibregl-ctrl-attrib-inner on one pin and span.pin-hint-pill on the other.
Two pins that looked fixed and were not.
So this asserts the property that matters -- where the popup ENDS UP relative to
the band that takes the click -- rather than whether it was clipped.
It runs the SHIPPED panPlanFor, read out of the SFC with new Function because it
is not exported, the same way check:optional-t-equivalence reaches useOptionalT.
A re-typed copy of the arithmetic would pass forever while the component drifted,
which is the one failure a geometry gate must not have. POPUP_OFFSET is read from
the source too rather than assumed.
Each case states the resulting geometry independently instead of re-deriving the
expected number from the same formula: a self-consistent oracle cannot see an
error that shifts both sides, which is how a 12px offset hid here once already.
The band is measured at runtime, never hard-coded, so the rule is swept across
0-120px rather than pinned to one number -- 120 is far beyond anything observed
and is there to prove the rule, not the value. Also swept across every width the
component renders at, since below 768px the container is 2x width.
Mutation-tested three ways, each failing the case written for it: ignoring
bottomInset (the pre-fix behaviour) fails 15 cases; reserving only half the band
fails; dropping the Math.max clamp fails exactly the negative-inset case.
24 cases, registered in all three places, verified by re-reading the parsed file.
Clearing the clip is not the same as being clickable. The attribution and the
pin hint are painted over the canvas and take pointer events, so a popup that
ends fully inside the frame can still have its buttons underneath them.
Measured at 390 on a 366x732 map: the pan cleared the clip exactly (cut 0) and
a real click on Full Profile still did nothing, because at y~701 the top element
at that point was div.maplibregl-ctrl-attrib-inner; on the sibling pin it was
span.pin-hint-pill. Both pins panned, both looked fixed, neither was.
Neither the popup nor the control is at fault. Attribution is a licence term of
the basemap, so raising the popup over it is not available to us. The usable
frame is simply shorter than the container, and the fit test has to say so.
panPlanFor now takes a bottomInset and fits against containerH - bottomInset.
On the measured case the plan changes from -77 (pan up, popup below the pin,
buttons under the attribution) to +119 (pan down, popup above the pin, clear of
the band) -- the planner flips direction because with the band reserved, fitting
above became the smaller move. That is the existing "pick the smaller move" rule
doing its job on a corrected frame, not a new rule.
The inset is MEASURED from the live controls rather than assumed. A constant
would be wrong on the next control we add, at a width where the attribution
wraps to two lines, or when the gallery's clearance transform lifts the band.
Only controls that intercept pointer events are measured: the corner container
is pointer-events:none and spans nearly the full map width at 390, so measuring
the container would over-reserve the whole strip.
Unchanged where it was already right: a pin whose anchor fits still returns 0
(rega measured pan 0 on those in both arms), a popup taller than the usable
frame still refuses rather than jumping the camera, and bottomInset 0 is
byte-identical to the previous behaviour.
7 probe cases, with the post-pan fit asserted from the geometry rather than by
re-calling the planner -- a self-consistent oracle cannot see an error that
shifts both sides.
check:wall-wysiwyg (0.9 s), check:wall-print-watch (0.7 s), check:wall-no-bg-photos (13 s) and check:template-i18n (9.7 s), each run once on this round's tip before chaining (all PASS) and each two-sided on its known-bad (dx-base poisoned all four independently). Placement read, not assumed: the two Vite runs use middlewareMode and bind no port, and wall-no-bg-photos serves a committed fixture with fetch stubbed, so no answer depends on the network, the machine or the moment. The orphan arm now counts 219 of 330 reached.
With no resolver configured, the kit chip bar joined a multi-value filter with ', ', so "Africa,
Asia" read as ALL to most people and carried English commas in every locale. The selection bus
matches several values on one facet with includes() (useSelectionBus isFilterActive), so adding a
value WIDENS the result: they are alternatives. The chip now uses formatList(value, { type:
'disjunction' }): "Africa or Asia", "Africa أو Asia", "Africa或Asia". gospel traced the matcher
and made the call as the only consumer's owner (gospel-internet's GiCombos). The English on this
chip changes from a comma to "or".
check:legend-mirrors lifts this exact expression and drives it with no resolver. It now lifts
formatList from library/i18n the same way (the shipped grammar, not a copy) and expects
"Islam or Hinduism". Poisoned with the old join it fails; restored it passes. formatList's doc now
says to choose the type by what the list MEANS, and never 'unit' for names.
PocketBase's link is /_/#/pbinstall/<token> (two l's). The forward rule matched \bpbinstal\b, which cannot match 'pbinstall', so the terminal showed 'Launch the URL below' with no URL below it (repo, testing live with PocketBase 0.40.4). The rule is now pbinstall?, tested on the line as PocketBase prints it rather than on a guessed spelling: the instruction, the URL and the CLI alternative are shown, the banner and dashboard URL stay quiet.
check:approx-pin-note server-renders ApproxPinNote in Node, and it crashed: with no locale argument
and no runtime locale, formatList fell through to detectLocale(), which reads `document`. The page
lookup is now a guarded last resort. ApproxPinNote also passes its sentence's own locale (the
host's vue-i18n when installed, else '' so the runtime/page chain applies), so a host with its own
i18n can never get an Arabic sentence with a list in another language.
The gate now expects the new English ("Koraga and Maila", the list grammar this change exists for)
and adds the Arabic case: two names joined with و, never an English comma inside Arabic prose.
Poisoned by putting join(', ') back, both of those cases fail; restored, it passes.
ApproxPinNote built pins.sharedPoint's {names} with join(', '), so the Arabic sentence carried English commas between the group names: the case gospel measured. It now uses formatList, which joins by the locale's own grammar (ar 'A وB وC', zh 'A、B和C', en 'A, B, and C').
With the backend's output going to .pb/logs, PocketBase's one-time 'create your first superuser' link and its command-line alternative went only to the log, so the HOWTO's admin screen at /api/pb/_/ was unusable on a fresh .pb without knowing to look there (repo found it testing the change live). Those lines are a step a person must take, so they are forwarded like errors and 'address already in use'. The banner and URLs stay quiet. Tested against the regex read from the source.
join(', ') builds "A, B, C" for every reader. Arabic joins with و and no comma, Chinese with 、,
German and Russian with their own final conjunction, and no catalogue key can fix it, because the
separator and conjunction ARE the grammar. gospel measured it inside a fully keyed sentence
(pins.sharedPoint in Arabic still carried English commas) and counted 31 such joins across library
and plugins, with no Intl.ListFormat and no helper anywhere in the tree.
library/i18n now exports formatList(items, { type, style, locale }), backed by Intl.ListFormat.
It resolves the locale the way tStatic does (explicit argument, then the host's runtime locale,
then the page's), caches one formatter per locale and options, and falls back to ', ' only where
Intl.ListFormat does not exist. Proven from the shipped source, not a copy: en "A, B, and C",
ar "A وB وC", zh "A、B和C", de "A, B und C", disjunction "A or B", one item, none, and the runtime
locale when no argument is given. The call sites stay with their owners to migrate.
PocketBase prints about 230 lines of startup SQL, and the dev plugin forwarded every line to the
terminal, so Vite's "Local:" address scrolled out of view right after it appeared. The fourth fresh
reader of the README saw it happen.
Each dev backend's output now goes whole to .pb/logs/<name>.log (gitignored), and the terminal gets
only the lines a person must act on: errors, warnings, panics, and "address already in use", the
line the README and HOWTO tell a reader to watch for before setting DX_PB_PORT. The started line
names the log file. Tested against representative lines (in use, WARN, panic shown; SQL and the
banner quiet). The live check rides the next fresh-reader run.
The gate asserts the regions idle paint has one source. It could not be chained
while only one of the two maps had adopted the library export, because it was
legitimately red on the other plugin owner's unlanded work, and chaining a red
gate holds the shared run for everyone.
Both halves are now in: this map took the export, and the other adopted it too,
so the gate reports all ten assertions green and can run with the rest.
This replaces the on-demand declaration rather than following it. Declaring a
gate as run-by-hand is a decision that costs a reader something to understand,
and it is only worth making while it is true -- the second half landed sooner
than the declaration assumed, so the honest end state is a chained gate and no
entry at all, not an entry added in one commit and deleted in the next.
Verified on the combined tree: the gate passes, the orphan guard passes with it
counted among the chained scripts rather than the declared ones, and poisoning
the gate still fails it.
useLegendMapBridge.js now exports regionIdle (sr, pair 1). The local
function is deleted and the name imported beside the bridge's other
exports; all 15 calls keep their values, the dynamic regionIdle(v)
included. Built bundle: the idle expression occurs exactly once before
and after (the local copy before, the library's after), 1 byte apart;
the country-posters composite still mounts. check:region-paint-single-
source PASS. Also drops a comment left behind when clearCountryHighlight
moved to the same library module.
useLegendMapBridge took the idle paint helper as an injected dependency and
called it five times, while each consuming map defined its own copy of the
one-line body and handed it over. The library owned the paint logic and not the
expression it painted with, so the copies could drift apart silently and the
two maps would render the same idle state differently.
Export it from the composable instead, and take it from there in this map. The
injected parameter is deliberately KEPT for one round: inside the composable
the parameter shadows the module export, so what the bridge paints with is
still exactly what its callers pass, and this change cannot move behaviour. It
is a deletion in the plugin and an addition in the library, nothing more. The
parameter goes when the second map has adopted the export too.
This map held a THIRD copy under a different name -- a function-scoped helper
in the paint prewarm, byte-identical body, used once. It now refers to the
library helper.
Verified rather than assumed: the library version and both original plugin
versions produce identical output for every value the maps actually pass,
including the dynamic one. check:region-paint-single-source is green for this
map and stays red for the other until it adopts the export, which is the state
the gate is meant to report.
The second map is the other plugin's file and is not touched here; its owner
takes that half.
Two production maps paint the doxa-regions layers directly -- 35
setPaintProperty('regions-fill'|'regions-border') calls between them, 10 in one
file and 25 in the other. Twenty-one pass their value through a one-line helper
that wraps it in the same ['case', ['has','iso_3166_1_alpha_3'], v, v]
expression, and each map defined its OWN copy of that helper while already
passing it into the library composable that paints with it. The library owned
the paint logic and not the expression, so the two copies could be edited apart
and the same "idle" would render differently on the two maps, with nothing
failing. No existing check covers this: the nearest one reads a different
component entirely.
The gate asserts the idle paint is single-sourced: the library exports the
helper, neither map keeps a local definition, and neither inlines the
expression. It deliberately does NOT require all 35 writes to route through the
library. The raw multi-line writes are highlight states and outline resets that
differ by design and by map; demanding uniformity there would make this red on
the day it was written, which is how a gate earns being ignored. It also does
not assert how OFTEN each map repaints -- one repaints the idle state more
than the other and has a dynamic call the other lacks. That is a real
difference in when, and flattening it is how a map goes blank. This checks that
the maps agree on what idle looks like, never on how often they say it.
Two-sided. Poisoning it un-exports the library helper and re-inlines a local
copy in a consumer; three assertions flip from ok to FAIL, each catching its
own planted regression, and the run exits 1.
Registered but NOT chained yet: it is legitimately red until the second map
adopts the library helper, and chaining a red gate would break the shared run
for everyone else. It joins the chain with that change.
Building it found a third copy nobody had counted, under a different name, and
two bugs in the gate's own first draft: a block-comment stripper that ate the
declarations it was meant to find (so every single-source assertion passed
while both files plainly defined the helper), and an exemption for lines
mentioning the helper, which hid a one-line definition that mentions it. Both
were caught by the rule that a gate must fail when poisoned.
Until now these ran from _qa-cycle, so they proved a landing once and guarded nothing after it. Each now lives with its plugin, locates the repo from its own path, and fails on its known-bad:
- wall-of-people-groups/scripts/check-wall-wysiwyg.mjs: the screen poster and the print are one layout on every preset (sheets, 2,107 card rects to 0.002 in, name size and wrap, reference size, chrome blocks). Planted: a card moved 0.01 in, a name a point larger, the footer missing on screen. About 0.6 s.
- wall-of-people-groups/scripts/check-wall-print-watch.mjs: the HD print counts its photos while they load, counts the ones that could not load, finishes at load or the 10-minute ceiling, and ends when its frame is gone. Planted copies of printWatch.js now go to the OS temp folder, never into the tree. About 0.3 s.
- wall-of-people-groups/scripts/check-wall-no-bg-photos.mjs + fixtures/pray-list-sample.json: the real store (through Vite) never requests a photo on its own, a group with no photo URL gets none, and one with a URL keeps it. Planted: the old background test, added through the store's $onAction. The store from before L12 (d9e9aa448) fails both arms: 12 photo requests in 3.5 s and 3 groups on the 404 placeholder. The fixture is a trimmed sample of the live list: 200 rows (120 with has_photo, 77 without, 3 synthetic with no image_url), only the fields the check reads, 66 KB. About 10 s.
- template-bundle/scripts/check-template-i18n.mjs: profile B renders from template.json in en, ar and zh, one sentence in each language's own order with both <code> islands, and no raw key. Planted: the English catalogue served as Arabic. About 4 s.
All four are headless: no browser seat.
This corrects my own "Other" fix (9d12e59b6), which could never fire. gospel's cities-internet analysis (87a168195) showed the same shape here: key-cities.json has no row with an empty region (1,646 rows), "Other" (2) and "N/A" (19) are region VALUES, and every section heading printed its raw English data string in every locale. The fallback I translated also put a label into the grouping key.
Now, as in cities-internet: the raw region string stays the grouping key (sections, :key, collapse state), with 'Other' as the data guard; the heading is projected at render by regionLabel.js from the keys the data's strings already are the English values of (dashboard.wagfRegion.*, dashboard.other, wall.na), written out in full so the build ships those areas. The poster wall, whose PosterWall prints its section name as given, groups by that projected heading, as country-posters' wall does. keyCities.posters.otherRegion is no longer used.
Headless (_qa-cycle/exp-shepherd/check-keycities-regions.mjs): every one of the nine region values maps to its catalogue heading in ar, zh, es and ru, and an unknown value passes through as data. key-cities is gated, so nothing in public/ changes.
BUNDLE=gospel-resources bun x vite build, then bun run tree and fix:sri.
Regenerated rather than rebased: development ca07fc3c8 rebuilt this bundle too,
so my earlier rebuild commit conflicted on the built artifacts. Hand-merging
generated files would invent bytes neither build produced, so that commit was
dropped and the bundle rebuilt from the merged source instead — which is why the
integrity hash moves from ca07fc3c8's U6FmpP… rather than from the one my first
build replaced.
One hash changes; the MapLibre and Mapbox CDN pins are byte-identical on both
sides, and ./gospel-resources/ is the only bundle path in any changed line.
The chips a reader sees on a group's sheet — "Scripture,", "Bible video",
"Training", "Gospel presentation" — were plain template text. All four already
had keys with real ar and zh translations, so this routes them through
gospel.res.* and adds nothing: scripture, bibleVideo, training,
gospelPresentation.
WHAT IS DELIBERATELY LEFT ALONE, because it is not a label:
- g.audio.m keeps its raw value and its 'audio' default. That is the table's own
media descriptor, data rather than UI text.
- gospel-resource-rows.js is already correct. add(title, type, …) translates
title through tx() and keeps type as the category identifier the rows are
grouped by; the gate flags those type strings, but translating them would
break the grouping — the same value/label trap as the region headings.
- d.s.replace('Open Bible Stories', 'Bible Stories') at :2754 rewrites a DATA
value, not a label.
- data-tables.vue:122 already carries labelKey; its `en:` is the fallback beside
it, by design.
- The caveat prose (Driver row 80) and the title= tooltips (row 120) are
untouched and stay that way until those rows are answered.
check:hardcoded-english on gospel-resources: 91 -> 87, which is these four.
check:composite-freshness found stale on main: none. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
The lockfile's workspace section had "7" for @capacitor/android, cli and core, where package.json says "^7.6.9". --frozen-lockfile accepts that (7.6.9 satisfies both), but a plain `bun install`, as the HOWTO shows, rewrote the three lines, so a new contributor's checkout was dirty before their first edit and their first push would carry the lockfile. Regenerated with bun 1.3.14: exactly those three lines change, installs report no changes, and --frozen-lockfile still passes. Found by the fourth fresh reader.
check:poster-browser-equiv is chained in gates:pre and exited 75 NOT MEASURED
every run. Its input, out/_records.json, is gitignored and written only by
generate.mjs against a live API, so on CI and on any box without credentials the
gate never looked at anything. The runner reported that honestly rather than as
a pass, but an identical NOT MEASURED every run is indistinguishable from an
unwired gate -- and this is the gate that proves a sheet generated in the browser
is byte-identical to the one Node writes.
An equivalence check needs the SAME input on both sides, not fresh input, so a
committed fixture loses nothing it was measuring. The fixture is the 124 public
records the two arms actually consume: Nepal's 34 for the country arm, and the
90 slugs the Gujarat zone selects for the zone arm. Same fields the served maps
show; no other data.
Verified equivalent to the full 2,106-record cache: identical zone pins in the
same order, identical Nepal set, count 90, pop 3,164,770, series {2209, 28, 1}.
Record ORDER is load-bearing -- it drives draw and label order -- so the fixture
keeps the cache's order. A first cut sorted it: every aggregate matched and the
pin SET matched, while the order differed. Sorting a fixture would have quietly
changed the sheet it produces.
generate.mjs gains RECORDS_FILE so a caller can name the offline input instead of
only the API-written cache. It still fails closed without an API when no offline
file is given. The gate passes it the very file the browser arm read, so both
sides are fed the same records by construction.
The gate now says which input it measured, because the full cache widens coverage
to all 175 countries where it exists and the fixture is the floor everywhere else.
Two-sided, on this tree: with the fixture it PASSES byte-identical on both arms
(834,174 and 921,522 bytes). Dropping the province boundaries at
posterSheets.js:206 -- the defect this gate's header records, where a country
beside India or China printed without them -- makes the country arm 251,576
against Node's 921,522 and the gate exits 1. Restored, it passes again.