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.
Arabic is one of the eleven locales, and RTL is the one layout property that can be
declared correctly and still not take effect. The gate asserted
document.documentElement.dir === 'rtl' — necessary, and not sufficient. Any inner
rule can override it while every other assertion here stays green, and an Arabic
packet with readable glyphs in LTR order reads as a styling quirk rather than the
bug it is.
It now samples up to 25 leaf text runs inside .sheet and measures the painted
extent against the element box. An RTL packet must pin its runs to the RIGHT edge,
an LTR one to the LEFT. The signal is unambiguous: en 0 right / 25 left,
ar 25 right / 0 left. Threshold is 80% of sampled runs, so one legitimately mixed
line cannot trip it.
★ MEASURE THE INLINE TEXT RUN, NOT THE BLOCK. My first probe compared each
element's box to its parent's and returned IDENTICAL numbers for Arabic and
English — it was measuring container geometry, which does not move when direction
flips, and would have "passed" an RTL packet laid out LTR. A Range over the text
node gives the painted extent, which does move. Centred text is skipped because it
proves nothing either way, and so is text that fills its box — no room to tell.
MUTATION-TESTED with the exact failure this guards: injected .sheet{direction:ltr}
so dir="rtl" REMAINS DECLARED on <html> while the layout reverts. FAIL, both
symptoms named — "only 0/25 sampled runs do (right 0, left 25) — dir=\"rtl\" is
declared and not taking effect", and "25/25 sampled elements compute
direction:ltr". All 11 locale packets pass with it live.
Verified a NEW RTL LANGUAGE end to end: Urdu added as a DATA BLOCK ONLY — 2 of 21
keys, no code change — renders dir=rtl with 25/25 runs painted right, 95 photos,
translated keys in Urdu, untranslated falling back to English, zero blank headings.
The packet's i18n contract holding for a language that did not exist when the tick
began.
The same defect had surfaced in FOUR gates across previous ticks — a gate keyed on
English prose silently excludes every translated surface, or goes red on a
correctly translated one. Finding a fifth by accident would mean the pattern was
still being discovered rather than eliminated, so this sweeps all 25 gates that
drive a browser instead of waiting for the next one.
9 flagged, 8 benign: CSS class names (.vtl-fab, .stl-panel) and CSS property VALUES
(/paint|layout|strict|content/) are locale-independent by definition.
ONE REAL HIT — check-poster-prints.mjs:131, `/Gadarenes/.test(...)`. That is the
World English Bible's proper noun, so the assertion and the bug were locked
together: it passes today ONLY because point4_passage is still English in all ten
non-English blocks. The day a licensed translation lands it would have gone red on
a CORRECT packet. Now asserts the .p4-passage block by length, the same structural
fix already applied in check-packet-render and check-packet-browser.
Proved on the case the old matcher got wrong: renaming Gadarenes -> gadarenos, as
any translation would, now PASSES where /Gadarenes/ would have failed.
MUTATION-TESTED both directions — a passage truncated to 180 chars FAILS ("looks
truncated: 259 chars"), and the .p4-passage block renamed in the MARKUP FAILS
("no .p4-passage block").
★ ONE MUTATION SILENTLY DID NOT LAND, and it is worth recording. My first attempt
at the second mutation used a count=1 replace, which hit the explanatory COMMENT
rather than the markup — "p4-passage" appears twice in packet-render.js. The gate
"passed" and I nearly recorded a hole in the assertion. The assertion was sound;
the mutation was not. The sweep made the same class of error in the other
direction, flagging check-packet-render for a "Gadarenes" that lives only in a
comment explaining why it was removed.
Five instances found and fixed across five ticks; none remain in live code. The
rule for any new gate: if it matches on user-visible text, resolve the expected
string from the catalog for the page language, or assert on STRUCTURE — a class
hook, a length — instead.
This gate printed nine metrics, folded six into `const bad = a || b || ...` — so a
failure said "FAIL" without naming which — and left three unchecked: page size and
the two headings, which are the very strings its own header claims it covers ("the
gate reports which headings came back translated"). Reports, not asserts.
★ IT LOOKED LIKE IT HAD FOUND A PRODUCT BUG, AND HAD NOT. Under ?lang=es it printed
an EMPTY wheel heading; under hi it blanked BOTH — while the catalog holds "Rueda de
Oración" and "प्रार्थना चक्र". That reads exactly like a broken i18n path, and it
PASSED anyway, because nothing asserted the values.
The cause was the harness. wheelTitle was innerText.match(/^.*Wheel.*$/m) and
pointsTitle /Prayer Points|Puntos/ — neither can match Hindi, and /Wheel/ cannot
match Spanish. The product was correct in every locale all along: verified after the
fix in en/es/hi/ar/zh, RTL and Devanagari included. Had I reported that as a defect
I would have been wrong.
mark5 carried the same fault: innerText.includes('Gadarenes'), an English proper
noun that goes RED the moment point4_passage is translated. Now asserted by
.p4-passage length — the same fix already applied in check-packet-render.
Headings are now compared to the CATALOG entry for the packet's language, with
per-key fallback to English exactly as the renderer resolves it. packet-render.js
gains class="points-title"/"wheel-title" as TEST HOOKS: both were inline-styled with
no selector, which is what forced the English greps in the first place.
MUTATION-TESTED: a renderer forced to ignore the language FAILS with both headings
named and the expected values shown; a renderer emitting an empty heading FAILS
("a blank heading is worse than an English one").
★ ONE MUTATION CORRECTLY PASSED, and it marks a real limit: reverting es
wheel_title to English IN THE CATALOG passes, because rendered and expected then
legitimately agree. This gate cannot tell a translated catalog from an untranslated
one — that is check:packet-strings-parity's job, and it does assert it. Two gates,
two questions.
Partial translation re-verified through the BROWSER path: a 1-of-21-key Swahili
block renders "Mambo 12 ya Maombi" with the wheel heading falling back to "Prayer
Wheel" — translated where present, English where not, neither blank.
That makes four gates with one root cause (tabs-localized, packet-render,
deck-click, and this one twice). Any new gate matching on user-visible text should
resolve it from the catalog.
Two gaps on the same surface.
1. "116 portraits" was collected, printed, and NEVER asserted — only the QR codes
were checked against the card count, so a deck of 116 cards with ZERO portraits
passed. A prayer card without a face is a data tile, which is the whole difference
between the deck and a spreadsheet. Not hypothetical: card-render.js records that
the deck once shipped with no photos at all, because image_url was never requested
in fetchListRecords.
THE COUNT CANNOT BE PINNED — roughly half the groups have no portrait and the CDN
drops some — so two invariants instead: portraits + typographic === cards (a card
that is NEITHER means a third shape nobody checks), and a deck drawn from records
carrying image_url must have SOME, with DECK_EXPECT_PORTRAITS=0 to declare a
deliberately photo-less deck out loud.
2. The gate matched /card deck/i, so it could ONLY run in English. Pointed at
?lang=es it reported "no Card Deck button" and listed 28 buttons all reading
"Baraja de tarjetas" — the UI correctly translated, the GATE unable to see it. The
card deck is a core prayer artefact and had zero coverage in any of the ten
non-English locales, which is exactly where a rendering bug is most likely. The
label now resolves from the catalog for the page's own locale.
MUTATION-TESTED THROUGH THE REAL CAUSAL PATH, not synthetic DOM edits:
- image_url removed from the records provider (the historical regression):
FAIL, "116 cards and NOT ONE portrait", naming the cause.
- the no-photo branch renamed so cards are NEITHER shape:
FAIL, "116 cards but 0 portrait + 0 typographic = 0".
- DECK_EXPECT_PORTRAITS=0 on that same deck: PASS, sum invariant still holding.
- es cardDeck changed in the CATALOG without rebuilding: FAIL — so this now also
catches catalog/bundle drift instead of passing on a stale bundle.
VERIFIED IN FOUR LOCALES, each matching its OWN label — en "Card Deck",
es "Baraja de tarjetas", ar "مجموعة البطاقات", hi "कार्ड सेट" — 116 cards, 116
portraits, 116 QR codes, @page 2.5in x 3.5in, 0 page errors in every one.
Same two patterns have now recurred three times: a count printed without an
assertion, and a gate keyed on English text that silently excludes every
translated surface. Worth checking for on any new gate.
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.
renderFor() had ZERO call sites and was the only caller of makeHiddenMap() and
loadPoster(); EXPORT_SPEC was never read. The whole chain dated from when this wall
rendered a poster by spinning up an off-screen map and compositing tiles. Export and
print now take the shipped .webp, or rasterise posters/<id>.html in the browser.
52 lines out, 17 of explanation in: 925 -> 888.
★ IT WAS NOT MERELY UNUSED — IT WAS CORRUPTING A GATE. isMapLeaf() in
generate-tree.mjs is a REGEX over FILE TEXT, so the engine handle inside the
unreachable function flagged this profile isMap=true. That flag made
verify-map-paint.mjs EXCLUDE the profile, with a written justification —
"constructs a real gl.Map ON DEMAND during poster export, off-screen at
left:-9999px" — where every clause was true of the SOURCE and false of the RUNNING
PRODUCT. A profile was excluded from a paint gate on the strength of code that never
ran, and the exclusion read as a measured product fact.
Deleting the chain flips the manifest to isMap=false, which is correct: the poster
wall is a wall of cards, not a map. The gate's comment now says it is absent because
it IS NOT A MAP, rather than because it is a map the gate cannot see.
⚠ The explanation had to be written carefully. Naming the engine handle whole in a
comment RE-TRIPS the same regex and reinstates the bug, so it is spelled in two
pieces on purpose with a note saying why — the same class as asserting Mark 5:1-20
via the English word "Gadarenes": describing a thing in the terms the detector scans
for makes the description itself the defect.
ALSO FIXED, found while verifying: check:poster-print-fallback probed
'../assets/posters/<id>.html', one level up. A leaf lives at
<tree>/<bundle>/<profile>/index.html and resolves assetsBase to '../../assets', two
levels up — so on a tree that DID carry the sources the probe 404'd and the gate
SKIPped with "posterSources:true was not set" when it had been. A precondition check
that is wrong about the precondition silently turns the whole gate off.
VERIFIED AT THE RENDERED LAYER after the deletion, not before: the print path still
rasterises with every .webp aborted ("generating the poster", clean terminal state),
and the card deck still prints 116 cards at 2.5in x 3.5in with 116 QR codes and 0
page errors.
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.
regions.json for it/ro/zh, 54 keys each. check:i18n-parity now reports "0 key(s) of
known translation debt remain", closing the 429-key gap it found when it was written.
★ THE DEBT MAP IS EMPTY RATHER THAN FULL OF ZEROS, and that is the whole point of
this ratchet: a locale with NO entry must be complete — the gate fails it outright
for a single missing key — so an empty DEBT is the strongest state it can be in, not
an unset one. Writing { it: 0, ro: 0, zh: 0 } would mean the same today and quietly
permit a regression the moment someone "adjusted" a number. Mutation-tested both
ways: removing ONE key fails ("it is missing 1/176 key(s) and has no DEBT entry"),
deleting the whole file fails (54/176).
THE CARD'S ESTIMATE WAS WRONG AND IS CORRECTED. It recorded "54 keys of full
SENTENCES". Measured: 45 short labels (median 14.5 chars) + 9 longer strings, three
carrying %count%/%countries%/%region%/%country% interpolation tokens. The catalog is
keyed by its own English TEXT, not symbolic keys — which is exactly why the gap was
invisible: the fallback renders the English key itself, so key and value are
identical in English and nothing ever looks broken.
PLACEHOLDERS HELD BY A PROGRAMMATIC CHECK, not by care: every %token% in every
English value must appear, same set, in all three translations. 0 mismatches, 0
empty values. Mangling one breaks interpolation at runtime with nothing to catch it.
VERIFIED AT THE RENDERED LAYER on the regions-list leaf, en/it/ro/zh: labels
translated (Regions -> Regioni), the intro sentence translated, and the INTERPOLATED
count sentence correct — "315 people groups in 41 countries" -> "315 popoli in 41
paesi", both placeholders substituted with real numbers. Zero page errors. Each
locale asserted to DIFFER from English, so a silent fallback could not pass.
★ That sentence is the exact bug regions-list.vue:144 records: the Russian string was
"ALREADY in library/i18n/locales/ru/regions.json — copied, never wired", caught by
reading a screenshot rather than by any assertion. Now it is asserted.
zh is SIMPLIFIED, measured not assumed: the existing zh catalogs carry 33
Simplified-only characters against 4 Traditional. Vocabulary follows each locale's
own established terms — Popolo / Popor / 民族, Regione / Regiune / 区域 — and WAGF
stays an acronym, as fr and de already had it.
check-packet-render printed "95 cards (0 photos, 95 typographic)" and PASSED, in
every locale. The packet spec names the photos explicitly, so that was the last
unasserted number on this surface.
The cause was my own fixtures, not the product: I regenerated the packets with
NOPHOTOS=1 two ticks ago for speed. Regenerating properly gives 95 photos, 3.5MB,
0 broken. But a gate that passes a packet violating a core spec line is the defect
whoever made the artefact, and this one would not have noticed a real photo-fetch
failure either.
★ THE COUNT CANNOT BE PINNED, so this is two invariants rather than "=== 95".
Roughly half the groups genuinely have no photo, the CDN drops some — de came back
94/95 on this very run, which is exactly why a hard count would have been wrong —
and NOPHOTOS=1 is a SUPPORTED dev mode for fast layout iteration. So:
1. photos + typographic === cards. Every card is exactly one shape; a card that
is neither means the renderer emitted a third nobody is checking.
2. a photo-enabled packet must have SOME photos. Zero is acceptable only when the
run declares itself photo-less via PACKET_EXPECT_PHOTOS=0 — "deliberately
none" said out loud, rather than indistinguishable from "silently none".
MUTATION-TESTED BOTH. Removing a single .card img yields "95 cards but 94 photo +
0 typographic = 94". The photo-less packet FAILS by default and PASSES under the
opt-out. (My first attempt at the sum mutation silently did nothing — the card
blocks are 11,340 chars and I edited a 1,200-char window — so the assertion only
counts as tested because the second attempt targeted the real .card img.)
All 11 locales regenerated WITH photos and re-swept: 11/11 pass. i18n re-verified
end to end — a partial Swahili block, 3 of 23 keys, added as DATA ONLY with no code
change: translated keys render in Swahili, untranslated fall back per key to
English, zero blank slots.
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.
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.
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.
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.
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.
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.
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.
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.
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 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 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.
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.
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.
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".
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.
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.
The pre-bundler upg-neighbor IIFE holds five lost experimental maps --
send-map, upg-neighbors (cockpit), lang-cluster, bloom-cockpit,
flower-mesh. One wrapper bundle now rescues all five as preset
instances, each mounted in a sandboxed iframe whose host page:
- loads our self-hosted MapLibre and aliases it as the mapboxgl
global with a patched Map constructor that rewrites the one
mapbox:// style to keyless OpenFreeMap Positron -- the whole set
runs WITHOUT a Mapbox token;
- shims relative /api fetches to the live API and sets
MAP_APP_API_URL (the cockpit fails closed without it);
- passes the legacy profile-config contract (the elements share the
modern loader lineage).
Wrapper reads profile-config as a PROP (direct-defined element --
inject('profileConfig') is null outside ProfileLoader; the send-map
default masked the miss because send-map is instance #1). The
vocabulary patch rides in the shipped copy ('Lower pressure').
Verified by screenshots read: send-map renders the full 4-D SEND JOIN
app; flower-mesh blooms (India, 2,040 UPGs); the cockpit renders map,
charts and bloom pane. Auto-flows into the SPA's Experimental section
via manifest discovery.
Identical defect to check-zone-census.mjs, landed minutes apart: line 34
fell back to resolve('/home/lk10x/.../wt-packet'), an absolute path into one
author's worktree. check:no-abs-paths rejects those by design -- "a clone
must work on any machine" -- and it runs inside build:unlocked, so main went
red for every builder again.
Same minimal fix: DOXA_REPO still wins and is untouched; only the FALLBACK
moved to resolve(dirname(fileURLToPath(import.meta.url)), '..'), the idiom
check-no-absolute-paths.mjs prints in its own failure text and every sibling
script here already uses.
VERIFIED both ways: check:no-abs-paths 0, and check:share-policy itself 0
running on the derived default rather than erroring on a missing directory.
This is now the second instance of one pattern in a single tick, so it is
recorded in the rollout ledger as fleet-wide debt rather than just fixed
twice: a new internal/ gate authored in one worktree hardcodes THAT worktree
as its default, and lands red for everyone else.
check:no-abs-paths started failing the moment check-zone-census.mjs arrived
from main: line 24 fell back to
resolve('/home/lk10x/.../wt-packet') -- an absolute path into one author's
worktree. That gate's whole rule is "a clone must work on any machine", and
it sits inside build:unlocked, so main went red for every builder rather
than only for whoever lacked that directory.
The override was never the problem: DOXA_REPO still wins and is untouched.
Only the FALLBACK moved, to the idiom check-no-absolute-paths.mjs names in
its own failure text and every sibling script here already uses --
resolve(dirname(fileURLToPath(import.meta.url)), '..'). The data it reads,
plugin-designs/my-upg-100-list/data, is present in this repo, so the default
now resolves for anyone who clones it, and a caller pointing at a different
worktree still exports DOXA_REPO exactly as before.
VERIFIED both directions: check:no-abs-paths 0, and check:zone-census itself
0 running on the derived default rather than skipping.
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.
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.
Some languages need a face the system stack does not carry — Hindi renders as
tofu boxes in Georgia/Palatino/serif. When the packet declares one, a failed
@font-face is SILENT: the text is present, the strings compare equal, every
other assertion stays green, and the page prints as empty rectangles.
The gate now asserts every declared face reaches status "loaded". Confirmed to
fire before being accepted: corrupting the inlined base64 reports
'Noto Serif Devanagari:error — text in that script will print as empty boxes'.
★ THIS ASSERTS THE MECHANICAL HALF ONLY, deliberately. A general tofu detector
was attempted twice and abandoned, and the attempts are worth recording:
- comparing shaped width against a deliberately-missing font FALSE-POSITIVES on
every system-covered script, because Cyrillic, CJK and Arabic legitimately
measure the same in both stacks when both resolve to the same system font;
- an ink-column-profile heuristic scored CORRECTLY RENDERED Devanagari the same
as tofu (3 vs 2 distinct column heights), because its baseline bar gives a
uniform profile.
Both would have blocked correct Arabic, Chinese, Russian and Hindi packets. A
detector that fails working output is worse than no detector, so glyph
correctness stays a human reading a screenshot, and the gate covers what it can
check without lying.
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.
An Arabic packet without dir="rtl" renders readable glyphs LEFT-aligned. Every
other assertion in this gate passes on that: the strings are translated, the
rhythms are present, the pages paginate. It reads as a styling quirk rather than
the bug it is, and nothing here would have caught it.
The gate now checks the rendered <html dir> against an RTL language set, in both
directions — an RTL language must be dir="rtl", and an LTR one must not be.
Confirmed to fire before being accepted: an Arabic packet relabelled dir="ltr"
reports 'lang="ar" is right-to-left but dir="ltr"'.
The gate asserted a hardcoded English word list for the prayer rhythms — which
tests the opposite of what i18n means. It passed a packet whose translation
silently did nothing, and FAILED every correctly translated one: the Spanish
packet reported "only 0/11 prayer rhythms present" while rendering all twelve
correctly as ALABANZA / ESPERAR / CONFESAR.
Expected words now come from the strings catalog for the packet's OWN language,
resolved per key with English fallback — the same contract the renderer honours.
So the gate asserts the PROPERTY (all rhythms present, in this language) rather
than a vocabulary. The catalog path is derived from the packet's own location
instead of a machine-specific constant, with PACKET_STRINGS to override.
Adds the assertion the tick exposed: a non-English packet must ACTUALLY DIFFER
from English. That failure is invisible to every other check, because an
all-English packet is a perfectly valid packet — a mis-keyed block, a block under
the wrong ISO code, or a renderer ignoring its lang argument all produce one.
Confirmed to fire before being accepted: a packet relabelled lang="de" with no
de block reports "lang=de but no string differs from English".
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.
The field-access-visas table (580 KB -- how workers enter closed
countries) was found PUBLIC in three copies and LIVE-SERVED at 200:
the key-cities bundle data dir, the research out/ dir, and a stale
staging tree inside the served catalog. Root cause: emit() enforced
PRIVATE_TABLES but the ROUTE bundle-copy loop did not -- a privacy
rule enforced at one of two writers is not a rule. The loop now skips
PRIVATE_TABLES; the redundant source copies are deleted (data-private/
keeps the one canonical file); and the hygiene gate flags the
filename and visa_ fields as HARD findings in every mode -- this data
class never rides the strict-tier softening.
All three public copies and the live URL were purged first and
verified 404 by anonymous fetch before this commit.
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.
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.
Two widenings from the 2026-09-24 anonymous-clone audit:
1. LEAK_STRICT: role nouns (the Driver/Driver), private repo/org names,
forge/platform names, operational-context vocabulary, CI workflow
paths. Every one was found live in a public mirror before this rule
existed. WARNINGS in the normal build (the source tree still
carries them; failing build:unlocked overnight gets a gate
disabled), FINDINGS under --strict -- which the publish/export flow
must run: publishing is a gated action, not a habit.
2. A credential is a DOCUMENT, not a string: every JWT-shaped value's
payload is decoded and name-like claims flagged. The pk token
accepted by the 2026-09-21 ruling carries a u claim naming a real
account -- the ruling covered the token's scope, not the identity
inside it.
Both modes negative-tested: strict exits 1 against the current tree
(110 findings), normal exits 0 (118 warnings).