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.
main added country-posters-mfe (isMap=true). It inherits the engine swap
correctly -- 14 -> 4 requests to api.mapbox.com, maplibregl loaded -- but
threw
Fetch API cannot load mapbox://mapbox.country-boundaries-v1.
URL scheme "mapbox" is not supported
A new map arriving is exactly what the rollout has to cover, so this was
worth chasing rather than noting.
TWO shared library sites, not one, which only measuring found. Fixing
useCountryOutline.js (consumers: doxa-simple-map, countries-map,
research-map) cleaned two of the four leaves and left countries-map -- and
the new element nesting it -- still throwing, from a SECOND site:
useMapLayers.js addRegionsLayer. Both now branch per engine, using the
keyless assets/boundaries/world-adm0.geojson the tree already ships, with
iso3 normalised once to iso_3166_1_alpha_3 so every downstream expression
keeps reading the name it always read, and 'source-layer' set for the
vector-tile path alone.
The engine is read off the LIVE map -- its container carries
maplibregl-map -- rather than from config, because both composables are
called with nothing but a map getter.
MEASURED: 0 mapbox:// errors across country-posters-mfe, countries-map,
doxa-simple-map, research-map and demo-map; the adm0 fetch confirmed firing;
provider sweep 15/17. The Mapbox path is unchanged -- its control screenshot
still shows the coloured WAGF region fills.
Also fixes check:descriptions, which the new profile red-gated by shipping
an empty description. Written from the component's own header, not a
placeholder: 34/34 profiles described.
NOT FIXED, and recorded in the ledger rather than glossed: under MapLibre
countries-map still paints NO coloured region fills. The error is gone and
the geojson loads, but the fill does not appear. That is a separate defect
from the source failing, and claiming this commit finishes it would be
false.
The Experimental folder now presents most-impactful first per the
finalized roundup ranking (EXPERIMENTAL-MAPS-ROUNDUP.md): Prayer Walk
Planner, the rescued legacy set (entry: send-map), All People Groups,
Country Posters, Vision Card -- rank is editorial curation and lives
beside TAB_DEF; unranked bundles still discover and sort A-Z after.
Verified in the built shell rail.
Exclusions tightened: demo-map + golden-path-check (scaffolding /
authoring tooling -- the studio keeps them), doxa-research-map (the
part already composed into Simple Research), stack-demo (broken and
deferred; a broken tab on the giveaway shelf reads as damage).
Wrapper description corrected: the legacy IIFE holds FIVE maps.
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".
The rebase conflicted only in GENERATED output — the built bundles, their SRI
hashes, the leaf pages and the manifest — because another agent's regions work
landed in the same files. My source file applied cleanly.
Resolving generated files by hand produces a tree matching neither agent's
source, so this takes main's versions and REGENERATES: four bundle builds plus
`npm run tree`. Re-verified rather than assumed — check:poster-prints and
check:mfe-two-tab both pass after the regeneration.
The poster wall was generalised into library/components/poster/PosterWall.vue
and two bundles migrated onto it — but the ORIGINAL, my-upg-100-list's 903-line
wall, was left on its own copy. So the one wall carrying the real print actions
is the one wall nothing else benefits from, and the shared component's packet
path was still a "coming soon" stub. Every print fix made in the 100-list this
flight (the card deck, the stock picker, the image_url fetch) was invisible to
the three bundles on the shared component, and vice versa.
★ THE SEAM IS WHAT MADE THIS SHAREABLE AT ALL. The PRINT ACTIONS are common; the
RECORD FETCHING is not. fetchListRecords in the 100-list knows the people-groups
API, the selector evaluator and the pg_slugs special case; Key Cities and Country
Posters have entirely different data shapes. usePosterPrints.js therefore takes a
`records(item)` PROVIDER and owns everything after it — the HUD state machine,
the in-place print frame, and the documents.
That also keeps the bundles honest. A wall that CAN supply rows passes the prop
and gets the list + card-deck prints; one that cannot omits it, the Card Deck
button is not rendered, and Prayer List keeps its truthful "coming soon" instead
of a button that fails at click time. Country Posters shows exactly that today:
175 cards, Prayer List and Poster Map, no Card Deck — because it has no provider
yet, not because something broke.
⚠ ONE exportState, not two. The composable owns a ref by default, and a wall
rendering its OWN state while these actions wrote to a private one would show no
progress and swallow every error — the mechanism working and the feedback not,
which is the failure mode this flight keeps finding. PosterWall hands the
composable its own ref so both write to the surface the reader is looking at.
Prints IN PLACE via the shared openPrintFrame, never window.open() — the
mechanism the Driver asked for by name: "not supposed to open up a new browser,
but should open up the print dialog directly in the browser".
NOT DONE, and deliberately: the 100-list wall is NOT yet migrated onto the shared
component. Its template is 107 lines against the shared 72 with a different
structure, and it is a Driver-reviewed surface — that is a rewrite whose risk
does not fit in one tick alongside this. The shared half lands first and is
verified; the migration stays open on the card with this composable now waiting
for it.
Verified: all three bundles on the shared wall build; country-poster-wall renders
175 cards with zero page errors; check:poster-prints drives the composable in a
browser and asserts BOTH directions.
The one conflict was in public/doxa-maps/index.html, which generate-tree
writes. Built files are regenerated, not hand-merged: country-posters
was rebuilt because main changed its source (a new two-tab MFE),
regions-dashboard because this branch did, then the tree and the hashes.
Two findings, both from measuring at locale=ru instead of trusting the
plan.
DO NOT VENDOR OSWALD. The work list called for it for Russian headings.
Their rule is [lang="ru-RU"] on h1/h2/h3 (_globals.scss:40) — but the
live document's lang is "ru", so the selector never matches. It is dead
code on their own site. Oswald never loads there either:
document.fonts reports it "loading"/"unloaded" while Bebas, Poppins and
Brandon all report "loaded", and the glyph oracle agrees — Oswald
renders "HAMBURGEFONS" at 855 against sans-serif's 856.36 (0.2%, noise),
where Bebas moves it to 486 (43.2%). Bebas itself has no Cyrillic (501
vs 501.42), so their Russian headings fall back to the system face at
letter-spacing normal. Vendoring Oswald would have made us DIFFER.
_probe-ru-parity.mjs now asserts that parity instead of assuming it:
10/10, both sides falling back identically at 444.88.
The list tab was half-localised, and three of the four defects were
invisible in English:
- .rl-title forced text-transform:uppercase. Their h1 is none on both
locales. Invisible in English because Bebas is an all-caps face, and
wrong in Russian, where theirs reads "Регионы" and ours read
"РЕГИОНЫ". This file's own comment already recorded the correct
finding for .region-card__title; this rule was missed.
- summaryLine() was a hardcoded English template where theirs is
t('%count% people groups in %countries% countries'). The Russian
string was already in our catalogue — copied, never wired.
- dashboardLink() likewise: their ru value is "Открыть панель: %region%",
a different word order no English template could be patched into.
- Nothing refetched on a locale change. The region and country labels
come from the API's ?lang=, and their site serves Азия/Африка/
Азербайджан on /ru/regions. region-dashboards.vue already had
watch(languageCode, loadRegions); this tab did not, so a locale switch
translated the chrome and left every label English.
Why none of it was caught: _probe-region-vs-live.mjs does compare
textTransform and reports 73/73 — but it diffs the DASHBOARD tab's
.rd-title. Nothing ever property-diffed the LIST tab's head against
/regions. The gap was coverage, not the property list.
_probe-list-vs-live.mjs closes it at 8/8.
The loading state now uses their own 'Loading results...' key rather
than new English, since that string is already translated in all eight
locales.
Regression: 18 suites green.
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 rebase conflicted only in GENERATED output — the built bundle, its SRI
hashes, the leaf pages and the manifest — because another agent's work landed in
the same files. My two SOURCE files applied cleanly.
Resolving generated files by hand would have produced a tree that matches
neither agent's source. Taking main's versions and REGENERATING is the only
resolution that makes the output a true function of the merged source, so that
is what this is: one `vite build` plus `npm run tree` after the rebase.
Re-verified after regeneration rather than assumed: check:mfe-two-tab passes on
country-posters-mfe and upg-100-mfe alike.
Driver: "Country Posters is supposed to have two tabs, structured like the
UPG-100 list."
★ THE CARD'S REAL POINT WAS THE CONTROL, and it was already settled: this is the
THIRD consumer of the SELECT VIEW switcher, and library/components/ViewSelect.vue
already exists precisely because a third hand-rolled copy was imminent — its own
header says a fourth divergent copy is not what the library is for. So this
element imports the shared component rather than adding one, and the whole
feature is one new profile following an established shape.
country-posters-mfe.vue mirrors upg-100-mfe deliberately (index.js already
records that this bundle is "the same shape as my-upg-100-list"): the
pane-wrapper column, each pane under its own ProfileLoader/Pinia, one
ShareButton in the header sharing the COMPOSITE leaf — not a child, which is the
BUG-SHARE-FROM-CAP4-COMPOSITE bug this pattern already paid for.
LIST FIRST, MAP SECOND, per the Driver's principle that maps are a secondary
view: the poster wall is the concrete artefact a newcomer can act on, the map is
where it gets placed. Pane order lives in at[0] and in the array, because
buildPaneColumn sorts by at[0] while ViewSelect renders the array verbatim —
desynchronising them is a recorded trap.
It also CLOSES A DEAD SEAM. country-poster-wall has dispatched
'doxa-country-posters:show-on-map' since it was written, but only the app shell
listened — so in a dropped embed or a nested composite the action fired into a
document with no handler and did nothing. This shell owns the event and swaps to
the map tab, which is what makes the two tabs one element rather than two pages
that share a bundle.
Verified in a browser by the new check:mfe-two-tab: host 900px, tabs
"Country Posters | Map", both panes mounted at 1280x843, exactly one share
affordance, zero page errors. The same gate passes unchanged on upg-100-mfe and
regions-mfe, so the three composites are now held to one contract.
The nine conflicts were all in generated artifacts under public/. Built
files are not hand-merged: every bundle whose source either side touched
was rebuilt, the tree regenerated against main's new generate-tree, and
the hashes re-baked. Source merged clean — main never touched
PosterWall.vue or en/regions.json, they only appeared in a two-dot diff
because this branch changed them.
Driver request: "a toggle or carrot added to the bottom of the region
section that says view country posters, which will let you view country
posters for each of the countries in the regions" — with View-on-map and
Print, "the same reusable code from upg 100 list". This is a third
sanctioned addition alongside the tab strip and the no-selection chooser.
Almost nothing was built. library/components/poster/PosterWall.vue was
already the shared, fully parameterised grid, and it already owned the
card layout, the "Show on map" header button, the PRINT OPTIONS footer
and the client-side raster. regions-list.vue supplies only data and a
toggle — the same division country-poster-wall.vue states in its own
header.
- A "View country posters" toggle at the bottom of every region section,
carrying aria-expanded and aria-controls, using the same caret SVG the
header and footer links already use, rotated when open.
- Opening a region renders that region's countries. Measured: 41 cards
for Africa and all 41 previews LOAD (naturalWidth>0 — a 404 still
yields an <img>, so the load is the real check). No assets were
copied: public/assets/previews already holds 174 country-*.webp and
generate-tree already stages them.
- "Show Chad on the map" reaches the Region Dashboards tab with title
"Chad" and hash "#chad", by mapping PosterWall's CustomEvent listId
back through the country-selected the tabs already exchange.
- India prints its own reason with the exact number from the data; China
renders a real poster. Only India is unsupported — 855 groups and no
sheet, against China's 154 on a 3x4.
Two defects the screenshot caught and no assertion did:
- PosterWall's root is position:absolute;inset:0, because in its own
bundles it IS a pane. Embedded in a region card it painted 1440x1100
over the region's country list. Fixed by parameterising the shared
component rather than overriding its CSS locally: new `embedded` and
`sectionHeads` props, both defaulting to today's behaviour, so
country-posters and key-cities are unchanged by construction. Both
re-checked. Re-measured: root static, 1200px wide, panel below the
grid, no overlap.
- With one unnamed group the wall printed a section head reading the
literal "Other" plus a count duplicating the region card's own
summary. Suppressed by sectionHeads=false.
The dataset moved to library/data/country-posters.json now that it has a
second consumer, one import line changed in country-posters, and
emit-countries.mjs was retargeted so the next generator run cannot
recreate the duplicate.
The three new strings do not exist on DX-life, so there was nothing to
copy and nothing was invented in another language: en only, with the
other seven locales falling back to the key.
Regression: 15 suites green, including region-vs-live 73/73,
country-vs-live 28/28, mobile 44/44 and 26/26, and a new
_probe-region-posters.mjs. check:library-index drifted for real this
time (3 new library files) and is regenerated here.
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.
check:public-hygiene went from PASS to FAIL across a merge, with two
findings -- "documentation in a served folder" for
public/assets/fonts/noto-serif-devanagari/README.md and for the copy
generate-tree stages into the tree. It arrived with 467b7f0, "Hindi
unblocked with a vendored Devanagari face". public-hygiene sits inside
build:unlocked, so main was red-gating every builder, not just this branch.
NOT deleted, because the obvious move would have been the wrong one. The
folder also carries LICENSE (SIL OFL 1.1). That file is a DISTRIBUTION
OBLIGATION and has to stay beside the woff2 -- removing it to satisfy a
hygiene gate would trade a lint finding for a licence breach. The README is
developer rationale, and it answers something the files cannot: why the font
is vendored at all, and why the 400 and 700 faces resolve to one variable
file. So it moved to documentation/, verbatim, with a header recording where
it came from and why.
check:public-hygiene 0 after. gates:pre 0, gates:post 17/18 -- the remaining
failure is the pre-existing verify/tables-complete break from the visa-leak
fix, still filed and still not ours.
Also in this tick, after main moved 7 commits including "Regions: one
rollup, ported from theirs" (the same file carrying the MapLibre boundary
branch): merged, rebuilt, re-verified. The branch is intact and the
boundaries still load -- 400 features on mapbox, 467 on maplibre, 0 console
errors -- and the full provider sweep still reads 14/16 with no regression.
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.
publish-html.mjs enumerated the raw cluster keys under a comment that said "the
zone ids the app can actually reach" — which was exactly what it did not do. It
then warned that deaf-old-world and deaf-new-world-asia-pacific were MISSING on
every run, when nothing was missing.
Those two are SUPERSEDED by the unified `deaf` row (affinity bloc A017), not
absent. Verified against live records instead of trusting the comment that says
so: 63 + 60 = 123 = A017 exactly, zero groups on either side of the difference.
They stay in clusters.json deliberately — three isolation-proof scripts assert
deaf_lists.length === 2 — but the legend does not expose them, so no user can
select them and a poster for them is unreachable by construction.
It now derives zone ids from leafZones(buildLegendNodes(...)), the same source
the app itself uses, so the two can never disagree again. Result: 28 posters
published, zero warnings, matching the legend's 28 leaves.
records:null, not records:[] — buildLegendNodes treats an empty array as truthy
and then demands population fields that are not available here.
A recurring warning that is always wrong is worse than no warning: it trains
everyone to ignore the one run where something really is missing.
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.
The packet now ships in every locale the app ships: en es fr pt de it ro ru ar
hi zh. Each carries all 21 translatable keys, the twelve prayer points, the
eleven rhythms, both attributions and the privacy note. point4_passage stays
omitted everywhere and falls back to the licensed English WEB text, for the
reason already recorded — translating scripture here would put unlicensed,
unverified Bible text into a printed prayer packet.
HINDI WAS BLOCKED ON A FONT, not on a translation. Devanagari renders as tofu
boxes in the packet's Georgia/Palatino/serif stack because this machine has no
Devanagari font at all. Noto Serif Devanagari (SIL OFL) is now vendored —
devanagari subset only, 124 KB, licence and provenance beside it, matching how
poppins/ ships latin and latin-ext separately.
It is base64-INLINED into the packet, not linked. A packet is a standalone file
meant to print offline — the Node generator writes HTML with no server, and the
photos are already inlined for exactly this reason — so a URL would break
precisely where the packet is most used. Cost: ~165 KB on a 3.5 MB packet, and
only for that language. One rule prepends the face to the stack rather than
editing 22 hardcoded Georgia declarations, and the browser falls back PER
CHARACTER, so Latin scripture references in a Hindi packet still set in Georgia.
mr and ne map to the same face; a missing font warns loudly rather than silently
producing rectangles.
Chinese needed no font work — CJK already shapes in the system stack — only the
translation.
★ TWO MEASUREMENTS LIED ABOUT SCRIPT COVERAGE, recorded so nobody automates this
cheaply. Comparing shaped width against a missing font reported ar and zh BROKEN
and hi FINE — exactly backwards, because Devanagari tofu boxes happen to match
real glyph advance. An ink-column-profile heuristic then scored CORRECTLY
RENDERED Devanagari the same as tofu (3 vs 2 distinct heights), because its
baseline bar gives a uniform profile. Both were abandoned: a detector that blocks
working packets is worse than none.
Verified: all 11 languages render 8 sheets -> 8 letter portrait pages, map
painted, zero overflow. The Hindi prayer-points page was READ AS A PICTURE —
conjuncts and matras shaped correctly, no boxes, Latin references still Georgia.
summarizeRegions() was duplicated in regions-list.vue and
region-dashboards.vue — two copies of the same rollup, so two chances
for the region page and the dashboard to disagree about one number.
Both now import a port of DX-life's config/countries-meta.ts, placed in
library/ because it has two named consumers.
Ported verbatim: slugify's replace chain, FULL_PRAYER_COVERAGE_COUNT,
addToStats's conditions, fitView's constants, lngExtent's largest-gap
walk, WORLD_VIEW, both sort orders. Changed only: TypeScript to JS
(interfaces to JSDoc), and their `import { COUNTRIES } from
'../app/utils/countries'` becomes library/data/countries.json — their
same 242-entry {value,label} array, in their order. toNumber and
emptyStats are exported beyond their surface, and only those two: their
pages need no cross-region total because DX-life has no "all regions"
view, and ours does for the no-selection chooser.
Two things the copies had wrong, both fixed by porting rather than
patching:
- They slugged a country from the API's LOCALIZED label. Theirs slugs
from the canonical English name, deliberately. The live site settled
it: /regions/the-united-states is 200 and /regions/united-states is
404; /regions/the-democratic-republic-of-the-congo is 200 and
/regions/dr-congo is 404. Since the dashboard writes the slug into
the URL hash, our deep links pointed at pages that do not exist
there.
- They dropped centre/zoom/bounds entirely, so our maps derived their
own frame. Theirs computes a fit view per country and per region,
including the antimeridian: Oceania's bounds come out east 187.9,
where a naive max() says 178 and spans the globe backwards.
Also fixed regions-list.vue's formatNumber, still hardcoded en-US after
the same fix landed in region-dashboards.vue, and its region sort, which
used (a.label || 'Other') where theirs compares a.label directly.
Proof: region-vs-live 73/73 and country-vs-live 28/28 UNCHANGED — the
rollup is equivalent. New _probe-countries-meta.mjs is 20/20 and
includes a negative test showing the antimeridian assertion really
discriminates. oceania-and-list, regions-swap, standalone-app,
dashboard, total and search all green. 159 lines of duplication gone.
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.
Driver asked for "horizontal cards" alongside the vertical ones, with the print
dialog's aspect ratio chosen at the print point.
★ LANDSCAPE IS NOT TRANSPOSED DIMENSIONS. The portrait card stacks the photo
ABOVE the text, occupying the top 46%; at 3.5 x 2.5in that same band is a strip
too short to carry a face AND leave room for the rows under it. The landscape
stocks put the photo BESIDE the text instead — full-bleed down the left 42%,
name over a scrim at its foot, data and QR in the right column. So `side` is a
LAYOUT switch the CSS reads, not a cosmetic flag.
It is driven by the stock rather than by comparing width to height: a square
stock would be ambiguous, and the composition is a deliberate property of the
format, not an inference from its numbers.
Two landscape stocks added and exposed in the poster-wall picker:
pokerLandscape 3.5 x 2.5 in -> 252 x 180 pt
postcardLandscape 6 x 4 in -> 432 x 288 pt
Verified at the rendered layer by the new check:print-geometry, which measures
the MediaBox rather than reading the CSS — and read as a picture, not only
asserted on: photo left, "Aheu" over the scrim, country/people/language/religion
in the right column, QR and "PRAY FOR THEM" at the foot, zero overflow.
Driver: "my filter underneath the simple research map does not look like the
same CSS styling of the one on dx-life."
Read the reference: UupgsList.vue puts the input and the Exact button in ONE
`.repel` row inside `.search-box`, so the modifier reads as part of the
field it changes. Ours had Exact in a SECOND filters__panel below the four
dropdowns, where it looked like a fifth filter -- and, because that panel
only exists while filters are expanded, a user searching with the panel
collapsed could not reach it at all.
★ COPYING THE REPEL WITHOUT THE WIDTH CAP MADE IT WORSE, and the screenshot
was the only thing that said so. dx-life's markup is
`.search-box | center | max-width-md` wrapping the repel: the repel only
reads as a PAIR because the box is narrow. Dropped into our full-width row
it pushed Exact against the far edge -- input ending at x=843, button at
x=1231, 390px apart -- which reads as an unrelated control. The row is now
capped at 600px and centred, and repels inside that. Wraps on a phone so the
button drops under the input rather than squeezing it.
NOT DONE, and deliberately left for the Driver: he listed Country, Adopted
and Engaged among dx-life's controls. This element ships FOUR dropdowns by
an explicit earlier decision of his, recorded twice in this file -- "COUNTRY
IS PROP-ONLY, NOT A FIFTH DROPDOWN ... the Driver's exclusion of Country was
about this element's OWN CONTROLS". Describing dx-life's controls is not the
same as asking for Country back, and adding it would silently reverse that.
Written up in the rollout ledger as a question instead.
gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix.
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 packet now ships in 9 of the app's 11 locales: en es fr pt de it ro ru ar.
Each carries all 21 translatable keys — page furniture, the twelve prayer points
with sub-headings, the eleven rhythms, both Metacamp attributions, the privacy
note. point4_passage (Mark 5:1-20 in full) stays omitted in every non-en block
and falls back to the licensed English WEB text with its credit, for the reason
already recorded: translating scripture here would put unlicensed, unverified
Bible text into a printed prayer packet.
RTL IS NOW REAL, not assumed. The renderer emits dir on <html> from an RTL
locale set. Without it an Arabic packet renders readable glyphs LEFT-aligned —
which looks like a styling quirk rather than a bug, and no assertion noticed.
★ TWO BIDI FINDINGS, both from reading the render rather than the code:
1. Scripture references are LTR runs inside RTL paragraphs, and the bidi
algorithm REORDERS a run that starts with a digit: "1 Peter 5:6-11" rendered
as "Peter 5:6-11 1". Each reference is now dir="ltr" + unicode-bidi:isolate.
2. With isolation, the reference LIST reads right-to-left on an RTL page (first
item rightmost). That is correct for an RTL reader, not a defect — recorded
in _meta so nobody "fixes" it. English source order is unchanged, asserted.
★ HINDI IS DELIBERATELY NOT ADDED. Rendering each script at 48px and READING THE
PIXELS: Latin, Cyrillic, Arabic and CJK all shape correctly in the packet's
Georgia/serif stack; DEVANAGARI RENDERS AS TOFU BOXES, because this box has zero
Devanagari fonts (fc-list). A hi block would ship a page of empty rectangles. It
is blocked on vendoring a Devanagari font into public/assets/fonts, which is its
own piece of work.
⚠ THE MEASUREMENT THAT LIED. My first test compared rendered text advance against
a missing-font baseline and reported ar and zh BROKEN and hi FINE — the exact
opposite of the truth, because Devanagari tofu boxes happen to match real glyph
advance. One screenshot settled in a glance what two rounds of clever
arithmetic got backwards. Recorded in _meta.
Verified at the rendered layer in all nine languages: 8 sheets -> 8 letter
portrait pages, 95 people cards, 12 wheel segments, map painted, zero
overflowing sheets, 19 strings differing from English per locale. The Arabic
prayer-points page was read as a picture, not just asserted on.
Driver: "two finger scrolling on the map should allow me to interact with
the map, but instead it just scrolls down past the map and does not
interact with the map -- that's what the scroll side bars are there for, to
let me interact with the map AND have a way to scroll down past the map."
REPRODUCED before touching anything: wheel over the centre of the map in
research-map-plus-grid moved scrollY 0 -> 480 and the camera never moved.
The map was inert exactly where it is the main control.
CAUSE: _syncScrollZoomToHost disabled scroll-zoom whenever the document or
an ancestor could scroll -- which is ALWAYS true in a composite that stacks
a grid under a map. It was not careless: it fixed
BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, where the map ate every wheel and the
grid below could not be reached. But that bug already had a second fix --
the .map-scroll-edge gutters, a dedicated page-scroll lane down both sides,
which the Map constructor's own note calls "the single-finger page-scroll
escape". Two fixes for one bug, and the second silently undid the feature
while leaving the first redundant.
So scroll-zoom now stays enabled and the strips carry the page. The
docScrolls/ancestorScrolls probe stays, because the edge affordance keys its
gray indicator off the same question.
MEASURED on the built tree, 1280x900:
wheel x4 over map centre scrollY 0 -> 0 map takes the gesture
wheel x4 over edge strip scrollY 0 -> 480 escape still works
(two strips, 46px wide each)
And the repo's own fitness gate, which exists for this property:
check:fits-screen 17/17 PASS -- including "the grid is reachable" and
"the wheel is targeted", so the bug this replaced has not returned.
DashboardMapPanel carries the same helper and was deliberately NOT changed:
different layout contract (map inside a fixed shell with facet/table/rail
columns), and fits-screen passes it today as "wheel escapes every column".
Flagged in the ledger for the Driver rather than altered on the strength of
a report about a different surface.
gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix.
Port dx-life's fuzzy search into UupgsList onto the repo's existing
@map/components/search/fuzzy.js shim: their 7 SEARCH_KEYS, threshold 0
when exact else 0.4, minMatchCharLength 3, diacritic stripping, their
highlight() construction and match routing, and .search-highlight in
#92b195 from their blocks/_cards.scss:258. A typo now finds the row and
the matched run is wrapped, neither of which the old substring filter
did. _probe-search.mjs: 6/6.
Then, reading the screenshot taken to prove that search worked, three
clone-fidelity defects that every numeric check had certified green:
- The count line above the list read "N People Groups · Cameroon". It
was invented; it is not on their page. Theirs
(UupgsList.vue:577-583) is "Total: N" in .font-size-sm inside a
.repel, with the see-all link suppressed by hide-see-all-link.
_probe-total.mjs: 7/7.
- formatNumber was hardcoded en-US. Theirs (RegionDetail.vue:16) is
n.toLocaleString(`${locale}-u-nu-latn`) — locale separators, Latin
digits. We were wrong in de, fr and es.
- The country strapline formatted its count. Theirs
(CountryDetail.vue:40) is the raw {{ country.count }} — that
component never defines formatNumber. Their asymmetry with
RegionDetail; a clone matches it instead of improving on it.
Why nothing caught the invented line: every check compares properties
of elements present on BOTH sides, so an element present only on ours
is invisible to all of them. _probe-total.mjs now carries a surplus
check — a phrase assertion scoped to .ul-root, since the first draft
swept the whole element and failed on their own legitimate "People
Groups" in the stat cards and the country table header.
Regression: region-vs-live 73/73, country-vs-live 28/28, mobile 44/44
and 26/26, i18n, chooser, dashboard, standalone-app, search, switcher,
goalcards, h1-highlight, total — all green.
Driver: "there is an SVG of a map that shows up under the search bar, where
the toggles are... my filter underneath the simple research map does not
look like the same CSS styling of the one on dx-life."
Read the reference rather than guessing at it:
dx-life/app/components/public/UupgsList.vue puts
background-image: url(<images>/worldmap.svg); background-size: 60%;
background-position: top
on the container wrapping the search box AND the toggles. So it is a
BACKGROUND behind the whole block, not an icon beside the input -- which is
the detail that makes it read as a map tool instead of a form. Ported
faithfully, per the house rule to imitate DX-life's decisions rather than
substitute our own tokens.
The 311KB file is served as a SHARED ASSET, not inlined: a data: URI would
put it in every bundle that imports this grid, and it is decorative.
resolveAssetsBase() is the same helper the boundary geojson already uses, so
it resolves wherever the tree is dropped -- CDN, sub-path, or the studio --
instead of assuming a root-relative path. It lives in public/assets/, the
shared pool, which `clean` does not wipe, so no change to the tree verb.
It is drawn ONLY when the block exists. With show-search and show-filters
both off there is nothing to sit behind, and a backdrop with no controls on
it is noise.
MEASURED on the built tree:
/assets/brand/worldmap.svg HTTP 200, 0 failed requests
#filters computed backgroundSize 60%
#filters computed backgroundPosition 50% 0% (top centre)
grid leaf (defaults on) backdrop ON
research-map-plus-grid (Simple Research) backdrop ON, behind search +
Hide Filters + 4 dropdowns + Exact
dashboard-plus-grid (both props false) no #filters element at all
Screenshot read, not just counted.
gates:pre 0, check:public-hygiene 0. gates:post 17/18 -- the single failure
is the pre-existing verify/tables-complete break from the visa-leak fix,
filed and not ours.
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".
The app ships 11 locales; the packet shipped 1. A Spanish-speaking reader got a
Spanish interface and then an entirely English prayer packet. The per-key
fallback machinery was built and verified several ticks ago — the catalog was
simply never populated, so i18n was functionally unshipped.
es, fr and pt now carry all 21 translatable keys: the page furniture, the twelve
prayer points with their sub-headings, the eleven prayer rhythms, both Metacamp
attributions and the privacy note. Terminology follows the app's OWN existing
locales rather than inventing a parallel vocabulary — "grupo étnico" for people
group and "no evangelizado" for unengaged come straight from
library/i18n/locales/es.
★ point4_passage IS DELIBERATELY OMITTED FROM EVERY LANGUAGE BLOCK, and that is
the point, not an oversight. It is Mark 5:1-20 printed in full, and the file's
own guidance already required "a public-domain or properly-licensed version for
that language". Translating scripture myself would put unlicensed and
unverified Bible text into a printed prayer packet. Omitting the key makes it
fall back to the licensed English WEB text with its credit line intact —
honest, and visibly English, which invites a real translation rather than
hiding the gap. The note in _meta now says this outright so the next translator
does not "complete" the block.
Also recorded, because the render made it visible: scripture REFERENCES stay in
English in every language ("Matthew 27:27" on a French page), so a reader can
find the passage in their own Bible. That is a deliberate tradeoff and it does
look foreign next to fully translated prose — _meta now names it and says how to
add localised book names properly if a language wants them.
Verified at the rendered layer in all four languages: 8 sheets -> 8 letter
portrait pages, 95 people cards, 12 wheel segments, map painted, zero
overflowing sheets, and Mark 5 correctly falling back to English with its
credit. The French prayer-points page was read as a picture, not just asserted
on: all twelve points translated, Matt 27:27 intact at point 1, "un lieu sûr" at
point 8.
Caught by spot-checking after main moved, not by the sweep. RegionCountryMap
added its country polygons from mapbox://mapbox.country-boundaries-v1, a
Mapbox-hosted VECTOR TILESET that MapLibre cannot fetch:
Fetch API cannot load mapbox://mapbox.country-boundaries-v1.
URL scheme "mapbox" is not supported
The basemap then drew perfectly with the highlighted countries simply
absent. My own sweep called this leaf "SWAPPED" because it counts canvases,
engines and request totals -- all of which were right. A coarse metric can
be green while the primary data layer is missing; only the console said so.
Fixed by the pattern DashboardMapPanel already proved, copied rather than
reinvented: under maplibre the source is geojson, fed from the keyless
assets/boundaries/world-adm0.geojson the tree already ships and three other
plugins already consume through resolveAssetsBase. Added EMPTY first and
setData after the fetch, so the addLayer calls stay synchronous and the
fills remain beneath the pins. The tileset names the ISO field
iso_3166_1_alpha_3 and the geojson names it iso3, so the property is
normalised ONCE at load and countryFilter() keeps reading the single name it
always read -- the Mapbox path is byte-for-byte unchanged. 'source-layer' is
set for Mapbox alone; MapLibre rejects a layer that declares one on geojson.
MEASURED via the PUBLIC querySourceFeatures (not a private field -- that
lesson cost two reverts earlier in this flight):
mapbox 400 boundary features, 0 console errors
maplibre 467 boundary features, 0 console errors (was 1 scheme error)
Both engines expose the same three layers.
gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix (filed, not mine).
Four zones had NO POSTER AT ALL. measureHeader() opened with
`if (!SPARSE) return HEADER_BASE` — a flat 460 — so the per-zone measurement its
own comments describe ("HEADER IS PER-ZONE, NOT A SHARED CONSTANT...
measureHeader() predicts the height from the zone's OWN content") only ever ran
in SPARSE mode. Outside it, any zone whose header was taller than 460 tripped
the fail-loud guard and rendered nothing:
oceania-indonesia 504 andean-caribbean 504
asia-china-tibet-autonomous-region 481 deaf 619
Raising the constant was not the fix. HEADER is subtracted from the map height,
so 460 -> 620 would have stolen 160px of map from the 25 healthy sheets to
rescue 4 — the exact tradeoff the per-zone measurement exists to avoid, and the
one its own comment records (tuned to North India, it overflowed west-africa;
tuned to west-africa, it stole from everything else).
MEASURED the real header on six zones instead:
HEAD_BOTTOM = (headY + 34) + 118 exact on all six
headY = 191 + 23 * tableLines exact on all four probed
The 118 is the two vision columns — a constant 104, because that copy is fixed
and wraps identically on every sheet — plus the 14px trailing gap. ALL per-zone
variation is the facts table, which advances 23px per wrapped line. So this is
arithmetic on the same inputs the renderer wraps, not an estimate, and it needs
no calibration history like the SPARSE model's +24/+52/+66/-8/+6 above it.
★ THE ERROR WORTH RECORDING: my first attempt derived the constant by READING
the code — tracing headY's assignments to 150 + 23n - 12 — and was 53px short on
every zone, because the scripture block between the table and the vision columns
also advances the cursor and I had not traced it. Every zone then failed,
including ones that worked before. Deriving it from four measured renders got it
exact. Reading selectively is not measuring.
Result: 28 posters render, zero failures. And the map GREW on the healthy
sheets, because they no longer pay for the worst case — laos' map frame starts
at y=443 against the old flat 460, while deaf takes the 627 it genuinely needs.
Still missing after this: deaf-old-world and deaf-new-world-asia-pacific, which
never reach the render at all (silent skip, exit 0 — they are `deaf_predicate`
selectors that leafZones() does not return). Different cause, carded separately.