The rebase conflicted in nine public/ files — build OUTPUT, not source. Neither
side of such a conflict is authoritative, so the resolution is to rebuild rather
than hand-pick hunks: these nine differ from what the manual resolution produced,
which is precisely why the rebuild was required before pushing.
No source file conflicted except package.json, where upstream had added
check:maplibre-drops and this branch check:packets-all. Both are kept, and
gates:post is upstream's 20 steps plus this branch's one addition — not either
side's version of the list.
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.
The expanded filters panel was the largest interactive surface never
compared to theirs, and comparing it found a whole panel absent.
Measured on their live country page: TWO .filters__panel blocks and
THREE .filter-toggle buttons — Adopted, Engaged, Exact Search — against
our one panel and zero toggles. The dropdowns already matched exactly,
four of them, with their country dropdown correctly hidden by
lockCountryCode.
A tell I had missed: the search already read active.exact for the Fuse
threshold, so Exact Search's BEHAVIOUR was ported while the control
that sets it never was.
Ported their panel, their three toggles, their toggle semantics (a
second click removes the filter), their activeFilters shape so each
toggle also renders as a chip, and .filter-toggle styled from the
MEASURED computed values of their live button rather than by chasing
tokens. The labels needed no new keys — Adopted and Engaged were
already translated in all eight locales.
Two of their bugs are cloned deliberately, both measured on their live
/regions/india at 855 rows:
- ENGAGED RETURNS ZERO. Their branch filters on u.people_praying, and
people_praying is not in the field list they request — they fetch
people_committed. The field is always undefined, so the filter
matches nothing: their page goes 855 to 0. Reading people_committed
instead would make us differ, and the ruling is that what exists on
DX-life must match. The upstream fix is one word in their file.
- THE ACTIVE STATE IS WHITE ON WHITE. Their rule sets background-color
var(--color-surface-brand-lighter, rgba(0,80,160,0.05)) with color
var(--color-white), and --color-surface-brand-lighter is not defined
in their token files. Their active button computes background
rgb(255,255,255) and color rgb(255,255,255) — an invisible label,
now visible in our screenshot too. Defining that one token upstream
fixes both sides.
Both are flagged for the Driver rather than quietly corrected: a bar
chart was already removed under this ruling for the opposite reason,
being in ours and not theirs.
Proof: _probe-filter-toggles.mjs, all assertions pass — two panels,
three toggles, nine measured style properties, Adopted narrowing 24 to
2 and appearing as a chip, a second click restoring, Engaged emptying
to 0 exactly as theirs does, Exact Search changing nothing alone.
Regression: 22 suites green.
Run against ?provider=maplibre the gate reported
FAIL — the map never populated (no rd-bubbles features after 90s)
about a dashboard that visibly renders all 2107 dots with a working
choropleth. It reads the source's live GeoJSON off `_data`, a private Mapbox
internal. MEASURED on one dashboard, one build, only the engine differing,
by calling the app's own setData() to shrink 2107 features to 5:
mapbox _data 2107 -> 5 _options.data 2107 -> 2107 (_data is LIVE)
maplibre _data never an FC _options.data 2107 -> 2107 (NEITHER is live)
So on MapLibre no private field tracks setData and the wait can never
succeed. It now diagnoses the engine and SKIPs with the reason. Same
false-FAIL shape removed from check:filter-width last tick: a control that
accuses correct code is worse than no control.
★ DELIBERATELY THE SMALL FIX. Re-expressing the assertion on the public
querySourceFeatures() would change the gate's reported counts, and the two
earlier attempts at that made things worse -- one failed BOTH engines by
preferring the construction-time _options.data, the other called a
node-side helper from inside a page-evaluated string where it is simply
undefined. Every line of page code added here is self-contained for exactly
that reason.
THREE CONTROLS:
maplibre -> SKIP, exit 0, naming why it cannot count
mapbox -> PASS, exit 0
--self-test -> gate REJECTED the reverted build, catching
'circle-color is the flat literal "#ef6c00"'
The third is the one that matters: the gate still catches the bug it exists
for rather than having been quietened.
This removes the LIE, not the GAP. MapLibre dashboards remain unverified by
this gate; BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE stays open, downgraded to
P3 and re-scoped to the coverage question.
The gate printed "FAIL — 2 host(s)" when it had measured NOTHING —
ERR_CONNECTION_REFUSED on :4173, "UNMEASURED" twice, then a failure. That
accuses the product of a regression on the strength of a missing process,
and it already cost one wrong report in this flight: the gate was reported
as failing when it had simply never run.
★ THIS DOES NOT REVERSE THE AUTHOR'S RULE. "An unmeasured host is a failure.
A gate that quietly drops a host reports success about something it never
looked at" is correct for a STRUCTURAL miss — the page loads and the trigger
or tab cannot be found, meaning the gate has been defeated — and that path
is kept, comment and all. Only connection-level errors are separated out.
It is also not a judgement call, because the house already decided: four
sibling browser gates skip on an unreachable host and say so in the same
words — check-fits-the-screen, interop-harness, check-catalog-identity and
check-legend-matches-source all print "SKIP — ... unreachable right now.
Not a product finding." This gate was the outlier.
THREE CONTROLS, ALL FIRED:
no server -> SKIP, exit 0
server + healthy code -> PASS, exit 0
real regression, the original
flex RANGE put back on
.filter-dropdown -> FAIL, exit 1 ("the row grew 369px")
The third is the one that matters: it proves the gate can still catch the
bug it exists for, rather than having been quietened.
Before believing any of those browser measurements I ran `bun run tree` and
confirmed check:sri 0 — a single-bundle build leaves stale SRI, the browser
refuses the script, and the page then measures clean for the wrong reason.
That trap made a negative control vacuous earlier in this flight.
The two repos carry the same work under different commit hashes, so this
branch had drifted 30 commits behind while looking 8 ahead. Pushing it would
have DELETED 315 lines of newer upstream work, including
check-maplibre-layer-drops.mjs entirely and the current versions of six
library/ files.
Both conflicts were textual, not semantic: package.json's script map is
key-for-key identical to remote's (verified before resolving, not assumed),
and check-packet-render.mjs is byte-identical on both sides — add/add
artifacts of two repos that built the same file in separate histories.
This also retires the divergence finding committed a moment ago. It was
measured against this repo's STALE checkout rather than against main; the
merge brings library/ to byte-identical with wt-packet and
check:poster-prints — which failed with "printPacket is not a function" —
now passes. The durable part of that finding is the absolute-path bug, and
that is already recorded where it matters, in the gates themselves.
check:i18n-parity, check:locale-reachable, check:poster-prints,
check:tabs-localized and check:no-abs-paths all pass on the merged tree.
check-locale-reachable and check-poster-prints defaulted REPO to a hardcoded
wt-packet path, so like check-i18n-parity before them they graded the OTHER
repo's files and printed PASS for a tree they never opened. These two are the
third and fourth instance of that pattern. Now repo-relative, so
check:no-abs-paths — which DOES gate this repo's build — stays green.
Reading its own tree, check-poster-prints now fails honestly:
usePosterPrints.js here has no printPacket. That is real, and it is NOT fixed
here, because the fix that worked for the locale catalogs would be destructive
for this code. All six diverged library/ files have lines that exist only in
this repo — two-way divergence, not staleness — so a wholesale copy would
delete studio-side work. What made the catalog sync safe was the measurement
that nothing would be lost, not the resemblance.
Recorded in internal/FINDING-library-diverged-from-wt-packet.md with the
per-file line counts and the decision a resolver needs to make. None of these
four gates run in this repo's gates:post, so nothing is newly blocked; they are
runnable by hand and now honest, which beats passing by reading another repo.
Six conflicts, all generated artifacts, resolved by rebuilding the
PosterWall consumers and regenerating the tree.
Main brought two things that land on this branch's work: another
builder made check:i18n-parity's DOXA_REPO default portable, which is
the wt-packet default this branch flagged last tick, so the gate now
measures the repo it lives in; and a new check:tabs-localized proves a
catalog is actually REACHED rather than merely symmetric. That gate
covers SemanticTreeLegend's tabs.json, not this element's SELECT VIEW
strip, so the strip's three strings remain uncovered and untranslated.
Every vs-live diff so far ran at LTR, so RTL was the next coverage gap —
and it is where layout actually breaks, because a physical `left`
silently survives a direction flip while a logical
`inset-inline-start` does not. Their CSS uses the logical form; ours had
physical `left` in three direction-sensitive places.
The new probe compares each element's offset from the INLINE-START edge
— the left edge in LTR, the right edge in RTL — which is the one number
that goes wrong when a physical property survives. Measured against
their live /ar/regions/cameroon:
- .search-icon sat 566px from the inline-start edge against their
19.5px: it had stayed on the physical left, which is the inline END in
Arabic. Replaced with their rule verbatim (blocks/_form.scss:119 —
inset-inline-start:1em, top:0/bottom:0, margin:auto 0, 1em square),
which also fixed a second difference, since our fixed 18px square
could not track the input's type where theirs is 1em. Ours now
measures their exact 19.5px.
- The country bullet kept left:-16px, so in RTL it detached on the
wrong side — computed right 256.938px instead of a -16px overhang.
- The chooser's region legend was pinned to the physical left. That one
is a sanctioned addition rather than a clone, but a legend stuck on
the physical left in an RTL layout reads as broken whoever designed
it.
Reading the Arabic screenshot then showed every heading, strapline and
map note in Arabic and RTL while the SELECT VIEW strip still read
"Regions List" — the one piece of chrome that never localised, because
regions-mfe hardcoded its labels and imported no i18n at all. Now
routed through t(), with the legend going through ViewSelect's existing
prop so the shared component and its three consumers stay untouched.
No catalogue keys were added, deliberately. Their common.json has
"Regions" and "View" but none of the composites, so there is nothing to
copy; translating our own additions is not this agent's call; and
check:i18n-parity tracks a per-locale debt ceiling — it/ro/zh sit at
exactly 94 missing because DX-life translates 8 of our 11 locales — so
English-only keys would push them to 97 and fail it. The shim falls
back to the key, so today's rendering is byte-identical. Proven rather
than assumed: a sentinel injected into ar/regions.json rendered on tab
1 at locale=ar while tab 2 stayed English, then was reverted.
Two of my own assertions were wrong first. The probe set
profile-config.locale on every custom element, which on the composite
means three regions-dashboard elements — host and both panes — and
writing a fresh {locale:'ar'} onto a pane that had inherited its config
by provide() clobbered it, leaving every root LTR; I nearly "fixed" RTL,
which works on all three leaves. And checking inset-inline-start in an
LTR context is meaningless, since it merely mirrors left.
Worth flagging: check:i18n-parity's DOXA_REPO defaults to wt-packet,
another worktree, so it must be pointed at this repo to mean anything.
Regression: 22 suites green.
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.
This repo's check-i18n-parity hardcoded an absolute path to wt-packet, so it
read THAT repo's locales and reported PASS on a tree it never opened. Making it
repo-relative revealed this tree was 103 keys behind in it/ro/zh and short in
every other locale too — including buttons.json:share and shareSteps, the exact
orphan the gate's own header describes it as having been written to catch. The
bug it was built for had reappeared in a second repo, invisible.
The tree is a strict subset of wt-packet's: zero keys exist only here, zero
translations disagree. So this is a lossless forward copy, not a merge. All 11
locales now carry 10 of 11 areas; regions.json in it/ro/zh is the only debt.
Adds check-tabs-localized, which renders the tab labels and asserts they CHANGE
with the locale — key-parity alone cannot distinguish a shipped translation from
a dead catalog.
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.
Six conflicts, all generated artifacts under public/, resolved by
rebuilding the three PosterWall consumers and regenerating the tree
rather than hand-merging built files.
Main brought the i18n catalogues to parity, and another builder has
translated this branch's three poster-dropdown strings into all seven
non-English locales — so the 'en only' caveat on that feature is now
closed. Main's two new gates, check:i18n-parity and
check:locale-reachable, both pass against the merged catalogue.
344 -> 282 keys. de and hi reached zero and join ar/es/fr/pt/ru as entries
DELETED from the DEBT map: the gate treats an absent locale as "must be
complete", so removal is what makes a finished translation permanent.
The three that remain (it, ro, zh) are each missing WHOLE catalog files rather
than stray keys, which is worth stating in the file — it tells the next person
these are real translation work, not the propagation gaps the last two ticks
closed.
Confirmed the guarantee before relying on it: deleting one Hindi tab key reports
"hi is missing 1/176 key(s) and has no DEBT entry" rather than passing.
Found by a COVERAGE AUDIT, not a screenshot. region-vs-live diffs
/regions/africa and country-vs-live diffs only UupgsList's nine
elements, so the country page's head and the region card had no
property diff at all. Adding both produced four defects sharing one
root cause: DX-life renders these as <a>, we render them as <button>
because this element has no router, and a button inherits neither the
UA underline nor their `a` rule (_globals.scss:132 — color
var(--color-brand-light), font-weight medium,
text-decoration-thickness 2px, text-underline-offset 4px).
- The dashboard back-link was #4e594f with no underline against their
#cbc5b9 underlined. Their .light-link recolours to
--color-brand-secondary = $color-beige-300 = #cbc5b9, exactly the
measured rgb(203, 197, 185). Wrong on BOTH our region and country
pages.
- The region-card link was font-weight 400 against their 500.
- Its icon spacing came from gap:10px where theirs comes from
.with-icon .icon.right { margin-inline-start: 1rem } — the same 10px
on screen, but their rule would never have applied to our markup, so
the mechanism is now theirs.
- The country entries were rgb(42, 51, 42), our body ink, against
their rgb(78, 89, 79) = $color-green-400 = --color-brand-light.
Their country links carry no class at all — they simply take the `a`
rule, which is why '.country-link' matched nothing on their side and
the element read as unmeasured until the selector became
'.countries-grid a'.
Coverage added so none of this can come back: a new
_probe-country-head-vs-live.mjs at 29/29 (back-link, h1, strapline, map
note, search-box container); the back-link added to region-vs-live,
which went 73 to 79 IDENTICAL; the search-box CONTAINER added to
country-vs-live, 28 to 32, so the 50ch cap cannot regress; the region
card added to list-vs-live at 30/30.
Three of my own assertions were wrong before the code was: I omitted
fontWeight and the underline geometry from the back-link spec while
their `a` sets all three; I compared `gap` on an element where theirs
never sets it; and my sub-pixel tolerance used Number('246.938px'),
which is NaN, so NaN <= 0.5 was false and it silently never fired.
Regression: 22 suites green, 9 gates green.
344 -> 282 keys. de and hi reached zero and join ar/es/fr/pt/ru as entries
DELETED from the DEBT map: the gate treats an absent locale as "must be
complete", so removal is what makes a finished translation permanent.
The three that remain (it, ro, zh) are each missing WHOLE catalog files rather
than stray keys, which is worth stating in the file — it tells the next person
these are real translation work, not the propagation gaps the last two ticks
closed.
Confirmed the guarantee before relying on it: deleting one Hindi tab key reports
"hi is missing 1/176 key(s) and has no DEBT entry" rather than passing.
Continues paying the debt the parity gate measured, cheapest first.
German and Hindi carried an IDENTICAL 22-key gap — the same legend and search
keys, added to English after those two catalogs were last touched. One batch of
work closed German entirely and took Hindi to 18, which was its whole missing
tabs.json. Both are now complete: 344 -> 282 keys.
Terminology follows each catalog's OWN existing vocabulary rather than inventing
a second one for the same concepts — de keeps Volk/Sprachfamilien, hi keeps
जनसमूह/भाषा परिवार. Tab labels stay SHORT because they sit in a narrow rail:
Spanish already uses "Pueblos" rather than a literal rendering, so Hindi does
the same. UUPG, WAGF and DOXA remain acronyms, as in every locale.
Seven locales (ar es fr pt ru de hi) are now DELETED from the gate's DEBT map
rather than set to zero — an absent entry means "must be complete", so deletion
is what converts finished work into a permanent guarantee. Verified: removing
one Hindi tab key reports "hi is missing 1/176 key(s) and has no DEBT entry".
Verified at the RENDERED layer: "View country posters" comes back as
"Länderposter ansehen" (de) and "देश के पोस्टर देखें" (hi), zero page errors —
which also confirms Devanagari shapes correctly in the APP, not only in the
prayer packet where the font was first vendored.
The three remaining (it, ro, zh at 94 each) are missing WHOLE FILES —
regions.json and tabs.json — so they are real translation work rather than the
propagation gaps closed here.
The ceilings drop as the debt is paid: 429 -> 344 keys, and ar/es/fr/pt/ru
reached zero.
Those five are REMOVED from the DEBT map rather than set to 0. The gate already
treats a locale with no entry as "must be complete", so deletion is what
converts a finished translation into a permanent guarantee — setting 0 would
read the same but leaves a line inviting someone to raise it.
Confirmed the guarantee works before relying on it: deleting one Spanish key
reports "es is missing 1/176 key(s) and has no DEBT entry" rather than passing.
The ceilings drop as the debt is paid: 429 -> 344 keys, and ar/es/fr/pt/ru
reached zero.
Those five are REMOVED from the DEBT map rather than set to 0. The gate already
treats a locale with no entry as "must be complete", so deletion is what
converts a finished translation into a permanent guarantee — setting 0 would
read the same but leaves a line inviting someone to raise it.
Confirmed the guarantee works before relying on it: deleting one Spanish key
reports "es is missing 1/176 key(s) and has no DEBT entry" rather than passing.
Working the 429-key debt the parity gate surfaced last tick, cheapest first.
popup.json existed ONLY in English and French — the country popup's four labels
(WAGF Region, WAGF Bloc, Country, UUPG Count), which is what a reader sees when
they click a country on the map. Now in all 11. WAGF and UUPG stay as acronyms
in every language, following what French already did: they are proper nouns of
the dataset, not words to translate.
Then six keys that were missing from EVERY non-English locale identically —
added to English at some point and never propagated: legend header.default,
search sections.within/allData, and the three country-poster strings in
regions.json. The {selection} placeholder is preserved.
Result: ar, es, fr, pt and ru are now COMPLETE, and 429 -> 344 keys of debt.
Those five are deliberately REMOVED from the gate's DEBT map rather than set to
zero — a locale with no entry must be complete, so none of them can silently
regress. Verified: deleting one Spanish key reports "es is missing 1/176 key(s)
and has no DEBT entry".
Verified at the RENDERED layer, not by counting files: "View country posters"
comes back as "Ver los carteles del país" under ?lang=es and "Показать плакаты
страны" under ?lang=ru, zero page errors.
★ One key I translated turned out to be DEAD: legend.header.default is consumed
by nothing — my first probe appeared to test it and was actually matching the
substring "Legend" inside this profile's hardcoded LEGEND_TITLE ("My UPG 100
List — Legend"). A probe that matches an artifact proves nothing; the assertion
has to be a string only the translated key can produce.
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.
Generated output only — three bundles rebuilt and the tree regenerated so the
SRI hashes match the shipped scripts. Re-verified rather than assumed:
check:i18n-parity and check:deck-click both pass afterwards.
Last tick made the 11 locales selectable. That immediately exposed what was
sitting behind the door: the wall's labels — Poster Map, Prayer List, Card Deck,
Print Options, Show on map, Close — were hardcoded English, so a reader who
chose Spanish got Spanish page furniture around English buttons.
All six are now keys in buttons.json, translated across all 11 locales, plus the
aria-label which interpolates the item name rather than concatenating it.
★ AND IT CLOSED TWO OLDER ORPHANS. buttons.json had 21 keys in English and 19
everywhere else: `share` and `shareSteps` — the Share button's title and its
five-step explainer, rendered on EVERY map. Ten locales had been shipping an
English Share button since those keys were added, invisibly, because vue-i18n
falls back to English rather than erroring. Both are translated now.
Verified in a real browser across four languages: the wall's buttons read
"Lista de oración | Mapa del cartel" under ?lang=es, "Liste de prière | Carte de
l'affiche" under ?lang=fr, and the Arabic text renders with dir=rtl — zero page
errors in each. The Share button's title follows too: Compartir, Поделиться.
The wall's `t` and `locale` come from the SAME useI18n instance, so a label and
a printed card deck can never disagree about which language the reader chose.
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.
My previous commit changed the REPO fallback to
resolve(dirname(fileURLToPath(import.meta.url)), '..') but did not add the
fileURLToPath import, so the gate died at load with
ReferenceError: fileURLToPath is not defined
and I pushed it. node --check passed because that is a RUNTIME error, not a
syntax one, and check:no-abs-paths went green because the offending literal
really was gone -- so both of the checks I ran said yes while the gate
itself could no longer run at all. The script's own exit code printed 1 in
the same output I pushed from, and I did not stop on it.
Verified this time by running the gate, not around it:
check:locale-reachable 0, check:no-abs-paths 0.
Fourth instance of one pattern in this flight, after check-zone-census,
check-share-policy and check-poster-prints: a new internal/ gate hardcodes
its author's worktree (/home/.../wt-packet) as the REPO fallback, and
check:no-abs-paths -- which runs inside build:unlocked -- turns red for
every other builder on the next merge.
DOXA_REPO still wins and is untouched; only the default becomes portable,
via the idiom check-no-absolute-paths.mjs prints in its own failure text.
Verified both ways: check:no-abs-paths 0, check:locale-reachable 0 on the
derived default.
Four occurrences is a template problem, not four accidents.
The region fills did not paint under MapLibre, and three ticks of
instrumenting addLayer() found nothing because the failure never travels
through addLayer's return path. Listening to the map's own `error` EVENT --
the only place it surfaces -- gave it in one run:
layers.regions-fill.paint.fill-emissive-strength: unknown property
layers.regions-border.paint.line-emissive-strength: unknown property
*-emissive-strength is a Mapbox GL v3 lighting property. MapLibre rejects a
layer whose paint carries an unknown property and DROPS THE WHOLE LAYER:
addLayer() accepts the call, emits an error event, and silently omits the
layer. getLayer() then returns undefined and the map renders without it, so
it reads as "the fills do not paint" rather than as a failure.
★ THE REPO ALREADY KNEW THIS, FOR CIRCLES ONLY. useMapProvider.emissivePaint()
exists and its own docstring says "the whole layer is dropped -> invisible
pins" -- but it was hardcoded to 'circle', while the fill, line and
clustering layers still wrote the literal and kept being dropped for exactly
the documented reason. Generalised to emissivePaint(map, kind) and applied
at all five literal sites: useMapLayers x3, useCountryOutline,
useMapClustering. One helper, one rule, no sixth copy.
A second defect was hiding behind the first: regions-border still declared
'source-layer' on the geojson source ("Source layer country_boundaries does
not exist on source"), which only became visible once the paint error
stopped masking it. Fixed the same way as regions-fill.
MEASURED on the built tree: 0 error events, both layers present, and the
screenshot shows the WAGF fills painting under MapLibre -- green, blue,
purple, red, orange -- matching the Mapbox control, which is itself
re-shot and unchanged. Provider sweep 15/17 (the 2 render no map at all),
gates:pre 0, gates:post 17/18 with only the pre-existing
verify/tables-complete failure.
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".
Generated output only. Regenerating rather than hand-merging keeps the tree a
true function of the merged source — and refreshes the SRI hashes, which is not
optional: a stale hash makes the browser refuse the script and the page mounts
NOTHING, silently. Re-verified afterwards rather than assumed: check:locale-reachable
and check:deck-click both pass.
The packet ships in 11 languages, every string translated. detectLocale() read
ONLY document.documentElement.lang — and EVERY generated leaf page in this repo
hardcodes lang="en" (measured: 20 of 20, later 34 of 34 after the tree grew).
So on the built static tree — the jamstack case these bundles exist for — ten of
the eleven translations could not be reached by any reader. Correct, complete,
and invisible. Several ticks of translation work landed behind a door with no
handle.
?lang= / ?locale= is now consulted FIRST, then the host page, then English. A
query parameter is the smallest thing that makes them reachable: no per-language
page, linkable and shareable, and the WordPress/Polylang path through <html lang>
is untouched — a gate assertion keeps the leaf pages declaring a lang so that
path cannot silently become the only way in. Both spellings are accepted because
both are in use here (`lang` in profile configs, `locale` in the i18n layer) and
guessing wrong is a silent no-op.
⚠ AND MAKING ARABIC REACHABLE EXPOSED THE NEXT PROBLEM. Only ONE component reads
RTL_LOCALES today, so selecting ar would have given Arabic TEXT inside a
left-to-right layout everywhere else — readable glyphs in the wrong place, which
reads as a styling quirk rather than a bug. createAppI18n now reflects an RTL
locale onto document.dir. It only ever SETS it, never clears it: a host page that
already declares dir owns that decision, and stomping it to "ltr" for an English
map embedded in an Arabic page would break the page around us.
Verified: ?lang=ar sets dir=rtl in a real browser; ?lang=zz correctly falls
through to English. detectLocale is exercised against its SHIPPED source across
7 cases including pt-BR -> pt.
★ One thing this tick taught me the hard way: rebuilding a bundle WITHOUT
regenerating the tree leaves the leaf page carrying the OLD SRI hash, so the
browser refuses the script and NOTHING MOUNTS — silently. I spent three probes
reading an empty page before checking the integrity attribute.
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".
Six conflicts, all in generated artifacts under public/, including this
bundle's own — another builder's tree regeneration had rebuilt it.
Regenerated rather than hand-merged: the three PosterWall consumers
rebuilt, then the tree and the hashes.
PosterWall.vue itself auto-merged with both sides intact — this
branch's embedded/sectionHeads props and main's usePosterPrints
refactor — and the four suites that exercise it through the regions
poster dropdown were re-run green after the merge, since their change
lands inside the component this feature renders.
The packet is the product's core artefact, and it moved into the shared
composable this tick. The gate now drives printPacket and asserts what came out:
4 sheets for 2 groups (map one-pager + one people page + prayer points + prayer
wheel), 12 wheel segments, Mark 5:1-20 present. Confirmed to fail before
acceptance — removing the wheel page reports 3 sheets and 0 segments.
⚠ THE HARNESS HAD TO RESOLVE THE RENDERERS, and the first run exposed why. The
composable imports them by RELATIVE path; under a blob URL there is no directory
to resolve '../packet/...' against, so the action failed with "Failed to resolve
module specifier" and printed nothing — reported as 0 sheets, which reads like a
product bug. It is not: Vite resolves them and the built bundle inlines both
renderers (verified in the shipped my-upg-100-list.js). The harness now
substitutes blob URLs for packet-render, card-render, qr and the strings catalog,
so the gate exercises the REAL renderers rather than a stub.
Continues the consolidation. The card deck moved to usePosterPrints last tick;
the PACKET export in upg-100-list.vue was still a second hand-maintained copy of
the same shape — its own renderer import, its own strings fetch, its own
language resolution. Two copies of one fact drift.
printPacket now lives in the composable, so the browser export and the
build-time generator share one renderer AND one calling convention. What stays
in the bundle is what genuinely cannot be shared:
packetRecords who is in this list — the people-groups API, the selector
evaluator, and the pg_slugs special case that 12 of the 27
lists need (the evaluator intentionally THROWS on that type)
packetMapSvg where this bundle's posters are served from. Absent is fine:
the packet prints a cover without the map rather than failing
⚠ AND THE MIGRATION WOULD HAVE MADE THE PACKET PRINT SILENTLY. The composable
reports through its own exportState; this profile has no HUD for that and shows
`status` instead — exportState appears ZERO times in the file. Without a bridge
there would be no "building…", and a failure would vanish with nothing said: the
mechanism working while the feedback does not, which is the exact trap the wall
consolidation hit from the other direction. A watcher now maps the composable's
phases onto `status`.
Verified: check:poster-prints drives the shared action and asserts the packet's
SHAPE — 4 sheets for 2 groups (map + people page + points + wheel), 12 wheel
segments, Mark 5:1-20 present — not merely that bytes were produced. Confirmed
to fail before acceptance by removing the wheel page (3 sheets, 0 segments). The
build-time generator still passes check:packet-render at 8/8, and
check:deck-click still prints 116 cards through the real button.
The packet is the product's core artefact, and it moved into the shared
composable this tick. The gate now drives printPacket and asserts what came out:
4 sheets for 2 groups (map one-pager + one people page + prayer points + prayer
wheel), 12 wheel segments, Mark 5:1-20 present. Confirmed to fail before
acceptance — removing the wheel page reports 3 sheets and 0 segments.
⚠ THE HARNESS HAD TO RESOLVE THE RENDERERS, and the first run exposed why. The
composable imports them by RELATIVE path; under a blob URL there is no directory
to resolve '../packet/...' against, so the action failed with "Failed to resolve
module specifier" and printed nothing — reported as 0 sheets, which reads like a
product bug. It is not: Vite resolves them and the built bundle inlines both
renderers (verified in the shipped my-upg-100-list.js). The harness now
substitutes blob URLs for packet-render, card-render, qr and the strings catalog,
so the gate exercises the REAL renderers rather than a stub.
Measured first, as the Oswald lesson requires, and this one was real:
their UupgsList root carries worldmap.svg inline (UupgsList.vue:451),
and it genuinely renders on their live country page — HTTP 200, 62,740
bytes over the wire, computed background-size 60%, position 50% 0%,
repeat no-repeat, min-height 400px. Confirmed by screenshotting the
ELEMENT, not the viewport: the watermark sits below the fold, so a page
screenshot shows nothing at all.
public/assets/images/worldmap.svg is their file byte-for-byte — 311KB on
disk, small against this tree's existing assets, which run 644KB to
42MB. The inline background-image takes its base from a new imagesBase
prop, since a dropped bundle cannot assume their absolute /assets path.
generate-tree's staged allowlist needed 'images' added — the same trap
that silently broke the vendored fonts. A dir absent from the allowlist
404s, and an unfetched background-image is quieter than the font
failure was: nothing paints and nothing reports. So the probe asserts
the REQUEST returned 200 rather than that the CSS names the file.
Then, reading the screenshot after every number was already green, two
more defects:
- A CLASS WITHOUT ITS RULE. The search label uses their class name,
sr-only — the right instinct — but their rule
(utilities/_visibility.scss:2) was never ported, so the class matched
nothing and the label rendered as a visible line of text above the
input, duplicating its own placeholder. Borrowing a class name only
works if its declarations travel with it, and a class matching no rule
is invisible to a property diff. Now 1x1px and clipped, still in the
DOM and still announced.
- A PIXEL CAP WHERE THEIRS IS RELATIVE. .search-box was capped at a
hardcoded max-width:480px; theirs composes .max-width-md, which is
max-width:50ch (utilities/_sizes.scss:11). Wrong value and wrong unit —
ch tracks the input's font, px does not — and ours truncated the
placeholder mid-word. 50ch now resolves to exactly 600px, their
measured width.
Regression: 18 suites green, including a new _probe-watermark.mjs at
15/15.
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.
Generated output only — built bundles, SRI hashes, leaf pages and the manifest —
conflicted because another agent's work landed in the same files. Resolving
those by hand yields a tree matching neither agent's source, so this takes
main's versions and regenerates. Re-verified rather than assumed: check:deck-click
and check:mfe-two-tab both pass afterwards.
Third instance of one pattern in this flight, after check-zone-census and
check-share-policy: a new internal/ gate authored in one worktree hardcodes
THAT worktree (/home/.../wt-packet) as its REPO fallback, and
check:no-abs-paths -- which runs inside build:unlocked -- turns red for
every other builder on the next merge.
Same minimal fix each time: DOXA_REPO still wins and is untouched; only the
default becomes portable, via the idiom check-no-absolute-paths.mjs prints
in its own failure text. Verified both directions: check:no-abs-paths 0, and
check:poster-prints itself 0 on the derived default.
Three occurrences is a pattern, not bad luck. Whoever is generating these
gates should take the portable default as the template, or the next one
lands red too.
Finishes the half this card left open last tick. The shared print composable
existed but the 903-line 100-list wall did not use it, so the card deck lived in
TWO places — I wrote both this flight, and they could already drift.
Two duplicates removed:
printCardDeck now delegates to usePosterPrints, sharing THIS wall's
exportState so the HUD keeps rendering one surface. The bundle
still owns what cannot be shared — fetchListRecords knows the
people-groups API, the selector evaluator and the pg_slugs
special case — and hands it over as the `records` provider. The
preview-only "no API configured" hint is preserved, because the
shared action cannot know about it.
openPrintWindow was a local reimplementation of printInPlace.js — same
off-screen iframe, same doc.write, same autoprint script,
differing only in an unused `title` argument. A FOURTH copy of
one mechanism is how a fix to the blank-first-page behaviour
reaches one caller and not the others. Now a one-line adapter.
★ AND check:share-policy CAUGHT A REGRESSION I HAD CAUSED. Adding the
country-posters two-tab MFE last tick gave that bundle a second ShareButton —
the BUG-UPG100-DOUBLE-SHARE state the 100-list already fixed once. Fixed the
same way the 100-list fixed it: the MAP's button is REMOVED and the MFE header
owns sharing. Removed, not flag-gated — a `v-if="false"` button is dead code
that still reads as an affordance to every reader and every counter, which is
exactly why the gate counts tags rather than renders.
Verified by clicking the REAL button in a browser, not by driving the renderer:
116 cards at 2.5in 3.5in with 116 QR codes, and 3.5in 2.5in when the stock
picker is switched to landscape — so records provider, picker, language, shared
composable and print frame are connected end to end. check:mfe-two-tab still
reports exactly one share affordance on the country-posters MFE.
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.