Commit graph

132 commits

Author SHA1 Message Date
doxa-maps-88655c70
6e958bc22f legend: open on the UPG-100 list, which is what the Driver asked for first
Some checks are pending
build-gate / build (push) Waiting to run
Measured at the rendered layer: the legend opened on REGION at desktop and
mobile. That is the Driver's first complaint about this legend word for word —
"it shouldn't have me sitting there on Regions, I don't know what to do, it's
confusing" — and the card recorded that part as shipped.

Every source signal said it worked. LEGEND_DEFAULT_TAB = 1 existed, defaultTab
was a declared prop, it seeded a local ref, and it was named in a watch fallback.

The root cause is one layer down. SemanticTreeLegend resolves its tab as
`instance ? instance.activeTab.value : localActiveTab.value`, and
usePplrInstance initialises activeTab to ref(0). Zero is indistinguishable from
"the user chose tab 0", so for every profile sharing an instance store the
defaultTab prop was dead — read on every render, never able to win. An
activeTabTouched sentinel fixes it: the store only wins once something actually
chose a tab, meaning a tab click or a node selection that jumps to that node's
generation. A sentinel rather than a new default, so no other profile's landing
tab moves as a side effect.

And a regression of my own, from the previous commit. There was only one
:default-tab left in the profile and it was on <LegendMobile>, where defaultTab
is not a prop. Last tick I "dropped the inert attribute" with a string replace
that matched the DESKTOP block first, deleting the working one and leaving the
inert copy. A dropped attribute is silent, so the build stayed green. Restored
to SemanticTreeLegend, and LegendMobile now has a real defaultTab prop so the
phone honours it too.

Both widths open on "UPG-100 Lists", and on ?lang=es both open on "Listas
UPG-100" — locale-independent. A user's own choice still sticks: clicking Region
holds at 0, and selecting a row does not snap back, so the sentinel is not a
trap.

check:legend-export-reachable still passes and now selects a real zone rather
than a region, because the legend opens on the Lists tab. The two fixes compound:
a phone user lands on the lists and can export the one they pick.
2026-09-24 17:59:57 +00:00
doxa-maps-88655c70
3db1dc1852 legend: on a phone you can browse the UPG-100 lists and cannot export them
Some checks are pending
build-gate / build (push) Waiting to run
The Export affordance lives in SemanticTreeLegend, which upg-100-list.vue mounts
for desktop only — its own comment says "Hidden below the mobile breakpoint,
where <LegendMobile>...". LegendMobile, the phone bottom-sheet, contains the
string "export" zero times. So the whole export path is desktop-only.

The packet IS the product for this profile. The Driver asked for "print options
at the top right of the legend... that's the only way that map would be usable".
A reader on a phone gets the browsing half and none of the printing half, with
no error and nothing to click — the failure is invisible rather than broken,
which is why it survived.

Measured at 390x844 against 1500x1000. Desktop: selecting a row takes
.stl-export from 0 to 3 elements, all with a real box, labelled "Export".
Mobile: the same 3 elements exist and measure 0x0 at (0,0), with display:block
and visibility:visible, inside an overflow:hidden parent — the desktop panel
rendered without being laid out.

Two earlier readings of mine were wrong, and the gate now encodes against both.
"No export button at either width" — I had not selected a row, and the control
is gated on selectedNode. "No legend rows on mobile" — 20 rows exist with 0
visible, because the sheet starts closed. Both were true statements about the
wrong surface, and both would have been reported as product defects.

The gate asserts a real box rather than a CSS property, for the same reason: the
mobile elements report visibility:visible while measuring nothing.

It FAILS on mobile by design and is deliberately not wave-wired. WHERE the
control goes on a phone is a design call — the sheet has no titlebar to put a
top-right action in, and SemanticTreeLegend is shared by four profiles — so the
gate names the gap instead of assuming a fix. Mutation-tested: flipping
SHOW_EXPORT_BUTTON off drops desktop to zero and the message distinguishes "no
element at all" from "exists but 0x0".
2026-09-24 17:27:28 +00:00
doxa-maps-88655c70
d9d4cbd0da spa tab-order gate: the product honoured the Driver, the gate over-read him
Some checks are pending
build-gate / build (push) Waiting to run
Red since the rail gained its three-section structure on 2026-09-23. The Driver
said "cap-5 tab order (Big Picture, UPG-100, Prayer Progress, Gospel
Resources)". That names an ORDER, not a row count, and nothing in it forbids a
row between. The gate quoted him correctly and then asserted those four names at
positions 1-4 of a flat rail.

Measured at the rendered layer: the rail is Big Picture, Simple Research,
Regions, UPG-100 List, Prayer Progress, Cities with Internet, Gospel Resources,
plus five lab-badged Experimental rows when that folder is open. The Driver's
four sit at 1/4/5/7, all present, relative order intact. The instruction is
honoured.

Provenance rather than timestamps was the tiebreaker. My first instinct was
"source is newer, so the gate is stale" — but this gate cites a Driver
instruction verbatim, so I checked what actually moved. The suspect commit
turned out not to touch the main rail at all; the restructure was two days
earlier.

A second defect in the same gate: '.vtl-tab' matched nested elements, returning
35 hits for 7 real rows. The old code's "first four" were therefore all
fragments of rows 1 and 2 — which is why it reported row 2 as "Simple Research"
when Simple Research is genuinely row 2 of seven. Now keeps only outermost
matches.

The expectation is presence, plus tab 1 positional, plus relative order for the
rest. Tab 1 stays positional because that is exactly what the Driver claimed:
the landing surface. Mutation-tested both kinds — reversing the order fails
naming the rows, expecting a different landing surface fails naming tab 1.

PASS now prints the whole rail with the Driver's four starred, so a reader sees
the rows between them. A gate printing only its own four would hide the
structure that made the old assertion wrong.

Still not wave-wired, and the reason is on the script: it FAILS rather than
skips when the SPA is unreachable, deliberately — "a gate that cannot reach its
subject has not passed, it has not run" — which is right standalone and
incompatible with gates:post running on server-less checkouts.
2026-09-24 16:56:10 +00:00
doxa-maps-88655c70
5905de8e30 facet gate: green 1 run in 4, and the variance is the dev server's
Some checks are pending
build-gate / build (push) Waiting to run
Re-ran the unwired-gate census from last tick. check:facet-changes-map failed
with a clean verdict line, which looked like a real finding. It was not — it is
flaky: PASS, FAIL, FAIL, FAIL across four consecutive runs, failing with "the
map never populated after 90s".

Against the BUILT tree it passes: twice in the first check, then three more
times through the new script entry. Five for five. The product is fine.

A flaky gate is worse than a failing one. It teaches you to re-run until green,
which is how a real red later gets dismissed as "just the flake".

The cause is the default target. A dev page load pulls 88 unbundled module
requests against the built leaf's 3, and Vite compiles them on demand, so with
several agents working the same box the wait this gate ALREADY retries three
times at 90s can still lose. Raising the timeout is the wrong fix for exactly
that reason: it is not one slow moment, it is contention.

The dev default is NOT changed. Its header gives a sound reason — the dev
surface always reflects current source — and another agent may be relying on it
for hand-running uncommitted work. Instead the flakiness, its cause and the
deterministic invocation are recorded on the script, and
check:facet-changes-map:built makes the built-tree run a command rather than
something to remember. Also recorded why this gate is not wave-wired: a step
green 1 run in 4 would train everyone to ignore the wave.

One dead end worth recording. I tried to reverse-engineer the wait by locating
the map instance myself and got "never found" on BOTH surfaces — my selector was
wrong, not the product. The gate has working discovery of its own; I stopped
probing and used its result, which was already decisive.
2026-09-24 16:38:59 +00:00
doxa-maps-88655c70
bfc1e0029b gates: two unwired checks were red on main, and nothing could tell us
Some checks are pending
build-gate / build (push) Waiting to run
Censused the 67 gate scripts that are in no wave. The framing I had been using —
"coverage we are not getting" — was wrong in the more serious direction. Two of
them were FAILING on main right now. The gates exist, work, and had found
something; because nothing ran them the findings sat silently.

check:legend-source, a real product finding: the live corpus moved 2108 -> 2107
and one group crossed Unengaged -> Engaged, so the legend baseline was
describing a dataset that no longer exists. Confirmed 2107 independently against
the live endpoint rather than trusting the gate's own reading.

Updated the baseline following the procedure its header requires, repeated
rather than assumed: grepping the six figures across plugin-designs/ and
library/ finds them only in explanatory comments, so no shipping code hardcodes
a corpus total and only the baseline lagged. Mutation-tested — an off-by-8
baseline fails. Recorded that this is the third reading, that it drifts in both
directions, and that it must NOT be widened into a range: the noise is the
signal that the numbers on the page are live rather than asserted.

check:verification-tags asks whether a human ever looked at a done card. 98
could not answer. 24 of those were mine, and they are now tagged agent/state —
honestly agent rather than human, and state rather than visual, because every
one was verified by measuring rendered DOM, PDF bytes and request tallies, never
by a human looking at pixels. The other 74 belong to other agents and the
Driver; guessing their provenance would defeat the gate, so it stays unwired.

Six further gates were green and self-contained and are now wired: locale
reachability, url resolution, manifest coverage, the palette registry, and the
legend/source comparison. gates:post 37 -> 42; wave membership 43 -> 50 of 106.

One I nearly wired wrong. check:dxlife passes standalone but exits 2 by
documented contract — 0 unchanged, 1 drift, 2 clone not found — and the wave
runner grades any non-zero as failure, so a checkout without the sibling clone
would go red on a precondition. Caught in the fresh-checkout run, unwired, with
the reason recorded on the script so the next census does not re-add it. "Can it
run" and "is it wave-safe" are different questions.

42/42 with servers up; 31/42 passed and 7 SKIPPED on a fresh checkout, every
skip named.
2026-09-24 15:53:58 +00:00
doxa-maps-88655c70
baf691e8cf provider-switch gate: run by default, instead of only when someone types a URL
Some checks are pending
build-gate / build (push) Waiting to run
check:provider-switch is a good gate — it counts requests BY HOST rather than
reading window globals (which stay defined after a swap), and it allows exactly
the api.mapbox.com URLs the generated leaf declares in its own head, so a
half-fix at 18 requests cannot pass.

It had a package.json entry and was in no wave, because it REQUIRED a URL
argument and exited 2 without one. So it only ever ran when somebody typed a
URL — the same present-but-never-executed shape check:packets-all had before it
was given a fixture.

Wiring it as-is would have turned every server-less build red rather than
silently green, which is the less common half of that failure. It now defaults
to the upg-100 leaf and SKIPs in the form the wave runner recognises, so the
skip is counted and named instead of grading as a pass or an error.

gates:post 36 -> 37, README re-synced. Verified both ways: 37/37 with a server,
and 27/37 passed / 6 SKIPPED on a fresh checkout with none.

Found while re-verifying BUG-UPG100-IGNORES-THE-PROVIDER-SWITCH from scratch
rather than trusting its done status. The card holds: ?provider=maplibre drops
Mapbox traffic 19 -> 5 on the upg-100 leaf, and all 5 are the page's own
declared preloads, so zero runtime calls remain. The same run independently
reproduced the four residual URLs in BUG-LEAF-PAGES-PRELOAD-MAPBOX-EVEN-UNDER-
MAPLIBRE, which stays untaken — its fix is in generate-tree, the tree verb
bundler-1 owns. When those preloads go, this gate's allowance drops to 0 and it
holds the new floor with no edit.
2026-09-24 15:30:35 +00:00
doxa-maps-88655c70
7a649b6845 packet browser gate: verify the packet a reader actually exports
Some checks are pending
build-gate / build (push) Waiting to run
The legend's Export > Prayer packet action is the path a reader clicks. The
build-time file is the one nobody clicks. The reader's packet was the less
verified of the two.

check:packet-render gained six assertion families over recent ticks — the 12
prayer points, the wheel's 12 LABELS rather than just its 12 segments, page 1's
geography, the print layer, the image census, the build stamp. Each was found by
mutation and each catches a real "looks fine, is empty" shape. None existed
here.

It also printed "Mark 5 in full : undefined" beside a PASS for as long as it has
existed: the measurement is r.mark5Len and the print read r.mark5, which is not
a key on r. The assertion always read the right key, so the requirement was
protected — but undefined next to a PASS reads as a gate that does not know what
it is doing. That is the inverse of this tree's usual failure, where a number is
printed and never asserted on.

A mistake caught before it became a false report. The new geography assertion
went red immediately with "no map svg on page 1", which would have read as a
serious product defect. It was the harness: this gate called buildPacket with an
EMPTY mapSvg, while the real export path fetches posters/<zone>.html and lifts
its <svg>. The shortcut was not just skipping an assertion — it meant the gate
never exercised mapCss() in a browser at all, which is the one thing it exists
to prove. It now fetches the map the way the product does and reports 13
geography paths, the same number check:packet-render gives for laos. ISO3 and
ZONE are also different strings ("lao" vs "laos.html"), so even a naive fetch
would have missed; PACKET_ZONE names it.

Mutation-tested: blanking the wheel labels gives 0/12 labelled and fails; hiding
content at print time gives 0 embedded fonts and fails.

i18n verified on the browser path for the first time. A partial locale with 3 of
12 points renders 12/12 points — the per-key merge holds here too — with the
points heading translated, the wheel heading falling back to English, Mark 5 at
2220 chars, and nothing blank.

Also made wave-safe: without a server it threw an unhandled navigation error and
dumped a stack trace, which is why it could never join a wave. It now SKIPs
cleanly naming its precondition. gates:post 35 -> 36, README re-synced. Verified
both ways — 36/36 with a server, and "0/1 passed, 1 SKIPPED" without one, which
is visible only because of the runner change in the previous commit.
2026-09-24 14:59:25 +00:00
doxa-maps-88655c70
a361da0a2e gate wave: a SKIP is not a pass, and this printed them identically
Some checks are pending
build-gate / build (push) Waiting to run
run-parallel grades on the exit code, and a gate that declines to evaluate
exits 0. So "35/35 passed" read the same whether 35 gates graded something or
31 did, and the summary line — the one people quote — was the most misleading
part of the output.

This is the generalisation of the packet-sweep fix two commits ago. That closed
ONE always-skipping gate; the reason nobody could see it for months was that the
runner reported it green. 18 of the 35 steps in gates:post CAN skip, every one
on a precondition a fresh checkout does not have, so the gap is structural
rather than specific to packets.

Measured on a genuinely fresh checkout — `git archive HEAD | tar -x`, which is
what CI sees. Before: "31/35 passed". After: "28/35 passed, 4 SKIPPED", naming
check:packets-all, check:zone-census, check:done-evidence, check:backlog-status.

All four are legitimate, and I checked rather than assumed. packets-all skips
until packet:ci runs, which CI does first. zone-census needs generated posters.
The two backlog gates read _qa-cycle, which lives outside any repo by the
deliberate decision that PM markdown never sits inside a code repo — and both
already say so in their own output. So the finding is the REPORTING, not a
broken gate.

Exit semantics are deliberately unchanged, and verified: a wave whose only step
skips still exits 0. Failing on a SKIP would fail every CI build on the backlog
gates and teach people to disable the reporting.

The detector is mutation-tested against seven real output shapes. It matches
"ZONE-CENSUS: SKIP — …" and the indented variant, and correctly does not match
prose mentioning skip, nor "SHARE-POLICY: PASS — 12 buttons, 0 skipped".

A correction to my own method along the way: I first censused skips in
doxa-maps-plugin-studio as a proxy for a fresh checkout. It is not one — it sits
many commits behind main with a 21-step gates:post that lacks 14 of the steps
main has, so the two censuses were of different waves. The fresh-archive test
replaced it.

Also documents check:zone-census's precondition, the one conditional gate in the
wave that had none — including why it cannot be fixtured the way packets-all
was: it asserts coverage of real generated output, so a fixture would only
assert that the fixture is complete.
2026-09-24 14:50:05 +00:00
doxa-maps-88655c70
3c17245c73 packets: give CI something to check, instead of a sweep that always SKIPs
Some checks are pending
build-gate / build (push) Waiting to run
check:packets-all is in gates:post. CI runs it on every build. It has SKIPped
every single time, because library/scripts/posters/out/ is gitignored — both the
records cache and every generated packet — so a clean checkout has none.

Every packet assertion in this tree has therefore never once been evaluated
there: the 12 prayer points, the wheel's 12 labels, page 1's geography paths,
the print layer, the build stamp. Several ticks of work accumulating on a gate
that had only ever graded files in my own working tree.

A SKIP that is always taken is indistinguishable from an unwired gate — and the
wave-membership census cannot tell them apart, because membership measures
invocation, not evaluation. That census is how check:packets-all was created in
the first place, which makes this the same class recurring one level up.

I raised this at the end of the last tick and deferred it on the premise that
closing it "adds a live-API call to every build". The premise was wrong.
packet.mjs already had an OFFLINE mode, and a fixture needs no network at all.

The fixture is the smallest zone — Tibet, 43 slugs, 20KB — with image_url
stripped so CI can never fetch 43 photos from someone else's CDN. It is DERIVED,
not hand-maintained: membership comes from the committed clusters.json, so
check:packet-fixture regenerates and content-compares it. Re-adding an image_url
fails; dropping a record fails naming the slug.

A fixture packet stamps itself "fixture", and "fixture nomap" in CI, which has
no poster sources either. `fixture` implies nophotos in the gate — without that
it reported "every source record carries an image_url", a true sentence about
real records and a false one about this packet.

A real bug in my own change, caught only by running the script through its REAL
invocation path: packet.mjs fails closed on a missing VITE_API_BASE_URL BEFORE
loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a
tree that had everything it needed. Every hand-test had passed because I was
supplying a dummy host. The guard now exempts fixture builds and still fails
closed otherwise.

From a genuinely clean state — the whole out/ directory moved away — the sweep
now reports "1 packet(s) checked, 1 passed". i18n verified through that same
path: a partial block (1 string, 5/12 points, 3/12 rhythms) renders 12 points,
12/12 wheel labels, wheel 3 translated and 9 English, 0 blanks.

check:packet-fixture joins gates:post (34 -> 35), README quote re-synced in the
same commit. Wave 35/35, packets-all 11/11 here and 2/2 in the studio.
2026-09-24 14:33:36 +00:00
doxa-maps-88655c70
baf302983e Rebuild the served tree after merging main, and unswallow two self-test flags
The merge surfaced what main's new script-flags gate was written for: two
gospel-resources entries advertised --poison inside an inline # comment, where
the shell drops it — a self-test that never runs is a gate certifying itself.
Each flag now has a runnable sibling entry (check:snapshot-guard:poison,
check:coverage-consistent:poison) and the prose points at the entry instead of
the raw flag. Both siblings executed: the injected defects are caught (12/12
clauses). capability-exemplars regenerated; full build green, 34/34 post gates.
2026-09-24 14:23:20 +00:00
doxa-maps-88655c70
445131cf94 Merge main into bundler-3/work — settle the 21-file conflict, keep both campaigns
Only 2 files conflicted in source: package.json and its CLI doc, both taken to
main's refactored gate pipeline (gates:pre/gates:post/build:bundles) after
verifying every check the old inline chain named still runs there. The other 19
were generated: public/assets/tables mirrors keep main's deliberate deletion,
and public/doxa-maps is regenerated by the build that follows.
2026-09-24 14:16:47 +00:00
doxa-maps-88655c70
e130cb672a packet generator: say when page 1 has no map, instead of printing a tick
Some checks are pending
build-gate / build (push) Waiting to run
`✓ laos  95 groups  8 pages  0 photos` — for a packet whose first page is blank.
The page count stayed 8 because a blank sheet is still a sheet.

The map one-pager is lifted from out/posters/<zone>.html, which a separate
manual step writes. When it is absent packet.mjs deliberately still builds, and
that fallback is right — but it was silent. The empty-groups branch three lines
above already warns and skips; the map branch had the same shape and no warning.

A gate catching this is not the same as the tool reporting it. The gate added
last tick runs later, optionally, and only when someone invokes it. The
generator prints its tick at the moment of production, which is the one output
an operator is guaranteed to see.

Now it prints ⚠ with the reason inline and repeats it in an end-of-run summary
naming every affected zone and the fix — a per-line mark scrolls off in a
27-zone run, and this is the one fault that cannot be seen by counting pages.

Both silent branches are covered, distinctly: a missing poster reports "no
out/posters/laos.html"; a poster that exists but carries no <svg> — a truncated
generate run — reports "out/posters/laos.html has no <svg>". An operator can
tell "never generated" from "generation broke".

Also adds the `packets` script. Its absence is why the posters -> packets
dependency was never expressible anywhere: packet generation was only ever a
hand-typed node invocation, so nothing could state what it reads.

Measured in both repos. doxa-maps-plugin-studio has 0 generated posters against
wt-packet's 28 — out/ is gitignored in both, so posters are build artifacts and
this is a missing build step, not a repo defect. A fresh studio run now warns;
wt-packet prints a clean tick. The size difference corroborates: 0.4 MB with the
map, 0.1 MB without.

i18n re-verified end to end since this touches the generator: a partial locale
block (2 strings, 3/12 points, 4/12 rhythms) renders the wheel 4 translated and
8 English, 12 point rows with 4 translated, 0 blanks, clean tick. All 11 locales
regenerated with photos — packets-all 11/11, each 12/12 wheel labels, 12/12
points, 13 geography paths. Wave 34/34.
2026-09-24 13:53:57 +00:00
doxa-maps-88655c70
6c60af555f library-index gate: ask whether the menu drifted, not whether a file is older
Some checks are pending
build-gate / build (push) Waiting to run
`touch library/components/legends/SemanticTreeLegend.vue` — zero content
change, nothing in git status — turned this gate red. So did a git checkout, a
rebase, and any editor that rewrites a file on save. It cried wolf twice in one
build session while the regenerated doc was byte-identical both times, and the
only available response was to run the generator and commit nothing.

An mtime answers a different question than the gate's own name asks.

This is the SECOND time this exact class was fixed here. The first was a SCOPE
error: the gate walked 2.3x more of library/ than the generator reads, so a
comment in a build script turned it red and named that script as the reason the
component MENU was stale. Narrowing the scope fixed that instance, but the
mechanism was still mtime, so the class survived inside the narrowed scope. The
lesson recorded then applied again word for word — a gate that fails for a
change it can prove is irrelevant teaches the reader to regenerate on reflex,
which is how a staleness check stops being read.

It now regenerates into a throwaway tree and diffs the bytes.
generate-library-index.mjs hardcodes its output path and derives ROOT from its
own location, so it cannot be told to write elsewhere — but copying the SCRIPT
into a temp tree whose library/ is a SYMLINK to the real one relocates its ROOT
without modifying it. The generator runs unmodified against real sources, writes
somewhere disposable, and an interrupted run leaves nothing behind. Verified
byte-identical to a real `bun run library-index` before this was written, and
the generator was confirmed deterministic first — a content diff would be
useless otherwise.

Comparing output also makes the scope self-enforcing: a change outside
components/, composables/ and utils/ cannot alter one byte of the menu, so the
INDEXED_DIRS list the mtime walk needed is gone.

Mutation-tested both ways, because a gate that never fires is worse than a noisy
one. Deleting a catalogued row fails with the line number and expected content.
Adding a real new library component fails — that is the drift this exists to
catch. Touching 40 library files, the rebase case, passes. Wave 34/34.

Swept the five other internal/*.mjs that reference mtime: only
check-shot-freshness also decides on one, and there it is correct — a PNG cannot
be cheaply regenerated and diffed, and a rebuilt bundle is meaningfully newer.
Left alone deliberately.
2026-09-24 13:29:58 +00:00
doxa-maps-88655c70
a177f98aff legend: the controls no longer read their own i18n key aloud
Some checks are pending
build-gate / build (push) Waiting to run
The legend's toggle button announced the literal string "aria.toggleLegend" to
screen readers. Not in translated locales only — in English too. vue-i18n echoes
the key when a lookup misses, and `toggleLegend` was absent from every catalog
while its two siblings in the same file, backToLegend and closeDetailView, were
present. Six call sites across LegendDesktop.vue and LegendMobile.vue, desktop
and mobile alike.

aria-label is the one string a sighted tester never sees. Every other
untranslated string in this tree got caught because somebody looked at the
screen; this one is only ever spoken aloud, so it survived every visual QA pass.

A second instance turned up in the same sweep with the opposite shape:
SemanticTreeLegend hardcoded 'Collapse legend' / 'Expand legend' into both title
and aria-label with no t() at all — correct English, and untranslatable. One key
missing FROM the catalog, one string never routed TO it.

Adds toggleLegend, collapseLegend and expandLegend to all 11 aria.json catalogs
and routes the hardcoded pair through t(). check:i18n-parity already guards them.

New gate smoke:aria-labels asserts BOTH shapes, because either assertion alone
is insufficient: "no raw keys" passes a hardcoded English label forever, and
"labels differ across locales" passes a raw key, since a key is identical in
every locale. It detects a raw key by SHAPE — dotted, camelCase, no spaces —
which is locale-independent and works on the Arabic and Chinese pages where no
English word list could.

Mutation-tested twice. Removing the catalog key fails with the raw key named, on
all four viewport/locale combinations. Hardcoding one label back to English
initially PASSED: the differ count dropped 6 to 5 and nothing asserted on it —
the same "a number printed but not asserted on is decoration" shape this tree
keeps rediscovering — so the gate gained a LOCALIZED_FLOOR ratchet and now fails
that too.

Verified the fix generalizes beyond the profile it was found on: country-posters
reports 182 of 190 accessible names localized, 0 raw keys. Rebuilds the six
bundles that embed a legend; check:sri confirms every leaf matches. Wave 34/34.
2026-09-24 13:16:18 +00:00
doxa-maps-88655c70
6af75895c7 legend: the info popovers explain the map in the reader's own language
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for the per-tab info buttons to stay — "the tab itself gives
you a little information button that will explain what a UPG-100 list is."
They existed, and they were English-only in every locale.

SemanticTreeLegend rendered `tab.info` RAW in all three places it appears (the
button's title=, and the popover body) while the tab LABEL beside it went
through the catalog. So a Spanish reader saw a translated chip next to an
English explanation of what that chip means — and the explanation is the part a
reader who does not read English actually needs. The three UPG-100 tab labels
were absent from tabs.json entirely, so they fell back to hardcoded English too.

Measured on the served leaf before changing anything: ?lang=es changed 2 of 76
rendered strings ("POPULATION" -> "POBLACIÓN") and left all three tabs and all
three info popovers in English. That positive control is what proves the locale
switch WORKS and the strings were simply never catalogued — without it this
reads identically to a broken ?lang=.

tabInfo() mirrors tabLabel exactly: `legend.tabInfo.<i18nKey|id>`, te() guard,
falling back to the hardcoded English so a tab with no catalog entry renders
unchanged rather than blank. The v-if stays keyed on the raw `tab.info`:
whether the affordance EXISTS is a property of the tab definition, while its
TEXT comes from the catalog.

Catalogued in all 11 locales, labels and info. check:i18n-parity already guards
them — deleting one zh key fails the build by name.

New gate smoke:legend-info renders the REAL served page and clicks each info
button. check:tabs-localized could not see any of this: it tests 7 hardcoded
research-map tabs and REIMPLEMENTS tabLabel inside page.evaluate, so it proves
the mechanism works rather than that a given profile is localized — and it never
looked at `info` at all. Mutation-tested: reverting the popover to the raw prop
fails with "the catalog is present and unreached".

Partial-translation contract verified by deleting 2 of 3 fr info keys and 1 of 3
fr labels: translated keys render French, untranslated fall back to English,
nothing blank, and labels and info resolve INDEPENDENTLY.

Rebuilds the six bundles that embed the legend so the tree stays internally
consistent; check:sri confirms all 55 script references match. Full wave 34/34.
2026-09-24 12:49:04 +00:00
doxa-maps-88655c70
f129294609 Clause L: a verification record that stops being re-run now fails the gate
Eleven clauses checked that the surfaces AGREE with each other. None checked that any
of it was still true. "Link checked" is a claim about the present tense, and a table
that quietly stops being re-run keeps making that claim on evidence that could be a
year old while every consistency clause still passes. A dead link is the failure a
field worker actually hits, so this is not bookkeeping.

Clause L fails when a shipped URL carries no check date at all, or when the oldest
shipped link was last checked more than 45 days ago, and the message names the exact
command to fix it. Re-running is cheap and resumable now, so the failure is an
instruction rather than an obstacle.

Proven against real data, not only the poison harness: deleting the check date from
one shipped URL is caught ("1 shipped URL(s) carry no check date"), and backdating one
by 120 days is caught ("last checked 120 days ago, past the 45-day limit"). Both
restore clean. Poison self-test now 12/12.

Also checked and deliberately NOT changed: the Arclight harvest keeps a language only
when counts.featureFilm >= 1, and it was worth asking whether that discards coverage.
Of 2,330 catalogue languages, 2,309 already have a feature film. Only 15 carry just a
shortFilm or a series, and exactly one of those - nmg Kwasio, 2 pins, 60k people - is
spoken by a group here with nothing else. Widening the filter would mean typing a
short film as "Film" beside the JESUS Film for a single language. The filter stays.

The registered description still claimed "6/6 clauses must fail"; corrected to 12.
2026-09-24 12:31:36 +00:00
doxa-maps-88655c70
e8ad1d56e6 packet: a partial translation renders 12 prayer points, not 3
Some checks are pending
build-gate / build (push) Waiting to run
packet-render.js resolved the prayer points with `L.points || EN.points`.
That is a whole-block fallback when the key is ABSENT, and TRUNCATING when a
translator has done some of the 12 — a 3-element array is truthy. Measured at
the rendered layer with a deliberately partial locale block: a page headed
"12 Prayer Points" containing THREE, points 4..12 silently gone, including the
Mark 5:1-20 passage point 4 prints in full. Nothing blank, no error raised.

Invisible in all 11 shipping locales because every one of them is complete.
It would have appeared on the next language added — and "a new language is a
NEW BLOCK, never a code change" only holds if a PARTIAL block degrades per key,
since half-finished is the normal state of a language in progress.

Now merges per point and per field against English: English fixes the LENGTH
and supplies every missing field, so a point translated for `text` but not
`sub` keeps the English `sub` — and keeps its NUMBER, which the merge supplies.

The sheet-sum invariant could not see this: a 3-point page is still exactly one
sheet, so every number check-packet-render printed stayed correct while nine
points were missing. It now measures and asserts the count. Mutation-tested —
reverting the fix prints "prayer points : 2 / 12" and fails.

All 11 locales regenerated with photos and swept: check:packets-all 11/11, each
12/12 points, including RTL ar and Devanagari hi. Renderer, generator and gate
synced to doxa-maps-plugin-studio, which also lacked the .points-title and
.wheel-title test hooks the new assertion needs.

Also wires check:capability-exemplars into gates:post (33 -> 34 steps). It reads
public/doxa-maps/manifest.json, a build artifact, so ordering mattered: `tree`
writes the manifest and is &&-chained immediately before gates:post, and the
gate SKIPs rather than fails when the manifest is absent. Full wave: 34/34.
The quoted gates:post line in the README is re-synced in the same commit,
because check:quoted-scripts goes red on the drift this commit creates.
2026-09-24 12:20:38 +00:00
doxa-maps-88655c70
0e0b142719 A half-finished catalogue fetch can no longer overwrite a good one
The FCBH fetcher read per-fileset video scope for 1,666 languages and that file was
committed. A later run, while live.bible.is was rate-limiting, produced 1,103 and
overwrote the good file with the degraded one. Nothing failed, nothing warned, and the
artefact silently lost a third of its content. It was caught only because git status
happened to be read, and the fix was a git checkout.

That is the third time this shape has appeared here:
  - a VERIFY_LIMIT smoke run replaced the full verification table with 30 rows;
  - dropping a dead link removed it from the corpus, so the next run forgot it was
    dead and the build put it straight back;
  - this one.
Every snapshot in this bundle is built from a network read, and a network read has a bad
day. The dangerous case is never the run that throws - that leaves the old file alone -
it is the run that half-succeeds and writes anyway.

scripts/_snapshot-guard.mjs now stands in front of every catalogue write. A snapshot may
grow freely and may shrink a little, but losing more than a tenth of the previous run is
treated as a failed fetch: the existing file is left untouched and the script exits 1.
It is deliberately not an equality check, because catalogues do genuinely lose entries
when a publisher retires something, and a gate nobody can satisfy gets switched off.
SNAPSHOT_FORCE=1 is the one loud way past it.

Proven by replaying the exact failure: feeding it the 1,103-entry version refuses the
write, names the real numbers (563 fewer, 13.8%, below the 90% floor) and leaves the
file byte-identical to the good one. SNAPSHOT_FORCE=1 writes it. An equal write passes.

All six fetchers are wired: ebible, arclight, arclight-share-urls, door43, zume, fcbh.
Re-running zume, door43 and ebible for real passes the guard at 52, 208 and 1,237 codes
and produces byte-identical data, so the guard perturbs nothing.

The one exception is the checkpoint write in the resumable share-urls fetcher, which
starts from the existing file as a cache and can only grow. It must now declare itself
with a CHECKPOINT-WRITE-OK marker, so the exemption is a statement rather than an
oversight.

New gate check:snapshot-guard fails if any fetch-*.mjs writes OUT directly without that
marker, or never calls writeSnapshot at all - a guard that is easy to bypass is a guard
that will be bypassed. It refuses to run against an empty file list rather than passing
vacuously, and --poison injects a raw write and requires the check to catch it. Chained
into build:unlocked and its doc copy kept in sync.
2026-09-24 11:59:48 +00:00
doxa-maps-88655c70
510f3d30d4 The backlog-status enum was too narrow, not the nine cards wrong
Some checks are pending
build-gate / build (push) Waiting to run
This gate keeps the AGGREGATE honest — a prose status makes every open-count wrong
while each card still READS correctly, so the error hides in the total. That matters
to this loop directly: every tick starts by reading status counts to pick a card.

MEASURED across 633 cards: EIGHT distinct status values are in use and EVERY ONE IS
A SINGLE WORD. No prose remains anywhere in the field — which is exactly what the
original 130-card migration achieved. The nine cards this gate was failing carried
legitimate TERMINAL states the enum simply predated: superseded (5), deferred (2),
decided, verified-already-true.

★ REWRITING THEM TO "done" WOULD HAVE DESTROYED REAL DISTINCTIONS to satisfy a list.
A SUPERSEDED card was replaced by another; a DEFERRED one was consciously postponed;
neither was finished. Losing that information to make a checker green is the
opposite of what this gate is for.

OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
open = todo + doing + in-progress (66), closed = done + these four (507),
blocked (60).

MUTATION-TESTED BOTH WAYS, so widening did not weaken it: a prose status
("done — reconciled against main, see notes") still FAILS by name — the original
defect — AND an unagreed one-word status ("wontfix") ALSO fails, so the set stays
CLOSED rather than becoming "any single word".

Wired into gates:post (32 -> 33) after confirming it reads only backlog JSON. The
doc that quotes gates:post is re-transcribed in the same commit.

Build: 7/7 pre, 23/23 bundles, 33/33 post, exit 0.
2026-09-24 11:58:39 +00:00
doxa-maps-88655c70
d4e6d8b044 Two --self-test modes could not be selected — a gate certifying itself
Some checks are pending
build-gate / build (push) Waiting to run
A --self-test IS the proof that a gate can fail. verify:interop and
check:fits-screen each advertised extra regression modes that nobody could reach,
so their non-default regressions were never demonstrated. The gate that found this
puts it exactly: "a --self-test that never runs is a gate certifying itself."

THE MECHANISM, PROVEN WITH AN ARGV PROBE rather than argued. Both parent script
values end in an inline # comment, so a mode appended by the caller lands INSIDE
that comment:
  dedicated entry (mode hardcoded) -> ARGV ["--self-test","--self-test-clause=b"]
  parent-style, caller appends      -> ARGV ["--self-test"]          clause (none)
The run then exits 0 in DEFAULT mode, indistinguishable from the mode being
selected. Unreachable: camera-freeze (interop) and clauses b-e (fits-screen).

Five dedicated entries now hardcode each mode, and the parent comments point at
them instead of at an append that cannot work. Confirmed end to end:
check:fits-screen:self-test:b fires a real clause-named failure
("[A] FAIL dashboard-plus-grid -> band=2401px vs viewport=900px").

★ MY FIRST ATTEMPT MADE IT WORSE — 2 findings became 6. I appended an explanatory
# comment to each new entry, which re-created the swallowing shape AND named the
flag in prose; the gate scans script VALUES, so describing a flag is
indistinguishable from advertising one. Rewrote the notes naming no flag. Then a
blanket string-replace mangled the fits-screen comment's clause list into
unreadable prose, repaired separately. The gate was right at every step; I was the
one adding the defect.

check:script-flags goes FAIL(2) -> PASS(148 scripts, 0 unreachable), and is wired
into gates:post (31 -> 32) after confirming it reads only package.json.
MUTATION-TESTED: reintroducing an advertised-but-swallowed flag fails by script
name. The doc that quotes gates:post is re-transcribed in the same commit.

Build: 7/7 pre, 23/23 bundles, 32/32 post, exit 0.
2026-09-24 11:52:16 +00:00
doxa-maps-88655c70
28ce04f6ac check:done-evidence knew one card schema and failed the best-evidenced cards on the board
A done card with no record is the reporting failure this loop exists to avoid — so a
gate that mis-identifies WHICH cards lack a record is worse than none: it teaches the
next agent to pad the field the checker reads.

★ THE THREE FLAGGED CARDS WERE AMONG THE BEST-EVIDENCED THERE ARE. Each carried
one_line, why, and a `verify` block naming the probe, its assertion count and its
negative tests — "74 files match byte for byte, all three drift classes exit 1",
"9 assertions, all pass", "two new assertions, both negative-tested".

The gate looked only for `bluf` or `fields`. MEASURED across 443 done cards: 40 carry
`verify`, 41 carry `one_line`, and ALL THREE flagged cards had one or both. `verify`
is if anything the STRONGER signal — it names how the claim was checked, which is
what evidence means here.

Fixing the CARDS by adding a redundant bluf would have been gaming the check. Fixing
the GATE to recognise the evidence already there is the honest repair.

★ THE BASELINE IS NOW EMPTY. All ten exemptions reported STALE — every one had since
gained evidence — so the list exempted nothing while implying ten cards still owed a
record. An EMPTY set is not an unset one: every done card must now carry evidence
with NO exemptions, and the gate fails outright for a single card that does not.
Keeping ten satisfied ids would mean the same today and quietly permit a regression
the moment someone reused an entry. Same reasoning as emptying the i18n DEBT map.

MUTATION-TESTED both directions: a done card with NOTHING in any of the four fields
FAILS by name; the same card with ONLY a `verify` entry PASSES.

Wired into gates:post (30 -> 31) after confirming it reads only backlog JSON, which
the build does not mutate. The doc quotes gates:post, so it is re-transcribed in the
same commit — the self-referential update noted last tick, handled in one pass this
time.

Build: 7/7 pre, 23/23 bundles, 31/31 post, exit 0.
2026-09-24 11:38:17 +00:00
doxa-maps-88655c70
856a350c6a The CLI doc quoted 18 gates while 29 ran — sync it, and wire the gate that noticed
Some checks are pending
build-gate / build (push) Waiting to run
Last tick left six documentation-drift gates failing and NAMED on every build. This
is the smallest — one finding — and it guards something real: a doc that quotes a
config is claiming to be ground truth.

THE DRIFT WAS ENTIRELY MINE. Every one of the eleven missing steps is a gate added
over the past ticks, all four i18n gates among them.

★ AND IT DRIFTED IN THE UNUSUAL DIRECTION. The doc did not OVERSTATE coverage, it
UNDERSTATED it: a reader consulting README-SECTION-3 would believe eleven checks
were not in the chain. That is precisely what the gate's own header warns about —
"there is no wrong character to notice, just a check the reader believes is not in
the chain."

★ THE JUDGEMENT CALL. The surrounding prose said "the 29 gates cost 84.5s" and
"31 in, 31 out" — figures from the 2026-09-23 phase refactor, now 36. I DATED them
rather than restating them as current. Those numbers are the EVIDENCE that the
phase change preserved the step set; rewriting them to 36 would destroy that
evidence while looking like an update. The gate compares quoted SCRIPT LINES only,
so this prose drift was invisible to it either way.

Wired into gates:post (29 -> 30) after confirming it reads only package.json and a
markdown file, neither of which the build mutates — the precondition test I got
wrong with check:settled last tick, where "passes twice standalone" proved
repeatability rather than compatibility with running mid-build.

★ WIRING IT IS SELF-REFERENTIAL and takes two passes: adding the gate to gates:post
CHANGES the line the gate quotes, so it failed immediately after being wired and
needed one more doc sync. That is the mechanism working, and it is now noted in the
doc so the next editor is not surprised by it.

MUTATION-TESTED: removing a single step from the DOC only makes it FAIL by name —
the invisible-omission case it exists for.

Build: 7/7 pre, 23/23 bundles, 30/30 post, exit 0.
2026-09-24 11:30:49 +00:00
doxa-maps-88655c70
482e6d4ca0 Wire the seven gates that could run, and make wave membership visible
Some checks are pending
build-gate / build (push) Waiting to run
MEASURED: 70 of 100 check/verify scripts were in package.json but in NEITHER
gates:pre nor gates:post. I had fixed this same "present but unwired" failure three
times for individual gates without ever asking how many others were in the state.

★ THE GATE THAT EXISTS TO CATCH THIS WAS ITSELF UNWIRED. check:no-orphan-gates
asserts every gate-shaped file is REACHABLE — named in package.json or imported —
and it passed, 87/87. But reachable and WIRED are different properties, and
conflating them is exactly how 70 scripts sat outside every build. A script entry
means a human CAN run it; wave membership means it RUNS.

Tested each unwired gate standalone: 7 pass with no precondition and were simply
never wired (now in gates:post, 22 -> 29 steps, +1.5s on a ~150s build); 6 FAIL on
pre-existing documentation drift nobody sees — check:verification-tags alone lists
61+ untagged cards — which is its own project, and wiring a red gate only gets it
disabled; the rest legitimately need a served tree, a device, a token or an argument.

★ ONE I WIRED WRONGLY, AND THE MISTAKE IS THE USEFUL PART. check:settled asserts the
tree is QUIET — no builder running, no recent churn — so it fails inside a build BY
DESIGN. It passed twice standalone, I wired it on that evidence, and the next
authoritative build came back 29/30 with check:settled the only red. Running a gate
twice while IDLE proves repeatability, not compatibility with running mid-build. The
question for wave membership is not "does it pass" but "is its assertion compatible
with a build in flight". Unwired, and the reasoning is recorded in
check-no-orphan-gates so nobody re-adds it on the same evidence.

check:no-orphan-gates now reports wave membership as a second, distinct question
from reachability — "37/93 run in gates:pre or gates:post" — naming the first eight
unwired so the debt is visible rather than silent. It REPORTS rather than fails,
because most of the remaining 56 are correctly hand-run. Mutation-tested: removing
check:zone-census from gates:post moves the count 37 -> 36.

Build: 7/7 pre, 23/23 bundles, 29/29 post, exit 0.
2026-09-24 11:24:01 +00:00
doxa-maps-88655c70
8f7a2f875d Gate the default drop: with no poster and no source, nothing prints and the reader is told
check:poster-print-fallback proves the GOOD path — no .webp, .html present, browser
rasterises. On a tree with NEITHER it SKIPs. And neither is the DEFAULT, since
posterSources and bundlePosters both default false, so the behaviour a reader is
most likely to meet was the one behaviour nothing asserted.

BUILD-POSTERS-GENERATE-ON-DEMAND-DROP-52MB claims in prose that export and print
"degrade honestly, with the preview refusing to open rather than printing an error
page". That claim was true and untested. Measured on a real dropped folder: the
wall requests the .webp (404), then the .html fallback (404), then shows "poster
not found (404)" with a Close button. 0 print frames, 0 downloads, 0 page errors.

The failure this guards is NOT a 404. It is a print dialog that OPENS on a poster
that does not exist — a blank sheet, or a low-resolution PREVIEW thumbnail printed
as though it were the 3ft poster. Both look like the feature working.

MUTATION-TESTED, two plausible wrong fixes, each caught distinctly: falling back to
printing the preview ("1 print frame(s) opened with NO poster and NO source"), and
failing silently by clearing exportState ("the reader was told nothing"). The gate
also asserts BOTH paths were attempted, so a wall that gives up before trying to
rasterise fails even while showing the right message.

★ SERVE THE TREE ROOT, NOT public/ — and this is in the gate's header because I got
it wrong first. A leaf resolves assetsBase to '../../assets', which from
<tree>/<bundle>/<profile>/ is <tree>/assets. Serving public/ puts public/assets on
that path, a directory still holding 378 posters in a dev checkout, so the poster
loads with a 200 and the gate tests the opposite of what it claims. The same click
returned 200 under `--directory public` and 404 under `--directory public/doxa-maps`.
My first reading of this was "a print frame opened on a missing poster", which would
have been a serious product defect; it was the harness.

Also audited check-packet-render this tick: all thirteen printed metrics now have
assertions behind them. No decoration left on that surface.
2026-09-24 10:16:22 +00:00
doxa-maps-88655c70
6732c8d430 Guard the packet catalog: the 12 prayer points and the wheel had no parity gate
check:i18n-parity covers library/i18n/locales — the APP catalogs. Nothing covered
library/scripts/posters/packet-strings.json, which is a separate file with its own
shape and is where the 12 PRAYER POINTS and the PRAYER WHEEL live. SIX gates read
it and not one checked that a language block is complete.

All ten locales happened to be healthy — 19/21 string keys, 12 points with every
field, 12 rhythms — but nothing held them there. A point dropped from one block
renders that slot through the per-key English fallback: correct runtime behaviour,
invisible defect, on a page the reader is meant to pray through.

★ IT ASSERTS PRESENCE, NOT TRANSLATEDNESS, and that is the load-bearing choice.
check-packet-render printed "19 strings translated" and only ever asserted `!== 0`,
so a locale regressing from 19 to 1 passed silently. The fix is NOT to pin 19:
measured, de translates 18 of the same 19 keys and fr 17, because a word or two
legitimately matches English — a hardcoded count fails two CORRECT catalogs. So
parity asserts every English key EXISTS in every block, a structural fact no
translation choice can change, and the render gate gains a FLOOR of two-thirds
derived from the catalog itself.

point4_passage + point4_passage_credit are DECLARED-ABSENT with the reason on
record (Mark 5:1-20, public-domain licensing per language). Naming the exception
beats tolerating it silently: the day a licensed translation lands, deleting the
entry tightens the gate by itself.

MUTATION-TESTED, five defect classes, each caught with a precise message: a dropped
string key, an 11th-of-12 prayer point, an EMPTY point text, a missing wheel rhythm,
and a block reverted wholesale to English. The floor was tested separately by
reverting de to 3 translated of 19 — "only 3 differ from English (floor 13)".

★ THE TWO GATES ANSWER DIFFERENT QUESTIONS, and a partial language shows both at
once. Swahili at 3 of 21 keys, no points, no rhythms: check-packet-render PASSES —
a partial translation renders, Swahili where translated, English where not, zero
blank slots — while check:packet-strings-parity FAILS and names exactly what is
missing, including that the points and wheel would fall back wholesale. Before this,
only the first question had a gate, so a half-finished block was indistinguishable
from a complete one at build time.

gates:post is now 22 steps; 11/11 packets still pass the sweep.
2026-09-24 08:57:34 +00:00
doxa-maps-88655c70
e5fe33b230 prayer-walk-planner can be shared — the last published map with no sharing path
check:share-policy's known-debt line is now GONE: it named two published maps, then
one, now zero. Every published map has exactly one decided sharing path.

★ THE OPEN QUESTION WAS ANSWERED BY THE CODE, NOT BY A RULING. This was held back
asking "is it MEANT to be shareable? a link carrying no route arrives as an empty
planner, and a button that reliably produces a blank map is worse than none". That
framing assumed a design choice existed. It did not: the profile keeps polygons in
localStorage and has NO BACKEND — its own header says so — so a URL cannot carry a
drawn route under any design. The shareable artefact is the PLANNER, exactly as
every other map shares its own page. The failure only exists if the link CLAIMS to
carry the route, so the status strip says once, on click: "This link shares the
planner. Your drawn zones stay on this device." — using the status mechanism the
profile already had rather than adding a surface.

★ ADDING <ShareButton> WAS NOT ENOUGH, AND check:share-policy COULD NOT SEE IT.
The button rendered NOWHERE. This bundle mounts its SFC directly as the custom
element — deliberate, it is what surfaces the defineExpose()d client-side API on the
DOM element ("the only API we can build has to be CLIENT SIDE") — and so installed
no i18n. ShareButton calls useI18n(), which throws in setup(), and index.js's own
header records the cost: "a thrown setup() removes the whole shadow-DOM subtree
silently." Measured: a runtime SyntaxError, five toolbar buttons rendered, the sixth
absent — while the policy gate, which counts <ShareButton> in SOURCE, said 1 button
and PASSED. That blind spot is now written into the gate, pointing at the smoke that
does catch it.

Fixed WITHOUT changing the mounting shape: defineCustomElement takes configureApp
whichever component is the root, so i18n installs and the API stays on the element.
The direct-mount decision and i18n were orthogonal; I had assumed they were coupled.

VERIFIED AT THE RENDERED LAYER: share-roundtrip-smoke resolves the url 200 text/html
and REOPENS it as <prayer-walk-planner> with a rendered canvas. The client-side API
was re-verified after the change — all 7 exposed methods present, addPolygon ->
listPolygons round-trips, 0 page errors. i18n verified in 6 locales: Share /
Compartir / مشاركة / साझा करें / 分享 / Partager. Added to smoke:share-all, now
12/12 pass 0 fail.
2026-09-24 08:23:30 +00:00
doxa-maps-88655c70
3a468c2bc3 The poster print preview rasterises too, and a deployment can ship sources without rasters
Some checks are pending
build-gate / build (push) Waiting to run
posters/*.webp is not shipped by default (42MB; bundling pushed the tree to 80MB,
git-packed to 57MiB, and the catalog rejected the push). The DOWNLOAD path has
handled that since d154a76 — PosterWall fetches posters/<id>.html and rasterises
it in the browser. The PRINT path did not: it handed an <img> a url it never
checked, so on a default drop the SAME poster downloaded fine and PRINTED as
"Poster not available yet." That is the bad first impression
IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF called out by name.

Three things were missing; all three are here:

1. printPoster now climbs the same ladder as the download path — probe the webp,
   and only on a miss fetch the .html, rasterise, and verify PIXELS before opening
   the dialog (an SVG in an <img> is an isolated document: a lost stylesheet prints
   a black square with no error). Blob urls are held until unmount, not revoked at
   print time — the preview <img> is still reading from them.

2. publish-html.mjs wrote into public/assets/posters, which is a GENERATED MIRROR
   that every `bun run tree` rmSync's. Its output never survived, which is a large
   part of why the fallback still had no input months after it shipped. Retargeted
   at data/posters, the tracked source the mirror is made from.

3. bundlePosters is one boolean over a directory holding 40.9MB of rasters AND the
   28 sources, so it could only ever ship both or neither — and "neither" is what
   made both full-size paths 404. posterSources:true ships the sources without the
   rasters, the combination that was previously inexpressible. DEFAULT UNCHANGED:
   28 sources are 27.9MB raw (6.9MB packed, measured with pack-objects), real
   weight in a published tree that needs a deployment decision, not a silent one.

VERIFIED AT THE RENDERED LAYER, not by reading source. check:poster-print-fallback
clicks the real control with every posters/*.webp ABORTED at the network layer —
the default-drop condition forced rather than hoped for — and asserts the HUD
enters "generating the poster" and settles clean. Mutation-tested: removing the
fallback produces 4 failures including "no posters/*.html was fetched". The other
branch is verified too — with a webp present it is used directly, zero html
fetches, no rasterise — so the extra probe is paid only on a miss.

★ THE GATE WAS WRONG THREE TIMES FIRST, each time blaming the product for a
harness fault, which is why it now asserts the HUD instead of an internal:
  - it matched /print/i, but the control reads "UPG prayer poster" — the product
    names the ARTEFACT, not the action;
  - it patched Document.prototype in the PARENT realm while openPrintFrame writes
    into the iframe's own;
  - openPrintFrame calls doc.open() AFTER append, and open() RESETS the document,
    discarding a write patched at append time.
Through all three the .webp/.html request counters looked corroborating, because
the wall loads both by itself. The HUD is what a user sees, survives a refactor of
the print internals, and cannot silently miss.

The 28 poster sources are committed because the fallback has no input without
them. city-* posters are deliberately NOT covered: they fetch OSM street tiles at
generation time, so they are not reproducible offline and their rasters must stay.
2026-09-24 08:07:22 +00:00
doxa-maps-88655c70
3d3586b5f9 Commit the map check, so the browser path survives this flight
Every browser check written for this bundle lived in _qa-cycle/, which is gitignored: a
dozen probes proving the pins render, the sheet opens with real links and the deep link
filters the table — all of which disappear with the working tree. check:coverage-consistent
guards the DATA and cannot see the map at all. The repo has carded orphaned gates twice;
this is the same loss one step earlier, a gate that was never committed.

It asserts, against the SHIPPED tree through the production-parity server: the element
mounts and exposes its state handle (a stale-SRI leaf silently refuses to execute, which
looks identical to a mount failure); every pin in the source is RENDERED, not merely
loaded; a tap opens a group with a NAMED language; the table deep-link carries that
group's own ISO code; and the gap filter leaves only tier 0.

★ IT TOOK THREE TRIES TO MAKE IT WORTH TRUSTING, and each failure is the point.

FIRST it was FLAKY — 1 run in 3. Clause 4 waited a fixed 900ms after clicking the filter,
but setFilter is applied asynchronously, so it queried mid-transition and still saw tier 1
and 2. It now waits for the map's own idle event. 5 of 5 runs clean. A gate that fails a
third of the time on correct code is a gate that gets switched off.

SECOND its doctrine check had a SAMPLE SIZE OF 1. It validated the two URLs of the one
group it happened to open — 1 of 2,685 codes — so a jw.org link planted anywhere else
passed cleanly. MEASURED: the injection did not fail the gate until the scan covered the
whole index. It now checks all 3,550 links, and fails if the index carries none at all,
because a scan over nothing passes vacuously.

THIRD, adversarially tested end to end: with jw.org injected on code `tel` the gate exits
1 naming the publisher; restored, it exits 0; the file's md5 is unchanged either side.

Registered in package.json and in verify-browser-all's GATES array — check:no-orphan-gates
confirms it, and check:quoted-scripts passes. Without a server it SKIPS loudly with the
command to start one, rather than passing quietly.
2026-09-24 07:31:01 +00:00
doxa-maps-88655c70
e5c2c77561 all-people-groups can be shared: its own button, verified by a round-trip
Some checks are pending
build-gate / build (push) Waiting to run
It was published, embeddable and reachable with NO sharing path by any mechanism —
no ShareButton, no shareUrl config, no composite parent overriding one on its
behalf — while every other published map had exactly one. A reader who landed on
it could not send it to anyone, which is the point of an embeddable jamstack map.
check:share-policy's known debt falls from 2 published maps to 1.

The button is in a MapToolbar (pure layout shell: position, z-index, mobile
offsets) rather than hand-positioned, so it sits where every other map's controls
sit. has-geocoder=false because this profile mounts no search pill. Only the share
control is added — zoom/location/fullscreen each need a live mapRef this bundle
does not expose, and the card asks for a sharing path, not a toolbar.

NOT gated on appReady, unlike this bundle's legend. Sharing a map does not require
its data to have loaded, and gating it would remove the affordance exactly when a
slow or failed load makes a reader most likely to want the link.

★ WHY A RENDERED CHECK WAS NEEDED. check:share-policy counts <ShareButton> in
SOURCE. Mutation-tested by gating the toolbar off: the policy gate still read
"1 button / own-button" and stayed GREEN while the button was absent from the DOM.
A source count cannot tell a rendered button from an unrendered one.

So this is verified by the EXISTING share-roundtrip-smoke, not a new gate. I wrote
one, then deleted it on finding that smoke does strictly more — it fetches the URL
and REOPENS it, asserting the same custom element renders, which catches SPA
fallbacks and hash-stripping that a link-text check cannot. A second share checker
would be the fourth-print-path mistake this repo has already paid for once.
Result: share URL resolves 200 text/html and reopens as <all-people-groups> with a
rendered canvas.

i18n verified at the rendered layer: under ?lang= the button title reads Share /
Compartir / مشاركة / साझा करें / 分享, so the new affordance is translated in every
locale, RTL included. buttons.share is one of the keys restored to parity earlier.

ALSO FIXED, found by running the suite: smoke:share-all was testing the LEAF urls
of the two 'header'-policy bundles, whose button lives in the MFE shell by design,
so it reported 10/12 with two failures that were the policy working as intended.
Swapped country-posters/countries-map for country-posters-mfe and dropped the stale
my-upg-100-list leaf (upg-100-mfe was already listed). Now 11/11 pass, 0 fail.
check-share-policy records the coupling so the list cannot drift from the policy
again.

prayer-walk-planner stays debt-no-share, deliberately. It builds a personal walking
route from polygons the reader draws; a link carrying none of that arrives as an
empty planner, and a button that reliably produces a blank map is worse than none
because it looks like sharing works. That is a semantics decision, not an
implementation one.
2026-09-24 07:25:19 +00:00
doxa-maps-88655c70
e952e01e4c Assert the packet numbers that were only being printed, and run the gate on every locale
check-packet-render's own header states the law it exists for — "never print a number
without asserting on it" — and two of the nine numbers it printed were decoration.

PAGE SIZE. Printed "612 x 792 pt", never compared. The landscape check only catches
h <= w, so a wrong PORTRAIT size sailed through: A4 is 595x842, taller-than-wide like
Letter, and would have passed on a packet printing with wrong margins on every US
printer. Not hypothetical — @page{size:...} silently falls back in Chromium, which this
repo has already paid for once. Now asserts every page's MediaBox against Letter.

CARD COUNT. `if (!r.cards)` passed a packet carrying ONE card. Counts must stay relative
(a per-zone packet has its own group count), but relative does not mean unasserted:
sheets === 1 + ceil(cards/PER_PAGE) + 2 holds for any zone.

★ That was not enough, and the mutation test is what showed it. Deleting a whole list
sheet removed 15 cards AND one sheet, so the invariant still held — 80 cards / 7 sheets
is valid arithmetic — and the packet PASSED while missing 15 people groups. Internal
consistency is not completeness. So it now cross-checks an INDEPENDENT witness: the
{from}-{to} of {count} header the renderer prints on each list page, where count comes
from groups.length and does not move when a page is lost. It also requires the ranges to
TILE [1..count], which catches a missing MIDDLE page that the totals alone cannot see.

Those headers are TRANSLATED and the languages disagree on order — en/ar read "1-20 of
95" while hi reads "95 में से 1–20" and zh "95 之中的 1–20", total FIRST. A positional
regex would read those backwards. Parsed as digits with max() as the total instead, and
locale digit forms normalised to ASCII, so it is grammar-independent. Verified by
running the missing-middle-page mutation in Hindi as well as English.

MARK 5 WAS ASSERTED BY AN ENGLISH WORD. body.includes('Gadarenes') would go RED on a
correctly-translated packet, and passes today only because every locale falls back to
English for that key — the check and the bug were locked together. The renderer now
emits class="p4-passage"/"p4-credit" as test hooks (the block is styled inline and had
no selector at all), and the gate asserts length >= 1200, which holds in any language.

check:packets-all runs the gate over EVERY generated packet rather than one hand-named
file. The single-file gate SKIPs without an argument, so it was in package.json, passing
in all 11 locales, and in NEITHER gates:pre NOR gates:post — it ran only when someone
typed a filename. Now in gates:post (21/21, 11 packets through a real browser). SKIPs
when no packets are generated, so a clean checkout is unaffected.

Mutation-tested: A4 FAILs, a dropped sheet FAILs, a missing middle page FAILs in en and
hi, a truncated passage FAILs, a removed passage FAILs. i18n verified end to end by
adding a PARTIAL new language as a data block only — 3 of 23 keys — and confirming the
translated strings render, untranslated keys fall back to English, and nothing is blank.
2026-09-24 06:55:58 +00:00
doxa-maps-88655c70
e8e2be1498 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	package.json
#	public/doxa-maps/all-people-groups/all-people-groups.js
#	public/doxa-maps/all-people-groups/all-people-groups/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 06:25:29 +00:00
doxa-maps-88655c70
9056b9329c All 12,379 pins were missing on MapLibre, and now a gate says so
Having found that MapLibre drops a whole layer for one unrecognised paint
property, I swept all 17 isMap leaves under ?provider=maplibre listening to
the map's own error event -- the only place this class surfaces. One more
real instance:

    layers.apg-pins.paint.circle-sort-key: unknown property

circle-sort-key is a LAYOUT property, written into paint. Mapbox tolerated
the misplacement; MapLibre rejected the layer, so every pin on
all-people-groups was absent under the keyless engine while the page looked
perfectly healthy. Moved to `layout`; the sort order itself is unchanged.
Screenshots read on both engines: 12,379 pins, identical.

NEW GATE check:maplibre-drops loads every isMap leaf under maplibre and
fails on the layer/source-dropping error family. It skips cleanly when the
server is unreachable, because an UNMEASURED check is not a FAILED one --
the flaw already filed against check:filter-width.

★ THE NEGATIVE CONTROL WAS VACUOUS TWICE BEFORE IT WAS REAL, and that is
worth more than the fix. The first poison renamed the layout block, which
does not reproduce the bug -- the property simply vanishes, no error. The
second poison was faithful, circle-sort-key back in paint, and the gate
STILL printed PASS. Cause: I rebuilt with `BUNDLE=... vite build` and did
not run `bun run tree`, so the leaf carried stale SRI, the browser REFUSED
the script, no map mounted, and the gate saw zero errors. A check that ran
correctly and measured nothing, reading as green -- the first entry in
AGENT-BRIEF's troubleshooting table, walked into anyway.

After `bun run tree` (check:sri 0) the gate FAILED on the real bug, naming
the exact property, and PASSED again once restored. Both directions seen on
this codebase, which is the only thing that makes its green mean anything.

gates:pre 0, gates:post 17/18 (pre-existing verify/tables-complete),
check:doc-scripts 0, check:no-orphan-gates 0.
2026-09-24 06:24:40 +00:00
doxa-maps-88655c70
4cb04480cc Localize tab labels for it/ro/zh, and prove the catalog is actually read
Some checks are pending
build-gate / build (push) Waiting to run
120 keys across three locales: legend.json (12 each), search.json kinds (10
each) and tabs.json (18 each, previously absent entirely). Each locale follows
its own established vocabulary rather than a parallel one — Popolo/Popor/民族,
matching the terms already used in that catalog's detail and legend strings.
zh stays Simplified here, as the rest of this tree is, even though the prayer
packet's zh block is Traditional: a reader sees 民族 next to 祷告进度, never
the two files side by side.

Ceilings drop 94 -> 54 per locale. regions.json is the only i18n debt left in
the app: 54 keys of full sentences, three locales.

WHY THE NEW GATE. check:i18n-parity compares catalogs TO EACH OTHER, so it
certifies a file no t() call ever reaches — perfect symmetry, zero consumers,
green forever. Grepping for t('tabs. found ZERO call sites and tabs.json was
nearly recorded as dead. It is not: the only consumer builds the key
dynamically (SemanticTreeLegend.vue:40, 'tabs.' + (tab.i18nKey || tab.id)),
which no literal-prefix grep can see. The reverse error appeared in the same
pass — regions. and options. matched only poster-regions.mjs and a JS options
parameter. Static reachability is unavailable in both directions, so
check:tabs-localized renders the labels and reads them.

It asserts DIFFERENCE from English, not mere presence: te() falls back to the
hardcoded English label, so "shows some text" passes on the exact failure
being tested. Mutation-tested three ways — missing catalog FAILs, an all-English
catalog FAILs (first draft PASSed it, taking a per-pair exemption seven times),
one legitimate collision still passes with a note.

Both i18n gates now run in gates:post; neither was wired into the build before.

Also fixed, found by the build rather than sought:
- check-i18n-parity hardcoded an absolute path, so the studio copy silently
  graded wt-packet's locales and reported PASS on a tree it never read. Now
  repo-relative. That exposed the studio's own locale tree as a strict subset,
  103 keys behind including buttons.json:share — the very orphan this gate's
  header says it was written to catch, reappeared unseen in a second repo.
  Synced forward: zero studio-only keys, zero text conflicts, lossless.
- index.json still declared field-access-visas with rows:1146, a leftover from
  the commit that removed that table as private. The generator already filters
  private tables; the committed artifact predated the filter. Removing the
  declaration also removes the row-count leak. Copying the file in would have
  reopened the leak.
- check:sfc-parse needs an installed node_modules and is the first pre-gate to
  fail without one, so a dependency-less worktree reads as a parse error.
  Documented, with the reason switching it to bun only appears to fix it.
2026-09-24 06:23:30 +00:00
doxa-maps-88655c70
ce750f5b55 test(i18n): a key in English must exist in every locale — ratcheted
Some checks are pending
build-gate / build (push) Waiting to run
The failure here is silent BY DESIGN. vue-i18n falls back to English for a
missing key, which is the right runtime behaviour and exactly why catalogs drift
unnoticed: the app looks fine, nothing errors, and a reader who chose Spanish
just gets English words with no signal anything is missing.

Measured when this was written: buttons.json had 21 keys in English and 19 in
every other locale. The orphans were `share` and `shareSteps` — the Share
button's title and its explainer, rendered on EVERY map. Ten locales had been
shipping an English Share button since those keys landed.

★ IT FOUND MUCH MORE THAN THE BUG THAT PROMPTED IT: 429 missing keys across ten
locales, with popup.json existing only in English and it/ro/zh also lacking
regions.json and tabs.json entirely — 101 of 176 keys each.

So it RATCHETS rather than blocks. Each locale has a ceiling; the gate fails
when one gets WORSE and prints a nudge when one gets better so the ceiling can
be lowered. Failing outright on 429 keys of debt this gate did not create would
block every build, and a gate everyone disables protects nothing. A locale with
no ceiling entry must be complete, so a NEW locale cannot be added half-done.

Keys are compared FLATTENED, so a nested object or an array is checked
key-by-key rather than counted as one present key.

Confirmed to fail before acceptance: deleting two French keys reports
"fr regressed: 8 missing key(s), was 6" and names them.
2026-09-24 05:46:13 +00:00
doxa-maps-88655c70
39ec4b42bd test(i18n): every packet language must be SELECTABLE, not merely present
Some checks are pending
build-gate / build (push) Waiting to run
The bug this gates is unreachability, not incorrectness. The packet shipped 11
complete translations while detectLocale() read only <html lang>, which every
generated leaf page hardcodes to "en" — so ten of them could not be reached by
any reader on the static tree. Nothing was wrong; nothing was visible.

Two assertions, because they fail separately:

  RESOLUTION    ?lang= / ?locale= selects a supported locale, an unsupported
                value falls back to English, a region tag (pt-BR) resolves to its
                base, and with no param the host page still wins.
  REACHABILITY  the packet's language list and SUPPORTED_LOCALES cannot drift
                apart, and the leaf pages still declare a lang — so if someone
                later drops that attribute, the host-page path does not silently
                become unavailable while the tests stay green.

★ It evaluates the SHIPPED detectLocale rather than reimplementing it. A gate
that reimplements the thing it tests only ever proves the copy agrees with
itself — which is how a fix can be reverted in the source while the gate keeps
passing.

Confirmed to fail before acceptance: removing the URL branch reports all four
param cases resolving to "en".
2026-09-24 05:36:45 +00:00
doxa-maps-88655c70
c2046be7ec Gate the agreement between the surfaces, after finding the same drift three times
Three defects this flight were the same defect, and none of them threw. Every page
rendered; only comparing two surfaces caught them:

  the country sheet listed 36 groups for Indonesia while the map drew 669 pins, because
  the sheet read a curated subset and the map read the pins;

  the resource table silently dropped 506 real resources across 377 languages, because
  its generator skipped any code no pin speaks — in a table titled "every link";

  resources attached to `und` told 14 people groups there was Scripture in a language
  the record explicitly does not identify.

A fourth is a matter of time, so the comparison becomes a gate rather than a habit.

SIX CLAUSES, all static, 60ms, no browser and no server: no ISO reserved code carries a
resource; every resourced code has a language name; the table's reach columns match an
independent recount off the pins; every resourced code appears in the table; no table URL
exists that the pin index does not hold; and each pin's tier agrees with its code's URLs.
Each one is a defect that was actually shipped, not a hypothetical.

★ IT REFUSES TO PASS VACUOUSLY. Five of six clauses assert a NEGATIVE, which is exactly
the shape that reads green against empty or mis-keyed input, so an empty corpus is a FAIL
rather than a clean run, and a missing input SKIPS loudly with the command to fix it.

★ AND IT SELF-TESTS. `--poison` injects one defect per clause in memory and requires all
six to fail. Clause D is included deliberately: it is the one that would have caught the
506 dropped resources, and the first version of this self-test left it unproven — the
most valuable clause was the one clause never shown to fail. 6/6 now.

Registered in package.json and in build:unlocked, because a gate nothing runs is not a
gate — this repo has carded that twice. check:no-orphan-gates went from FAIL to PASS on
registration, which is the check confirming itself. The CLI doc quotes the build chain
verbatim, so it was updated to match: package.json is what runs, the doc is the copy.

No product file changed, so nothing is rebuilt. check:quoted-scripts, check:doc-scripts,
check:no-orphan-gates and check:mjs-parse all pass.
2026-09-24 05:26:34 +00:00
doxa-maps-88655c70
e263991dc5 test(deck): click the REAL button and read what it prints
Some checks are pending
build-gate / build (push) Waiting to run
Every other packet/card gate drives the RENDERERS. This one drives the UI: it
finds the Card Deck button in the shadow root, clicks it, and captures the
document that reaches the print frame. That is the only path proving the wiring
— records provider, stock picker, language, shared composable, print frame — is
connected end to end. A renderer can be perfect while the button calling it
passes the wrong stock, or nothing at all.

⚠ It intercepts the printed HTML rather than reading the iframe back.
openPrintFrame calls window.print(), which BLOCKS in headless Chromium, and the
frame self-removes on afterprint — reading it back measures a frame that has
already gone and reports 0 cards for a correct deck.

Measured through the real UI: 116 cards at 2.5in 3.5in with 116 QR codes, and
3.5in 2.5in when the stock picker is switched to landscape. Confirmed to fail on
a wrong expected page size.

Also updates the share policy: country-posters moves from 'own-button' to
'header'. Its two-tab MFE now owns sharing, exactly as my-upg-100-list's does —
the gate caught the bundle briefly carrying two affordances after the MFE
landed, which is the regression it was written for.
2026-09-24 05:13:03 +00:00
doxa-maps-88655c70
449adfe515 test(poster): the shared print actions must produce real documents
Some checks are pending
build-gate / build (push) Waiting to run
Gates usePosterPrints.js — the print actions now shared by every poster wall —
by driving them in a browser with a fake records provider, so the assertion is
about the DOCUMENTS rather than any bundle's API being reachable.

★ BOTH DIRECTIONS, because "the button is there" and "the button works" are
different claims and this flight has repeatedly found the first true while the
second was false. With a provider: a real table, correct row count, the prayer
checkbox column present. WITHOUT one: phase "error" naming the missing provider,
not a silent no-op and not a throw.

⚠ IT CAPTURES THE PRINTED HTML INSTEAD OF READING THE IFRAME BACK, and finding
that out cost a wrong first result. openPrintFrame calls window.print() under
autoprint, which BLOCKS in headless Chromium, and the frame self-removes on
afterprint — so reading it back measured a frame that had already gone and
reported 0 rows for a perfectly correct document. Intercepting what the
composable hands to the frame measures what actually gets printed.

Confirmed to fail before being accepted: deleting the checkbox column from the
row template reports "the list print has no prayer checkbox column".
2026-09-24 04:59:45 +00:00
doxa-maps-88655c70
e1553df458 test(provider): ?provider=maplibre must swap the ENGINE, measured by request host
Some checks are pending
build-gate / build (push) Waiting to run
Gates BUG-UPG100-IGNORES-THE-PROVIDER-SWITCH. my-upg-100-list builds its map RAW
with `new gl.Map` instead of going through useMapInstance, so the page-level
switch — which resolves inside that composable — could not reach it. The card
measured 19 requests to api.mapbox.com at ?provider=maplibre while five sibling
leaves swapped cleanly.

★ IT COUNTS NETWORK REQUESTS BY HOST, not globals. `window.mapboxgl` can be
present because the leaf page preloads it while the map is actually DRAWN by
maplibre, and the reverse is equally possible. What the switch exists for is not
loading a proprietary engine and not calling its servers, and only the request
log shows that.

The allowance is DERIVED from the leaf's own HTML — count the distinct
api.mapbox.com urls the generated page declares, plus one for a stylesheet that
gets fetched twice — rather than hardcoded. Asserting merely "fewer than the
default" would pass a half-fix at 18 of 19; hardcoding 5 would pass a leaf that
preloads 4 and leaks 1 runtime call.

★ THE GATE'S OWN FIRST VERSION MEASURED NOTHING AND PASSED. `const URL =
process.argv[2]` shadowed the global URL constructor, so every
`new URL(request.url())` threw, the host tally stayed empty, and it cheerfully
reported "0 mapbox requests" on a page making 19. It now names the variable LEAF,
counts what it could not parse, and FAILS if the default run records no requests
at all — because a silently empty tally is indistinguishable from success.

Passing on three leaves today: upg-100-list 19 -> 5, doxa-simple-map 21 -> 4,
countries-map 24 -> 4, with every residual being the page's own <link>/<script>
preloads. Confirmed to fail when the allowance is tightened by one.
2026-09-24 04:47:54 +00:00
doxa-maps-88655c70
c9ed3b4e9a test(mfe): a two-tab element must mount both panes AND occupy space
Some checks are pending
build-gate / build (push) Waiting to run
Gates the composite MFEs at the rendered layer. The failure it exists for is
BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT: every layer inside these elements is
position:absolute/height:100%, so the chain resolves to ZERO unless :host is
sized. The header renders, the tab strip paints, the panes mount — the element
looks alive and occupies no space. A DOM-presence check passes it.

★ THE HOST HEIGHT IS THE LOAD-BEARING ASSERTION, and finding that out cost a
wrong first attempt. I wrote the gate to check PANE heights, which is the
obvious reading of "zero-height collapse" — then measured it: squashing the host
to 0px leaves its panes still reporting 843px, because they are absolutely
positioned and escape the collapsed box. A pane-height check alone does NOT
catch the bug. Only measuring the host does. The pane check stays as a secondary
signal with a note saying why it cannot be trusted alone.

Also asserts exactly one share affordance (two on one surface shipped once as
BUG-UPG100-DOUBLE-SHARE) and that the strip has the expected number of tabs.

Tabs are selected by role=tab, not by class. '.viewsel .seg' matched the tablist
WRAPPER as well as its buttons, reporting 3 tabs for a 2-tab strip with
concatenated text ("Country PostersMap") — the gate finding a bug in itself.

Passing on all three composites today: upg-100-mfe, regions-mfe and the new
country-posters-mfe. Confirmed to FAIL before being accepted.
2026-09-24 04:35:59 +00:00
doxa-maps-88655c70
8e8e659556 test(share): one decided rule for which maps carry a share button
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "Some maps need a map-level share button and some do not... Decide +
record the per-map rule, then apply it."

The BEHAVIOUR was already right on both maps the card names — Prayer Progress
(an instance of doxa-simple-map) has its button, and UPG-100 deliberately omits
one because upg-100-mfe.vue's header owns sharing, with a comment saying so.
What did not exist was the RULE. Nothing stopped the next agent adding a second
button to a surface that already had one, and nothing noticed a published map
with no way to be shared at all.

Surveying all 11 map bundles found FOUR patterns in use, so the policy is
written as the four it actually needs rather than a binary:

  own-button   the map IS the product surface      6 bundles
  header       a composite header owns sharing     my-upg-100-list
  composite    a parent overrides shareUrl so the
               PARENT is shared, not the child     research-map-plus-grid
  none         unpublished, nothing to share yet   key-cities

★ IT FOUND TWO REAL GAPS the card did not anticipate: all-people-groups and
prayer-walk-planner are PUBLISHED and have no sharing path by ANY mechanism —
no button, no shareUrl, no composite parent. They are recorded as
'debt-no-share' so the gate passes on today's reality while NAMING the gap, and
so the count cannot drift without someone deciding. Silently passing them would
have buried the finding; failing on them would block a green build over
pre-existing debt this card never scoped.

A map bundle with no POLICY entry FAILS, so adding a map forces the sharing
decision instead of deferring it. Both negative cases confirmed to fire before
this was accepted: a second <ShareButton> reports "two affordances on one
surface" (that shipped once already), and removing an entry reports the bundle
as undecided.
2026-09-24 04:15:59 +00:00
doxa-maps-88655c70
d050f7e216 test(zones): census every SELECTABLE zone against the posters built
Some checks are pending
build-gate / build (push) Waiting to run
The failure this catches is a short count with no error. generate.mjs used to
abort on the first bad zone — 16 of 29 written, the rest silently unbuilt — and
separately, tooling that enumerated clusters.json rather than the LEGEND warned
two zones were "MISSING" when nothing was. Both look like success from a
distance; only a census shows them.

★ ASK THE LEGEND, NOT THE DATA FILE. clusters.json carries 30 list definitions;
the legend exposes 28 leaves. deaf-old-world and deaf-new-world-asia-pacific are
collapsed into the unified `deaf` row (affinity bloc A017) — VERIFIED against
live records rather than taken from a comment: 63 + 60 = 123 = A017 exactly,
with zero groups on either side of the difference. They remain in clusters.json
deliberately, because three isolation-proof scripts assert
deaf_lists.length === 2, but no user can select them.

So they are reported as CONTEXT ("superseded, not a gap"), never as a failure.
Counting the file instead of the legend manufactures two phantom failures and
then hides real ones behind them — which is how a genuinely missing poster would
get lost in noise everyone had learned to ignore.

Passing: 28 selectable zones, 28 posters. Confirmed to fail before being
accepted: removing one poster reports that zone by name.
2026-09-24 04:07:44 +00:00
doxa-maps-88655c70
347823a92b test(print): each print path must produce its OWN page geometry
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "make sure poster generation works and the aspect ratio of the print
dialog can be custom based on your print point, so print dialog works for
poster, for vertical packet, and for horizontal cards... all working in the
jamstack maps."

One gate over all four paths, because they share one failure: silently falling
back to letter. Chromium does that for ANY @page size it does not recognise
('A6 portrait' -> 612x792pt, no error, no warning), so every assertion here is
on the MEASURED MediaBox from page.pdf(), never on the stylesheet. A gate that
grepped the CSS would pass a deck that prints as paper.

Measured, all from the shared renderers the UPG-100 page itself imports:
  packet (vertical)   612 x 792 pt   letter portrait
  cards (poker)       180 x 252 pt   2.5 x 3.5in
  cards (landscape)   252 x 180 pt   3.5 x 2.5in
  poster              1500 x 2000 px rasterised, painted

Orientation is asserted separately from size, so a transposed stock cannot pass
by accident, and the poster is checked for ink rather than dimensions because it
rasterises rather than paginating.
2026-09-24 03:55:16 +00:00
doxa-maps-88655c70
d638544870 test(posters): a poster with no pre-rendered image must still download
Some checks are pending
build-gate / build (push) Waiting to run
Gates the client-side poster fallback, and it measures PIXELS rather than
bytes — because the failure mode here is silent and total.

⚠ An SVG loaded through <img> is an ISOLATED DOCUMENT. It cannot see the host
page's CSS, so every var() falls back to its initial value, and `fill`'s initial
value is BLACK. A poster that loses its stylesheet downloads as a black
rectangle with no error, no exception, and a perfectly normal file size. The
prayer packet shipped exactly that bug once already (660 of 661 map elements
painting black, nothing in the console).

So the gate asserts mean luminance and colour variety, not "did it produce
bytes", and it checks the stylesheet actually travelled into the inlined SVG.

Confirmed to FAIL on the real regression before being accepted: with the
<style> block stripped, luminance drops 229 -> 13 and distinct colours 54 -> 2,
and all three assertions fire.

Passing: laos rasterises to 3000x4000, 725 KB, stylesheet inline, luminance 229,
54 distinct colours.
2026-09-24 03:05:27 +00:00
doxa-maps-88655c70
a9faab1662 test(cards): a deck must print as CARDS — assert the measured page size
Some checks are pending
build-gate / build (push) Waiting to run
Gates the prayer-card deck at the rendered layer, and asserts two things a
plausible-looking check would miss.

1. THE MEASURED MediaBox, not the CSS. Chromium SILENTLY falls back to letter
   for a named @page size it does not know — `size: A6 portrait` yields
   612x792pt with no error, while `size: 2.5in 3.5in` yields exactly 180x252pt.
   A gate that grepped the stylesheet would pass a deck that prints as paper,
   which is the entire bug being fixed. It also names the failure: if the page
   comes back 612pt wide it says "this is LETTER: the @page size silently fell
   back".

2. THE VALUES ARE HUMAN-READABLE, not merely present. The first deck rendered
   "LAO", "thm", "EAN" because normalizeRecords() flattens { value, label } to
   the VALUE and parks the label under <field>_label. Counting rows passed it —
   the rows existed, they were just codes on a card meant for praying. The gate
   now flags any field showing a bare 2-4 character token.

Also asserts one card per page, portraits actually loading, a real QR symbol per
card (not an empty path), and no card overflowing its own box.

Expected page sizes are DERIVED from the stock's inches rather than hand-copied:
an earlier constant said A6 was 396.5x559.7pt and FAILED a correct render — the
gate's own arithmetic was wrong, not the renderer.

Verified across all three stocks: poker 180x252pt, postcard 288x432pt, A6
297.12x420pt, 95 cards / 95 pages each.
2026-09-24 02:56:33 +00:00
doxa-maps-88655c70
097ab02458 test(packet): gate the packet the READER gets, not the one Node writes
Some checks are pending
build-gate / build (push) Waiting to run
check:packet-render verifies a packet FILE from the build-time generator. This
verifies the BROWSER path — the legend's Export > Prayer packet action, which
imports library/packet/packet-render.js, fetches live records and prints
in-place.

Both callers now share that one renderer, and this gate is what proves the
shared module actually runs in a tab. A module can be perfectly correct under
Node and still fail in a browser: a bare process.env reference, a Node-only
import, a JSON import assertion. Node passing says nothing about that.

It drives the real thing: the renderer over HTTP, the app's own normalizer
(the API returns { posts, total } — hand-rolling that extraction is how a probe
ends up testing something the product never does), live records, then
page.pdf() and a PDF page count.

Measured on the browser path: 8 sheets -> 8 pages at 612x792pt, 95 people
cards, 12 wheel segments, Mark 5:1-20 in full, exactly 2 metacamp.org
attributions, 0 page errors.

I18N is asserted here because it is a product requirement, not a nicety: pass a
language and the gate reports which headings came back translated. Verified with
a partial Spanish block — "12 Puntos de Oración" and "Rueda de Oración" rendered
while Mark 5 and both attributions correctly fell back to English. Fallback is
PER KEY, so a half-finished translation renders its finished keys and never
blanks. The stub block was removed after verifying; shipping it would imply a
Spanish translation that does not exist.

Parameterised rather than pinned: PACKET_ORIGIN / PACKET_API / PACKET_ISO3, and
the language as argv. Needs a static server over the repo root.
2026-09-24 02:44:06 +00:00
doxa-maps-88655c70
4d9ac89f6e test(packet): gate the prayer packet at the RENDERED layer
Some checks are pending
build-gate / build (push) Waiting to run
Every bug this gate exists for produced a file of the right size, with no
error, no broken image, and a generator reporting success. They were only
visible once a browser laid the document out, so this drives a real browser
and reads computed style rather than inspecting the HTML source.

★ The law it encodes: never print a number without asserting on it. The
throwaway probe this replaces PRINTED "PDF pages: 16" for several runs while
reporting "packet verified", because the failure counter never looked at that
value. A number that is displayed but not asserted on is decoration.

Two regressions it catches, both confirmed to FAIL before this was accepted:

  transform instead of zoom -> 16 PDF pages for 8 sheets
  map stylesheet removed    -> unresolved --paper/--sea/--frameline;
                               660/661 map elements fill BLACK

The second is the subtler one. The map SVG carries almost no presentation
attributes; it is styled from the poster document's stylesheet. Lift the
<svg> without those rules and SVG fill falls back to its initial value, which
is black — the whole map paints as a solid rectangle with nothing to see in
the DOM, no console error, and no broken image. It is asserted as a
WHOLE-MAP property because the first attempt tested one hand-picked <rect>
(the one just repaired) and went green while 647 others were still black.

Page count is load-bearing rather than cosmetic: the packet is printed
front-and-back, so "one sheet is exactly one page" is a correctness property,
not a tidiness one.

Counts are relative (sheets vs pages, cards vs photos) so the gate is not
pinned to one zone's group count, and the packet path is an argument or
PACKET_HTML rather than a hardcoded path. With no packet given it SKIPS
rather than failing, since the packet is generated on demand.
2026-09-24 01:53:21 +00:00
doxa-maps-88655c70
a334e8ebff Merge main into bundler-1/work
Main moved 20+ commits while the regions work was on its own branch. Every
conflict was a GENERATED file — manifest.json, the root page, and the
regions-dashboard bundle's .js and leaf pages. Zero source conflicts: all six
of my source files (the two regions .vue, the new map child, fix-sri.mjs,
sri-consistency-check.mjs, package.json) auto-merged.

Generated files are not hand-merged. They were resolved to main's side and then
REGENERATED from the merged source — BUNDLE=regions-dashboard vite build, then
bun run tree, then bun run fix:sri. `bun run tree` rewrote every bundle's pages
and produced byte-identical output to main's committed ones for all twenty
other bundles, so the only files this commit changes beyond the merge are the
six that had to be rebuilt. That agreement is also evidence main's committed
tree matches main's source.

The pinHint:false regression I saw before is gone: main's 566ad46 committed the
two metas that were previously only on disk, which was the root cause — a tree
regeneration now preserves them instead of dropping them.

Re-verified against the merged library, which is the part that mattered: main
changed useMapInstance.js, StackLayout.vue and ViewSelect.vue, all of which the
regions element sits on. All three suites still pass — the map draws 934 pins /
51 outline features for a region and 61 / 5 for a country, the pin mode still
follows the subject, the country page still renders CountryDetail's shape, and
every width still matches DX-life's container formula at six viewports.

23 of 25 gates green. The two that fail are pre-existing and not in this diff:
check:library-index is an mtime artifact (regenerating the index produces
byte-identical content), and check:backlog-status reports 6 of 546 non-enum
statuses in the shared _qa-cycle board, which lives outside every repo.
2026-09-24 00:33:28 +00:00
doxa-maps-88655c70
20a3d3d245 Run the verification chain in phases instead of one long queue
The 29 gates in build:unlocked cost 84.5s chained with &&, but 21 of them
finish in under 700ms — they were queueing behind check:mjs-parse (26.9s)
and check:parse (18.7s) on an otherwise idle 8-core box. The 22 vite builds
cost 129.0s and were never ordered at all: vite.config.js gives each bundle
its own outDir with emptyOutDir:false and emits nothing outside it, so they
only ever needed to not overwrite each other.

internal/run-parallel.mjs runs a set of independent steps concurrently and
grades the set by exit code. build:unlocked now reads:

  gates:pre -> library-index -> clean -> token -> build:bundles -> tree
            -> gates:post -> gallery

Measured end-to-end, both chains green, two rounds each, clean+token
excluded as common to both: 165.7s -> 65.3s mean (170.7/160.8 vs 60.2/70.5).

No gate was removed, weakened, sampled, or made conditional on what changed.
The step set is identical — 31 in, 31 out, asserted at edit time — and each
step runs as the same command graded by the same exit code. Only start times
moved. The && BETWEEN phases is load-bearing and stays: a source gate still
blocks the build, and the build still blocks every gate reading its output.

Within a phase the runner reports ALL failures rather than stopping at the
first, so three broken gates cost one fix-and-rerun cycle instead of three.
It imposes no timeout of its own, because a command killed by a short
timeout is not a failure.

Negative-tested three ways, each required to exit 1: a failing gate inside a
phase, a red phase halting the chain, and a failing vite build inside
build:bundles. The bundle case also confirmed the runner enumerates
plugin-designs live (it picked up 23 dirs, not a cached 22).
2026-09-23 20:57:13 +00:00
doxa-maps-88655c70
6734be1ad6 Repair SRI hashes without a full rebuild
check:sri failed whenever a single-bundle `BUNDLE=x bun x vite build` left a
leaf's baked integrity behind its bundle — and the gate then told the agent the
fix was `bun run build`, whose first step deletes public/doxa-maps/ and tears
the tree out from under every concurrent reader.

Adds `bun run fix:sri`: rewrites the disagreeing hashes in the built tree and
nothing else — leaf <script integrity>, manifest bundles[].integrity, and the
paste-verbatim embed snippets. 0.4s, writes only the offending pages, and the
bytes it writes are identical to what the full build wrote.

check:sri's failure text now prescribes that instead of a rebuild, and its
header no longer claims generate-tree runs only inside the full build (it does
not; `bun run tree` re-bakes the hashes too, at the cost of rewriting 1234
files under public/ and re-reading .vue source every run).

Negative-tested: `node internal/fix-sri.mjs --self-test` builds a poisoned
synthetic tree, requires check:sri to go RED on it first, then green, then a
second repair to write zero files.
2026-09-23 20:35:07 +00:00