out/packets/ is gitignored, so check:packets-all grades whatever .html files
happen to be sitting there — an accidental set, not a current one.
On this machine that was 11 freshly generated packets. In
doxa-maps-plugin-studio, which shares main with this worktree, it was two
NOPHOTOS=1 layout probes of mine from earlier ticks, and the sweep was RED there
for anyone who ran the wave. The gate was honest about each file it checked; the
set it checked was accidental.
The underlying problem is that a fast layout-iteration build and a shippable one
were byte-indistinguishable. The packet carried lang and dir and nothing about
its own provenance, so the only way to tell them apart was for the person
running the gate to remember and pass PACKET_EXPECT_PHOTOS=0.
packet.mjs now stamps data-packet-build with the flags it generated under, and
the gate reads it. The GENERATOR writes it rather than the gate inferring it:
had the gate guessed "no photos present, so probably a probe", it would have
excused genuine photo-fetch failures. Tested explicitly — a packet stamped
"full" whose map svg is then stripped STILL FAILS. The stamp records intent and
cannot excuse a defect the build did not declare.
Deliberately not a timestamp. An mtime staleness rule was considered and
rejected on evidence from this same session: packet-strings.json was 303s newer
than the packets while byte-identical to HEAD, because a probe had restored it
from backup, so an mtime rule would have failed a correct tree — the exact
mistake fixed two commits ago in the library-index gate. Content comparison is
not available here (regenerating hits a live API and takes minutes), so the
build records its intent instead of the clock.
The studio sweep goes from "2 checked, 0 passed" to "1 checked, 1 passed", with
the packet self-declaring build="nophotos nomap". Env vars stay as an override
for pre-stamp artefacts, and the declared-deliberate note now names whichever
source actually declared it rather than always crediting the env var.
All 11 locales stamp "full". i18n re-verified with a partial block (1 string,
3/12 points, 6/12 rhythms): wheel 6 translated and 6 English, 12 point rows with
6 translated, 0 blanks. Wave 34/34, packets-all 11/11 here and 1/1 in the studio.
check-packet-render has called page.pdf() for a long time and then read only
MediaBox and /Type /Page. Eight blank sheets of the right size would have passed
every assertion in it.
That matters because the packet exists to be PRINTED — it is a standalone file
with photos and fonts inlined precisely so it prints offline — and until now
every check was on-screen. A print-only CSS rule can strip content that the
on-screen checks still see.
The first mutation is the argument for the whole change. Adding
`@media print{.card img{display:none}}` left the on-screen line reading
"95 photos, 0 typographic" — every pre-existing check green — while the PDF
carried 2 images instead of 85. The second, hiding sheet contents in print,
fires all three new assertions at once on a packet that is still 8 correctly
sized pages.
Images are compared against DISTINCT srcs, not cards. A PDF de-duplicates
identical images, and laos has 95 cards and 95 <img> but only 85 distinct
sources, because ten groups share a portrait — a fact about the upstream photo
set, not a rendering fault. Comparing against 95 would have reported "8 photos
dropped at print time" on a correct packet. I nearly did exactly that.
A scare investigated and cleared rather than reported: the PDF names only
DejaVuSerif, which looked like the vendored Devanagari face failing to reach
print. It is not. Under print media emulation the stack still resolves to
"Noto Serif Devanagari", and the same string measures 210px in it against 420px
in plain serif, so the face is genuinely in use and the PDF names are Chromium
subset aliases. Corroborated independently: hi is the largest packet at 3.2MB
with the fewest faces at 7, which is one big vendored face, not a fallback. A
ToUnicode-based check was discarded after calibration found only 30 Latin
codepoints in a known-good English PDF — an incomplete parser whose
zero-Devanagari result proved nothing.
All 11 locales embed fonts and carry every photo. i18n re-verified through the
new assertions with a partial block (2 strings, 4/12 points, 5/12 rhythms):
2.8MB, 11 fonts, 87 images, wheel 5 translated and 7 English, 12 point rows,
0 blanks. The studio's declared map-less photo-less packet still passes — the
image assertion skips at 0 distinct sources, so the opt-outs compose. Wave 34/34.
`✓ laos 95 groups 8 pages 0 photos` — for a packet whose first page is blank.
The page count stayed 8 because a blank sheet is still a sheet.
The map one-pager is lifted from out/posters/<zone>.html, which a separate
manual step writes. When it is absent packet.mjs deliberately still builds, and
that fallback is right — but it was silent. The empty-groups branch three lines
above already warns and skips; the map branch had the same shape and no warning.
A gate catching this is not the same as the tool reporting it. The gate added
last tick runs later, optionally, and only when someone invokes it. The
generator prints its tick at the moment of production, which is the one output
an operator is guaranteed to see.
Now it prints ⚠ with the reason inline and repeats it in an end-of-run summary
naming every affected zone and the fix — a per-line mark scrolls off in a
27-zone run, and this is the one fault that cannot be seen by counting pages.
Both silent branches are covered, distinctly: a missing poster reports "no
out/posters/laos.html"; a poster that exists but carries no <svg> — a truncated
generate run — reports "out/posters/laos.html has no <svg>". An operator can
tell "never generated" from "generation broke".
Also adds the `packets` script. Its absence is why the posters -> packets
dependency was never expressible anywhere: packet generation was only ever a
hand-typed node invocation, so nothing could state what it reads.
Measured in both repos. doxa-maps-plugin-studio has 0 generated posters against
wt-packet's 28 — out/ is gitignored in both, so posters are build artifacts and
this is a missing build step, not a repo defect. A fresh studio run now warns;
wt-packet prints a clean tick. The size difference corroborates: 0.4 MB with the
map, 0.1 MB without.
i18n re-verified end to end since this touches the generator: a partial locale
block (2 strings, 3/12 points, 4/12 rhythms) renders the wheel 4 translated and
8 English, 12 point rows with 4 translated, 0 blanks, clean tick. All 11 locales
regenerated with photos — packets-all 11/11, each 12/12 wheel labels, 12/12
points, 13 geography paths. Wave 34/34.
Their chevron is a single <use href="/assets/icons/arrow-chevron.svg#chevron-up">.
We had copied its path into three call sites and never vendored the file.
I assumed the platform forced that, and it does not: an external
<use href="file.svg#id"> renders in Chromium, renders inside a shadow root,
and a local <symbol> plus <use href="#id"> renders too — 410x234 in all
three. What is forced is the external href, not the mechanism. A dropped
bundle resolves assets against whatever tree it sits in and the tree
generator stages an allowlist with no icons directory, so an external
reference would 404 in silence and the chevron would simply be absent. That
is the same reason worldmap.svg arrives through a prop. So: their path,
verbatim, as one <symbol>, referenced the way they reference it.
The first attempt blanked all 21 chevrons — the regex rewrote the path inside
the sprite it had just inserted, so the symbol referenced itself. Worth
recording how that was caught, because the CSS fingerprint called the broken
page identical: a <use> resolving nothing keeps exactly the same computed
styles. Geometry caught it, and so did looking at the page. The fingerprint
now says so in its own header, along with the fact that its keys are
ordinal-sensitive for unclassed elements.
Deleting the map svg from page 1 entirely left this gate reporting
"map: palette resolves, not black — PASS, map painted".
mapFaults queried `document.querySelector('.sheet svg')` — the first svg
ANYWHERE in the document. With page 1 empty the query silently retargeted to the
PRAYER WHEEL's svg two sheets later, and the wheel has no CSS variables to leave
unresolved and no black fills, so both checks passed vacuously. The sheet count
stayed 8 because the empty sheet still exists.
A fallback selector turns an absence into a pass: when the thing being asserted
about disappears, the query finds a different object that happens to satisfy the
checks — and here the gate printed a stronger claim than it had evidence for.
"page 1 = map one-pager" is the first line of the packet spec.
Now scoped to sheet 0, which the page order fixes as the map page. And it
asserts the map paints GEOGRAPHY, because palette-resolves and not-black are
both properties a map with zero country shapes satisfies perfectly. Geography is
paths whose `d` exceeds 200 chars — a coastline, not an icon. The floor of 5 is
set from a measured spread rather than guessed: laos 13, brazil 18,
middle-east-core 55, tibet 69, uncovered-scatter 74, varying with how many
countries a zone spans.
Mutation-tested in three shapes: no svg fails naming page 1; an svg present but
drawing nothing fails at "only 0 geography path(s)"; the good packet passes at
13. All 11 locales report 13 — the map is data, not text.
Syncing the gate surfaced a second thing. doxa-maps-plugin-studio ships 0 poster
html files against wt-packet's 28, and packet.mjs deliberately "still builds
(cover page without the map) rather than failing" when out/posters/<zone>.html is
absent. That soft-fail is intentional but it was invisible: the degraded packet
has 8 sheets, an <svg> present (the wheel's), and no marker at all, so it is
structurally indistinguishable from a good one while page 1 promises a map and
delivers an empty sheet. PACKET_EXPECT_MAP=0 follows the PACKET_EXPECT_PHOTOS
precedent — fail by default with the remedy named, pass when the degradation is
declared.
Also fixed the summary line, which read "map painted" unconditionally and so
described a declared map-less packet as having a painted map. It now reads
"NO map (declared)". The summary is the part a reader quotes.
i18n verified with a 2-key partial block: page 1 renders the translated keys,
falls back to English elsewhere, 0 blank runs. Wave 34/34, packets-all 11/11.
main's legend work added library/components/legends/SemanticTreeLegend.vue
without re-running the derived index, so check:library-index was failing on a
file none of this branch's work touched. Regenerated: 94 items catalogued.
public/doxa-maps/index.html conflicted. It is generated, so it was resolved by
regenerating the pipeline rather than by hand-merging two machine-written
files: build the bundle, rebuild the tree, re-hash. fix:sri then reports all
111 hashes already in agreement, which is the evidence the tree is coherent
rather than merely conflict-free.
`touch library/components/legends/SemanticTreeLegend.vue` — zero content
change, nothing in git status — turned this gate red. So did a git checkout, a
rebase, and any editor that rewrites a file on save. It cried wolf twice in one
build session while the regenerated doc was byte-identical both times, and the
only available response was to run the generator and commit nothing.
An mtime answers a different question than the gate's own name asks.
This is the SECOND time this exact class was fixed here. The first was a SCOPE
error: the gate walked 2.3x more of library/ than the generator reads, so a
comment in a build script turned it red and named that script as the reason the
component MENU was stale. Narrowing the scope fixed that instance, but the
mechanism was still mtime, so the class survived inside the narrowed scope. The
lesson recorded then applied again word for word — a gate that fails for a
change it can prove is irrelevant teaches the reader to regenerate on reflex,
which is how a staleness check stops being read.
It now regenerates into a throwaway tree and diffs the bytes.
generate-library-index.mjs hardcodes its output path and derives ROOT from its
own location, so it cannot be told to write elsewhere — but copying the SCRIPT
into a temp tree whose library/ is a SYMLINK to the real one relocates its ROOT
without modifying it. The generator runs unmodified against real sources, writes
somewhere disposable, and an interrupted run leaves nothing behind. Verified
byte-identical to a real `bun run library-index` before this was written, and
the generator was confirmed deterministic first — a content diff would be
useless otherwise.
Comparing output also makes the scope self-enforcing: a change outside
components/, composables/ and utils/ cannot alter one byte of the menu, so the
INDEXED_DIRS list the mtime walk needed is gone.
Mutation-tested both ways, because a gate that never fires is worse than a noisy
one. Deleting a catalogued row fails with the line number and expected content.
Adding a real new library component fails — that is the drift this exists to
catch. Touching 40 library files, the rebase case, passes. Wave 34/34.
Swept the five other internal/*.mjs that reference mtime: only
check-shot-freshness also decides on one, and there it is correct — a PNG cannot
be cheaply regenerated and diffed, and a rebuilt bundle is meaningfully newer.
Left alone deliberately.
The three regions components carried 119 inlined values — 23.5264px,
19.5312px and sixteen others — with nothing to tell a reader that those two
are --spacing-lg and --font-size-md on the site being cloned. They now say so.
The values are unchanged. Live declares them in rem against a 10px root, but
rem resolves against the document root even inside a shadow root, and these
components render inside whatever page embeds them, so rem would scale every
space by 1.6 against a default 16px host. That is why they were resolved to
px originally, and the tokens stay px. Nor are they re-derived from live's
fuller precision: naming numbers and changing numbers are separate changes,
and keeping them separate is what makes this one provably inert.
They live in the plugin folder rather than library/styles because the count
says so — 121 occurrences here, none in any other plugin, none in library —
and shared placement wants a second consumer. The file says to move it when
one arrives.
Verified by fingerprinting every element the bundle renders: four pages at two
widths, 2,650 elements, 27 computed properties each, baseline taken from the
previous source. Zero differences, nothing gone, nothing new.
Blanking every wheel segment label and regenerating left check:packet-render
reporting "12 segments, 11/11 rhythms, content intact — PASS" on a wheel that
was a twelve-colour pie chart.
Both printed numbers stayed true, which is why nothing caught it. The twelve
segments are <path> elements and survive their labels being removed. And the
rhythm assertion searched document.body.innerText, where the SAME twelve rhythm
words are rendered TWICE in every packet — once per wheel segment, once as a
prayer-point heading. So "every rhythm word appears in the document" was
satisfied by the points page alone and said nothing about the wheel.
That is the self-consistency-vs-completeness family again, with a new twist:
when two surfaces share one vocabulary, a document-wide string search cannot
attribute a hit to the surface being asserted. The fix is scoping the query to
the surface, not a better word list.
wheelLabels counts the non-numeric <text> runs inside the .wheel-title sheet's
own svg, excluding the twelve "5" pips and the "5 minutes x 12" footer. It
counts ELEMENTS, NOT DISTINCT WORDS, deliberately: `praise` and `praise_end`
collapse to one word in English — which is exactly why the existing line reads
11/11 and not 12/12 — and that collision is locale-dependent, so a distinct-word
count would be a different number per language.
The Driver's spec names the wheel as one of the packet's four sections, so 12
labels is a contract. Mutated packet reports 0/12 and fails; restored reports
12/12.
i18n verified at the rendered layer with a partial locale block (4 of 12 rhythms
translated): the wheel painted four XX- labels with the other eight falling back
to English and zero blank — and the split pair is visible, segment 1 English
PRAISE beside segment 12 XX-PRAISE_END, which is what per-key resolution looks
like. All 11 locales regenerated with 95 photos each: check:packets-all 11/11,
every one 12/12 labelled, including RTL ar and Devanagari hi. Wave 34/34.
The legend's toggle button announced the literal string "aria.toggleLegend" to
screen readers. Not in translated locales only — in English too. vue-i18n echoes
the key when a lookup misses, and `toggleLegend` was absent from every catalog
while its two siblings in the same file, backToLegend and closeDetailView, were
present. Six call sites across LegendDesktop.vue and LegendMobile.vue, desktop
and mobile alike.
aria-label is the one string a sighted tester never sees. Every other
untranslated string in this tree got caught because somebody looked at the
screen; this one is only ever spoken aloud, so it survived every visual QA pass.
A second instance turned up in the same sweep with the opposite shape:
SemanticTreeLegend hardcoded 'Collapse legend' / 'Expand legend' into both title
and aria-label with no t() at all — correct English, and untranslatable. One key
missing FROM the catalog, one string never routed TO it.
Adds toggleLegend, collapseLegend and expandLegend to all 11 aria.json catalogs
and routes the hardcoded pair through t(). check:i18n-parity already guards them.
New gate smoke:aria-labels asserts BOTH shapes, because either assertion alone
is insufficient: "no raw keys" passes a hardcoded English label forever, and
"labels differ across locales" passes a raw key, since a key is identical in
every locale. It detects a raw key by SHAPE — dotted, camelCase, no spaces —
which is locale-independent and works on the Arabic and Chinese pages where no
English word list could.
Mutation-tested twice. Removing the catalog key fails with the raw key named, on
all four viewport/locale combinations. Hardcoding one label back to English
initially PASSED: the differ count dropped 6 to 5 and nothing asserted on it —
the same "a number printed but not asserted on is decoration" shape this tree
keeps rediscovering — so the gate gained a LOCALIZED_FLOOR ratchet and now fails
that too.
Verified the fix generalizes beyond the profile it was found on: country-posters
reports 182 of 190 accessible names localized, 0 raw keys. Rebuilds the six
bundles that embed a legend; check:sri confirms every leaf matches. Wave 34/34.
The Driver asked for the per-tab info buttons to stay — "the tab itself gives
you a little information button that will explain what a UPG-100 list is."
They existed, and they were English-only in every locale.
SemanticTreeLegend rendered `tab.info` RAW in all three places it appears (the
button's title=, and the popover body) while the tab LABEL beside it went
through the catalog. So a Spanish reader saw a translated chip next to an
English explanation of what that chip means — and the explanation is the part a
reader who does not read English actually needs. The three UPG-100 tab labels
were absent from tabs.json entirely, so they fell back to hardcoded English too.
Measured on the served leaf before changing anything: ?lang=es changed 2 of 76
rendered strings ("POPULATION" -> "POBLACIÓN") and left all three tabs and all
three info popovers in English. That positive control is what proves the locale
switch WORKS and the strings were simply never catalogued — without it this
reads identically to a broken ?lang=.
tabInfo() mirrors tabLabel exactly: `legend.tabInfo.<i18nKey|id>`, te() guard,
falling back to the hardcoded English so a tab with no catalog entry renders
unchanged rather than blank. The v-if stays keyed on the raw `tab.info`:
whether the affordance EXISTS is a property of the tab definition, while its
TEXT comes from the catalog.
Catalogued in all 11 locales, labels and info. check:i18n-parity already guards
them — deleting one zh key fails the build by name.
New gate smoke:legend-info renders the REAL served page and clicks each info
button. check:tabs-localized could not see any of this: it tests 7 hardcoded
research-map tabs and REIMPLEMENTS tabLabel inside page.evaluate, so it proves
the mechanism works rather than that a given profile is localized — and it never
looked at `info` at all. Mutation-tested: reverting the popover to the raw prop
fails with "the catalog is present and unreached".
Partial-translation contract verified by deleting 2 of 3 fr info keys and 1 of 3
fr labels: translated keys render French, untranslated fall back to English,
nothing blank, and labels and info resolve INDEPENDENTLY.
Rebuilds the six bundles that embed the legend so the tree stays internally
consistent; check:sri confirms all 55 script references match. Full wave 34/34.
main's packet fix lands inside this bundle (492.59 -> 492.76 kB), so regenerating the tree alone would have shipped the old .js against the new source.
The import note claimed "the repo's Fuse-parity shim ... a wiring job, not a
new matcher." Nothing had ever measured that. Diffed against live
/regions/cameroon, the shim matches their Fuse exactly on an exact term —
"Cameroon" gives the same 8 rows and the same 9 highlight spans — and both
tolerate a typo of it, and both return nothing for a term with no match.
Where it differs: "islam" returns 5 rows against their 7. The two we omit are
Majera and Osatu, and Osatu's religion is "Ethnoreligion - Animism" with
"islam" appearing in none of the seven searched keys. They are Fuse's fuzzy
reach at threshold 0.4, not rows the shim failed to find; its first tier
returns the exact set and stops. The shortfall is always on the fuzzy side,
never an invented row.
The substitution is forced rather than chosen: fuse.js is neither in
package.json nor installed, new dependencies are out, and reimplementing
bitap scoring would be the from-scratch lookalike that copying exists to
avoid. The shim also has four other consumers, so widening its contract to
chase Fuse would change their search too. The note now carries those numbers
so the next reader inherits the measurement rather than the claim.
packet-render.js resolved the prayer points with `L.points || EN.points`.
That is a whole-block fallback when the key is ABSENT, and TRUNCATING when a
translator has done some of the 12 — a 3-element array is truthy. Measured at
the rendered layer with a deliberately partial locale block: a page headed
"12 Prayer Points" containing THREE, points 4..12 silently gone, including the
Mark 5:1-20 passage point 4 prints in full. Nothing blank, no error raised.
Invisible in all 11 shipping locales because every one of them is complete.
It would have appeared on the next language added — and "a new language is a
NEW BLOCK, never a code change" only holds if a PARTIAL block degrades per key,
since half-finished is the normal state of a language in progress.
Now merges per point and per field against English: English fixes the LENGTH
and supplies every missing field, so a point translated for `text` but not
`sub` keeps the English `sub` — and keeps its NUMBER, which the merge supplies.
The sheet-sum invariant could not see this: a 3-point page is still exactly one
sheet, so every number check-packet-render printed stayed correct while nine
points were missing. It now measures and asserts the count. Mutation-tested —
reverting the fix prints "prayer points : 2 / 12" and fails.
All 11 locales regenerated with photos and swept: check:packets-all 11/11, each
12/12 points, including RTL ar and Devanagari hi. Renderer, generator and gate
synced to doxa-maps-plugin-studio, which also lacked the .points-title and
.wheel-title test hooks the new assertion needs.
Also wires check:capability-exemplars into gates:post (33 -> 34 steps). It reads
public/doxa-maps/manifest.json, a build artifact, so ordering mattered: `tree`
writes the manifest and is &&-chained immediately before gates:post, and the
gate SKIPs rather than fails when the manifest is absent. Full wave: 34/34.
The quoted gates:post line in the README is re-synced in the same commit,
because check:quoted-scripts goes red on the drift this commit creates.
In Arabic the chevrons moved to the correct side of the label but kept
pointing right, so "forward" pointed backwards. Measured on the live site,
the same selector in both directions: .region-card__link svg is
matrix(0, 1, -1, 0, 0, 0) at /regions and matrix(0, -1, 1, 0, 0, 0) at
/ar/regions. Ours was the LTR matrix in both.
Their utilities/_shapes.scss already answers this:
.rotate-90 { transform: rotate(90deg);
[dir="rtl"] & { transform: rotate(-90deg); } }
and their .rotate-180 pointedly has no such variant. That distinction is the
rule worth keeping: a chevron meaning "forward" mirrors, a disclosure caret
meaning "open or closed" does not. So of the three rotations here only the
chevron gains the variant; both 180-degree carets were already right.
The open-state rule beside it is load-bearing rather than decorative. The new
[dir="rtl"] selector outweighs the --open modifier, and without its own RTL
rule the poster caret would have frozen mid-turn in Arabic.
This gate keeps the AGGREGATE honest — a prose status makes every open-count wrong
while each card still READS correctly, so the error hides in the total. That matters
to this loop directly: every tick starts by reading status counts to pick a card.
MEASURED across 633 cards: EIGHT distinct status values are in use and EVERY ONE IS
A SINGLE WORD. No prose remains anywhere in the field — which is exactly what the
original 130-card migration achieved. The nine cards this gate was failing carried
legitimate TERMINAL states the enum simply predated: superseded (5), deferred (2),
decided, verified-already-true.
★ REWRITING THEM TO "done" WOULD HAVE DESTROYED REAL DISTINCTIONS to satisfy a list.
A SUPERSEDED card was replaced by another; a DEFERRED one was consciously postponed;
neither was finished. Losing that information to make a checker green is the
opposite of what this gate is for.
OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
open = todo + doing + in-progress (66), closed = done + these four (507),
blocked (60).
MUTATION-TESTED BOTH WAYS, so widening did not weaken it: a prose status
("done — reconciled against main, see notes") still FAILS by name — the original
defect — AND an unagreed one-word status ("wontfix") ALSO fails, so the set stays
CLOSED rather than becoming "any single word".
Wired into gates:post (32 -> 33) after confirming it reads only backlog JSON. The
doc that quotes gates:post is re-transcribed in the same commit.
Build: 7/7 pre, 23/23 bundles, 33/33 post, exit 0.
A --self-test IS the proof that a gate can fail. verify:interop and
check:fits-screen each advertised extra regression modes that nobody could reach,
so their non-default regressions were never demonstrated. The gate that found this
puts it exactly: "a --self-test that never runs is a gate certifying itself."
THE MECHANISM, PROVEN WITH AN ARGV PROBE rather than argued. Both parent script
values end in an inline # comment, so a mode appended by the caller lands INSIDE
that comment:
dedicated entry (mode hardcoded) -> ARGV ["--self-test","--self-test-clause=b"]
parent-style, caller appends -> ARGV ["--self-test"] clause (none)
The run then exits 0 in DEFAULT mode, indistinguishable from the mode being
selected. Unreachable: camera-freeze (interop) and clauses b-e (fits-screen).
Five dedicated entries now hardcode each mode, and the parent comments point at
them instead of at an append that cannot work. Confirmed end to end:
check:fits-screen:self-test:b fires a real clause-named failure
("[A] FAIL dashboard-plus-grid -> band=2401px vs viewport=900px").
★ MY FIRST ATTEMPT MADE IT WORSE — 2 findings became 6. I appended an explanatory
# comment to each new entry, which re-created the swallowing shape AND named the
flag in prose; the gate scans script VALUES, so describing a flag is
indistinguishable from advertising one. Rewrote the notes naming no flag. Then a
blanket string-replace mangled the fits-screen comment's clause list into
unreadable prose, repaired separately. The gate was right at every step; I was the
one adding the defect.
check:script-flags goes FAIL(2) -> PASS(148 scripts, 0 unreachable), and is wired
into gates:post (31 -> 32) after confirming it reads only package.json.
MUTATION-TESTED: reintroducing an advertised-but-swallowed flag fails by script
name. The doc that quotes gates:post is re-transcribed in the same commit.
Build: 7/7 pre, 23/23 bundles, 32/32 post, exit 0.
The rebuild that follows a rebase re-hashes the bundles, and the gallery bakes those
integrity hashes into its page. A stale hash is the silent failure this repo has
paid for: the browser refuses the script and the page mounts nothing.
A done card with no record is the reporting failure this loop exists to avoid — so a
gate that mis-identifies WHICH cards lack a record is worse than none: it teaches the
next agent to pad the field the checker reads.
★ THE THREE FLAGGED CARDS WERE AMONG THE BEST-EVIDENCED THERE ARE. Each carried
one_line, why, and a `verify` block naming the probe, its assertion count and its
negative tests — "74 files match byte for byte, all three drift classes exit 1",
"9 assertions, all pass", "two new assertions, both negative-tested".
The gate looked only for `bluf` or `fields`. MEASURED across 443 done cards: 40 carry
`verify`, 41 carry `one_line`, and ALL THREE flagged cards had one or both. `verify`
is if anything the STRONGER signal — it names how the claim was checked, which is
what evidence means here.
Fixing the CARDS by adding a redundant bluf would have been gaming the check. Fixing
the GATE to recognise the evidence already there is the honest repair.
★ THE BASELINE IS NOW EMPTY. All ten exemptions reported STALE — every one had since
gained evidence — so the list exempted nothing while implying ten cards still owed a
record. An EMPTY set is not an unset one: every done card must now carry evidence
with NO exemptions, and the gate fails outright for a single card that does not.
Keeping ten satisfied ids would mean the same today and quietly permit a regression
the moment someone reused an entry. Same reasoning as emptying the i18n DEBT map.
MUTATION-TESTED both directions: a done card with NOTHING in any of the four fields
FAILS by name; the same card with ONLY a `verify` entry PASSES.
Wired into gates:post (30 -> 31) after confirming it reads only backlog JSON, which
the build does not mutate. The doc quotes gates:post, so it is re-transcribed in the
same commit — the self-referential update noted last tick, handled in one pass this
time.
Build: 7/7 pre, 23/23 bundles, 31/31 post, exit 0.
The no-selection chooser paints its pins by WAGF region — that is the whole
point of the state, and the region key beneath the map is what explains it.
Above that map sat DX-life's own sentence: "Each pin is coloured by how many
people are praying for that people group." True on their region and country
pages, where the pins really are prayer-coloured. Flatly false here.
That sentence is a single translated key, so it cannot be split (a ". " split
breaks on Chinese and Arabic punctuation) and a replacement clause would be
copy invented in eleven languages. The chooser therefore drops the claim
rather than restating it: the subtitle already gives both counts, and the key
below the map names every colour. Their own pages keep the sentence verbatim.
Last tick left six documentation-drift gates failing and NAMED on every build. This
is the smallest — one finding — and it guards something real: a doc that quotes a
config is claiming to be ground truth.
THE DRIFT WAS ENTIRELY MINE. Every one of the eleven missing steps is a gate added
over the past ticks, all four i18n gates among them.
★ AND IT DRIFTED IN THE UNUSUAL DIRECTION. The doc did not OVERSTATE coverage, it
UNDERSTATED it: a reader consulting README-SECTION-3 would believe eleven checks
were not in the chain. That is precisely what the gate's own header warns about —
"there is no wrong character to notice, just a check the reader believes is not in
the chain."
★ THE JUDGEMENT CALL. The surrounding prose said "the 29 gates cost 84.5s" and
"31 in, 31 out" — figures from the 2026-09-23 phase refactor, now 36. I DATED them
rather than restating them as current. Those numbers are the EVIDENCE that the
phase change preserved the step set; rewriting them to 36 would destroy that
evidence while looking like an update. The gate compares quoted SCRIPT LINES only,
so this prose drift was invisible to it either way.
Wired into gates:post (29 -> 30) after confirming it reads only package.json and a
markdown file, neither of which the build mutates — the precondition test I got
wrong with check:settled last tick, where "passes twice standalone" proved
repeatability rather than compatibility with running mid-build.
★ WIRING IT IS SELF-REFERENTIAL and takes two passes: adding the gate to gates:post
CHANGES the line the gate quotes, so it failed immediately after being wired and
needed one more doc sync. That is the mechanism working, and it is now noted in the
doc so the next editor is not surprised by it.
MUTATION-TESTED: removing a single step from the DOC only makes it FAIL by name —
the invisible-omission case it exists for.
Build: 7/7 pre, 23/23 bundles, 30/30 post, exit 0.
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.
Five conflicts, all in public/ — build OUTPUT, where neither side is authoritative,
so it is resolved by rebuilding rather than picking hunks. Six files differ from the
hand-resolution, which is why that rebuild is not optional.
No source file overlapped: the upstream commits were regions-dashboard work, mine
is the packet-browser gate and its two renderer test hooks.
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.
Applying last tick's lesson — a channel captured and never asserted —
to the vs-live diffs themselves. All four compared getComputedStyle and
NOTHING ELSE, so an element could render the wrong words with
pixel-perfect styling and still report 79/79. Two of them even captured
the text and never compared it.
region-vs-live now compares the STRUCTURAL text as well: stat labels,
goal-card headings, table headers and the back-link — the chrome whose
words are fixed. Values are deliberately excluded, since figures are
already compared against live by _probe-oceania-and-list.mjs and
duplicating that would break the moment the API's numbers move. The
lists are compared whole, so a missing or reordered label is caught too,
not just the first one.
It immediately found a defect I introduced last tick. Their live
/regions/africa table header reads "Country", singular. I had changed
ours to t('Countries') for plural symmetry with the chooser's dropdown,
having checked that the key was translated but NOT that the word matched
their page. Restored to t('Country'), which is in their catalogue and
translated in every locale — so parity and localisation both hold, and
ar still reads "الدولة". The chooser's own column keeps the plural,
since that column exists only in our addition and t('Region') has no key
in their catalogue.
list-vs-live and country-head-vs-live now assert their fixed-word text
too — the h1, the strapline and the back-link. Negative-tested by
perturbing the expected string: exit 1.
The rebuild that follows a rebase re-hashes the bundles, and the gallery bakes
those integrity hashes into its page. A stale hash is the silent failure this repo
has paid for: the browser refuses the script and the page mounts nothing.
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.
I have reported "the SELECT VIEW strip's three strings need
translating" for several ticks without ever quantifying it. Measured at
ar and zh by classifying every visible string by script: TWENTY were
still Latin, not three. Most were not awaiting a decision at all — they
were bugs, and the largest was my own code discarding their data.
- The chooser's region legend read WAGF_REGIONS[value] || r.label, so
the hardcoded English display names in library/colors/wagf-region.js
always WON over r.label — the localised name the API returns for
?lang=, and the very name the region CARDS render. At ar the cards
read "آسيا" while the legend beside them read "Asia". Inverted: the
API's label now wins and WAGF_REGIONS is the fallback for a region
returned with no label.
- The no-selection title was the literal 'All Regions' while this same
file already calls t('All Regions') for the back-link — one string,
translated in one place and hardcoded in another.
- syncTitle compared the subject title against the English literal, so
once the title was localised the comparison could never match; both
halves now go through t(), whose keys are theirs.
- The chooser's two dropdowns carried static label and placeholder
attributes. That is the element's only keyboard and screen-reader
path — pin clicking is mouse-only by construction — and it was
labelled in English inside an otherwise localised page. Now
t('Regions'), t('Countries') and t('Type to search...'), all their
keys, all translated in every locale we ship. The table's first
column too: t('Region') has no key in their catalogue, since they
never needed an all-regions state.
Twenty Latin strings down to fourteen, of which nine are our own
additions, four are Mapbox's chrome and one is an API code. 93.9%
localised at both ar and zh.
Two lessons in the measuring itself. My scan read only TEXT NODES, so
the two hardcoded placeholders were invisible to a check that had just
reported 95% localised — it now reads placeholder, aria-label, title
and alt as well, which immediately surfaced four more English
aria-labels that exist purely for assistive tech. And the remainder is
now classified in the probe rather than left to fail forever: a
permanently red check only teaches you to ignore it, so it passes on
the known set and fails on anything new.
Migrating my-upg-100-list onto the shared wall left one behind, and it was not
decoration: clicking the poster preview opened it for printing, a Driver-named
behaviour ("show the poster in the browser to help print it"). I removed it, so
this is a regression I introduced and am closing.
previewAction: 'none' | 'print'. Default 'none' keeps the image inert, which is
exactly what country-posters and key-cities render today.
★ IT IS A KEYBOARD CONTROL, NOT JUST A CLICK TARGET. The original carried
role="button", tabindex="0" and Enter/Space handlers alongside @click. Re-adding
the click alone would have been a half-restoration that LOOKS complete — an <img>
with only @click is unreachable without a mouse, and the focus-visible outline is
how a keyboard user sees where they are on a control that is otherwise a picture.
The alt and title change with the mode too: "prayer poster" and "click to preview
and print" are different promises to a screen reader.
buttons.previewPrintPoster added to all eleven locales in the same change;
check:i18n-parity still reports 0 keys of debt.
VERIFIED AT THE RENDERED LAYER, and exercised BY KEYBOARD rather than by mouse:
100-list (preview-action="print") — 27 previews, is-clickable, role=button,
tabindex=0, cursor zoom-in, translated title, descriptive alt. Focused the image
and dispatched ENTER: 1 print frame opened, 0 downloads, 0 errors, with every
.webp ABORTED so it ran through the rasterise fallback.
country-posters (default) — 174 previews, is-clickable false, role null,
tabindex null, original alt. Inert exactly as before.
That completes the consolidation: my-upg-100-list 890 -> 179 lines, and all four
walls now render from one 633-line component with nothing left unported.
The original wall is gone: 716 lines of duplicated markup, CSS, export modal and
print wiring deleted. This profile now supplies only what cannot be shared —
REGION_OF/regionFor (its taxonomy, keyed by list id because a selector can be a
wagf_block, a country_codes array or pg_slugs and none of those implies a
continent) and fetchListRecords (the people-groups API, the selector evaluator and
the pg_slugs special case). It joins country-posters (94 lines) and key-cities (75)
on one component, which is what the Driver asked for: "don't recreate the wheel but
imitate upg 100 list".
It took three capability additions across three ticks, because migrating without
them would have REGRESSED this wall rather than consolidated it: cardStocks, then
posterAction="print" and hidePrayerListWhenSoon — the last two a Driver ruling, not
a preference.
★ TWO REAL BUGS, BOTH INVISIBLE AT THE SOURCE LAYER, BOTH BUILDING CLEANLY.
1. groupBy SIGNATURE MISMATCH. regionFor takes an ID; the shared wall's groupBy
takes the whole ITEM. `:group-by="regionFor"` compiled, the page loaded, and then
threw `c.startsWith is not a function` at first render — the whole wall blank, zero
cards, nothing wrong in the source. Adapted in the template rather than changing
regionFor, since the id is what the taxonomy is keyed by.
2. A THROWN FETCH IS A MISS, NOT AN ERROR — and this one is a robustness gap in the
SHARED component, affecting every consumer. fetch REJECTS rather than returning
!ok when a request is blocked or offline, and exportPoster let that reject escape to
the outer catch, skipping the rasterise fallback and showing "Failed to fetch" on a
poster the browser could have generated from posters/<id>.html. The wall this
component was EXTRACTED FROM already guarded it; the extraction did not carry the
guard across. check:poster-print-fallback went PASS -> FAIL with 0 .html requests,
and is green again.
VERIFIED AT THE RENDERED LAYER, all three print actions on the migrated wall:
27 cards, 27 stock pickers, 4 buttons per card — the original's three prints plus
show-on-map. CARD DECK 116 cards / 116 QR codes / @page 2.5in x 3.5in / 0 errors,
identical to pre-migration. POSTER PRINT with every .webp aborted: 1 .html fetched,
"generating the poster", clean finish. PRAYER LIST: 1 people-groups fetch, 0 errors
— positive evidence the provider ran, since that print cannot work without it.
REGRESSION CHECK: country-posters still downloads (0 print frames, 1 download), so
the default is unchanged for the walls already on this component.
Another agent pushed during the build, so main-capture's scan refused the push —
working as intended, and the lock released on the ';' as it must. The conflict was
in public/doxa-maps/index.html: build OUTPUT, where neither side is authoritative,
so it is resolved by rebuilding rather than by picking hunks. Ten files differ from
the hand-resolution, which is why that rebuild is not optional.
No source file overlapped: their commits were worldmap.svg provenance, mine is
PosterWall. 22/22 post-gates green after the rebuild.
★ THE BLOCKER ON MIGRATING THE 100-LIST WAS NEVER ONLY A MISSING CAPABILITY. It was
a Driver-rejected UX baked into the shared component, and migrating as planned would
have shipped it.
my-upg-100-list/poster-wall.vue:280 records the ruling verbatim (2026-09-23): "three
print buttons, each opening an in-browser print dialog, no silent file download as
the default action. A raw 'Download' button and a 'Prayer List: coming soon'
placeholder were exactly the two buttons he objected to." exportPoster(),
downloadPacket() and download() were REMOVED from that wall for that reason.
PosterWall still had both: exportPoster() downloads a .webp unconditionally, at two
call sites, and downloadPacket() renders the "coming soon" placeholder whenever
there is no records provider. Consuming it would have reintroduced the exact two
buttons the Driver named.
Two props close that, both defaulting to today's behaviour so country-posters and
key-cities are untouched:
posterAction: 'download' | 'print' — 'print' opens the OS print dialog on the
poster. BOTH delivery paths (the shipped .webp and the browser-rasterised
fallback) now route through ONE deliverPoster(), so the modes cannot drift or be
applied to only one of them. Printed blob urls are held until unmount rather than
revoked at print time: the preview <img> is still reading from them, and revoking
early prints a blank sheet.
hidePrayerListWhenSoon — suppress the Prayer List button rather than show it as a
placeholder. The placeholder stays honest where the feature is genuinely unbuilt;
it is only wrong on a wall that DOES print the list.
VERIFIED AT THE RENDERED LAYER, both modes, by counting the two outcomes that
actually distinguish them:
poster-action="print" -> 1 print frame opened, 0 downloads
default (prop absent) -> 0 print frames, 1 download
Counted at appendChild, where openPrintFrame attaches the iframe, rather than by
polling for iframes — the frame SELF-REMOVES on afterprint, so polling finds none
and reads as a failure.
⚠ Two probe mistakes worth recording, because both looked like product bugs: a HUD
selector that matched <script>/<style> text and returned the page's whole token
ladder, and a first click that landed on INDIA — which country-posters marks
`unsupported` (855 groups do not fit a sheet), so it short-circuited with zero
poster requests. Skipping cards showing .ph.is-soon fixed it; the next click fetched
country-chn.webp and completed.
c43aa39 landed the same 311KB asset at public/assets/images/worldmap.svg --
DX-life's own path -- together with a PROVENANCE record: a full-file scan
for copyright, licence, attribution, author and the usual stock-library
names returns nothing, the ids are design-tool export names, and DX-life
keeps no licence file beside that folder either, so this is internal reuse
of first-party artwork rather than redistribution of a third-party asset.
The contrast it draws is the right one: the vendored typefaces ARE
third-party and do carry their OFL licence in the folder that serves them.
I had ported the same file to assets/brand/ earlier in this flight. MEASURED
identical -- md5 b7af01b31096c265e221a1d4e3f88c8d on both -- so the tree was
shipping 311KB twice, and, worse, keeping the provenance record in one
folder and a copy of the asset in another, where the two can drift apart
without anything noticing.
So the grid now points at the documented copy and the brand/ one is deleted.
Nothing about the rendering changes; only which of two identical files it
asks for.
VERIFIED on the built tree: /assets/brand/worldmap.svg now 404,
/assets/images/worldmap.svg 200, the #filters backdrop still computes
background-size 60% and position 50% 0% with 0 failed requests, check:sri 0,
gates:pre 0, gates:post 0.
Applying the same question as the last three ticks to the standing rule
with real-world weight — "vendored bytes small and LICENSED". Two gaps,
one of them mine.
I vendored worldmap.svg silently: 311KB copied from their repo with no
note of where it came from or why no licence accompanies it. The
judgement was left implicit, which is exactly what a provenance
question should not be.
Checked properly now. A full-file scan for copyright, licence,
attribution, author, creator, <title>, <desc>, <metadata>, rdf:/dc:
nodes and the usual stock-library names — Natural Earth, simplemaps,
amCharts, Freepik, Shutterstock, OSM, Mapbox — returns nothing. It is a
plain vector: 394 <path>, three <g>, zero <image>, zero <text>, with
design-tool export ids (Group_345, data-name="Group 345"), so it was
authored by their designer rather than downloaded with embedded
credits. DX-life keeps no licence beside public/assets/images/ either;
they treat that folder as their own artwork. So this is internal reuse
of a first-party asset within the same organisation, not
redistribution of a third-party one.
public/assets/images/PROVENANCE records that reasoning, the exact
source path, the measured live values, and the contrast with the
typefaces — which ARE third-party and therefore carry their OFL text in
the folder that serves them. Extensionless, following the LICENSE
precedent, since check:public-hygiene refuses a *.txt in a served
folder; hygiene stays clean at 0 findings and the note is staged into
the tree beside the asset it describes.
The second gap needed no source change and lands in the probe suite:
OFL compliance rested on a one-time manual act with nothing checking
it, so a merge that dropped a LICENSE, or a future bundle vendoring a
face without one, would have shipped unlicensed bytes with every gate
green. A new suite walks public/assets for woff2/ttf/otf/eot, asserts a
licence sits beside the fonts, asserts it is real text rather than a
stub, asserts it is not a *.txt, and then repeats the audit inside the
generated tree — because a licence that never reaches the tree does not
travel with what a reader downloads. It immediately covered a third
vendored face I did not know existed, noto-serif-devanagari, already
compliant. Negative-tested twice: a removed licence fails, and a
two-byte stub fails on the real-text assertion.
The card's first step was a gap analysis: what does the 888-line my-upg-100-list wall
render that the shared component's props cannot express? Diffed both templates. They
are STRUCTURALLY THE SAME — wall/tag/sub, sections with head and count, a card with
"N UPG · Show on map", main with image-or-placeholder, a Print Options fieldset, the
same export modal. Exactly TWO real deltas:
1. the CARD-STOCK PICKER — closed here;
2. a clickable preview image (.img.is-clickable) — still open, and small.
Everything else that wall "owns" is data and taxonomy: REGION_OF, sections,
fetchListRecords. That is precisely what country-posters (94 lines) and key-cities
(75) already supply to this same component.
`cardStocks` takes true for every stock card-render.js supports, or an array for a
subset; `cardStock` remains the DEFAULT selection. Default false is today's behaviour
byte-for-byte — neither migrated consumer mentions the prop and both still build.
★ LABELS ARE DERIVED FROM card-render.js STOCK, never hand-written. The 100-list
carries its own DECK_STOCKS array duplicating all five labels, beneath a comment
saying they "come from the renderer's own STOCK table so this list cannot drift".
That was the intent, not the implementation. Deriving them makes it true, and the
migration will delete the duplicate.
⚠ The import is LAZY on purpose. usePosterPrints imports card-render.js dynamically
to keep a 10KB renderer out of every wall's initial bundle; importing it statically
for five labels would undo that for every consumer, including the ones that show no
picker at all. Options fill in when the module resolves and the <select> is not
rendered until then, so there is no flash of unlabelled ids. If the configured
cardStock is not among the offered stocks, the selection falls back to the first that
is, rather than printing at a size the reader was never shown.
buttons.cardStockAria added to ALL ELEVEN locales in the same change:
check:i18n-parity still reports 0 keys of debt. An English-only key would have broken
the ratchet emptied two ticks ago.
VERIFIED: the derivation unit-tested against the real STOCK table — all five stocks,
a subset, a subset EXCLUDING the configured default (falls back correctly), and an
unknown id (dropped, not rendered blank). Not yet exercised in a browser because the
picker is gated on a `records` provider and NO wall passes :records to the shared
component yet — the 100-list still holds its provider in its own file. Opting
country-posters in temporarily gave 0 pickers and 0 errors, which is that gate working
as designed. The migration will exercise it end to end.
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.
Continuing last tick's question — is the data I ported actually used? —
applied to the slug half of countries-meta. It is: both tabs resolve
selections through r.slug and c.slug, which summarizeCountries computes
with countrySlug, so the canonical-English rule really reaches the URL.
Verified against the values the live site adjudicated: "United States"
from the API still yields the-united-states, and "DR Congo" yields
the-democratic-republic-of-the-congo.
What the check did turn up is dead weight. slugify was imported by both
tabs and called by neither, and regions-list also carried emptyStats
and toNumber — leftovers from the rewire, since that tab has no chooser
and never needed the zero accumulator or the coordinate parser. All
four removed; a re-audit of every import in all five components now
reports zero dead names. The two exports beyond their surface stay
justified: emptyStats has five uses in region-dashboards and toNumber
two, both in the chooser that needs them.
Also verified rather than assumed: the region legend really does render
in REGION_ORDER. That matters because validate_palette measured
ADJACENT PAIRS for colour-vision separation, so the 8.6 deutan and 30.6
normal-vision figures only hold in that order — sort the legend by
count and pairs the validator never checked become neighbours. Nothing
asserted it, so the chooser suite now pins the dot colours to the
palette's own key order, with the expected sequence read from
library/colors/wagf-region.js rather than retyped. Negative-tested by
reversing the sequence: exit 1.