Commit graph

548 commits

Author SHA1 Message Date
Doxa Maps
8896ad2130 Merge remote-tracking branch 'origin/development' into upg100/wall-count
# Conflicts:
#	internal/check-batch-selection-export.mjs
#	package.json
#	plugin-designs/my-upg-100-list/data/selection-export.js
#	plugin-designs/my-upg-100-list/poster-wall.vue
#	plugin-designs/my-upg-100-list/upg-100-list.vue
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/manifest.json
#	public/dx-maps/my-upg-100-list/my-upg-100-list.js
#	public/dx-maps/my-upg-100-list/poster-wall/index.html
#	public/dx-maps/my-upg-100-list/upg-100-list/index.html
#	public/dx-maps/my-upg-100-list/upg-100-mfe/index.html
2026-10-01 08:24:48 +00:00
Doxa Maps
a1c74004a0 Merge development (353bf078c) into the whole-India landing: package.json gates unioned (check:side-lanes added), poster-render re-pinned on the merged file 2026-10-01 08:24:45 +00:00
Doxa Maps
cbafb21921 Merge origin/development (86a81cc44) into studio/shell-header 2026-10-01 07:49:52 +00:00
Doxa Maps
9b0f815e7c Merge origin/development (86a81cc44) into studio/poster-sizes 2026-10-01 07:49:08 +00:00
Doxa Maps
ba022f68f9 Merge origin/development (d3b879c94) into studio/pinhint 2026-10-01 06:32:47 +00:00
Doxa Maps
cdbda65a06 People-group names: the threshold pinned, no trust without Latin evidence, raw indexing flagged
Folds in the review's four points:
(a) check:pg-name-gate plants a row with EXACTLY two far Latin locales, which must
    show English, so a rule that needs three fails.
(b) A row with fewer than two Latin names cannot be checked (117 rows), so it now
    shows English too. The whole table: 8,172 of 21,419 (38.2%).
(c) The one-reader scan also flags a file that imports the helper but still
    indexes rows itself (`.names[`), with negative controls both ways.
(d) The helper documents that callers always pass `english`.
Seven mutants of the helper each fail the gate.
2026-10-01 06:04:41 +00:00
Doxa Maps
269226318d Merge origin/development (863174825) into studio/p-assembly 2026-10-01 05:57:02 +00:00
Doxa Maps
1c61cbb926 Country Posters: the whole-India poster prints, every group named
India's card is no longer "too many to fit": it prints as the 60x96 side-lane sheet (all 855 groups named beside
the map, crossing-free leaders, real photos), whatever paper the wall asks for, until the shared size picker
offers 66x66 and 36x60 too. Its facts row reads "36 states and union territories" (English, from the wall
head's plural key). As ruled, the whole-India card offers no prayer list, packet or cards; the state cards keep
theirs. Its card preview is the real sheet.
check:side-lanes (+ :self-test, gates:pre): the three side-lane formats on a seeded worst case (855 groups,
stacked and clustered) through the real renderer: every name once, 0 crossings re-measured from the SVG, no
overflow, photos only where real; without the lane clamp the same data crosses 36 times, and the arm fails.
2026-10-01 05:52:47 +00:00
Doxa Maps
6e80da212c People-group names: one helper decides when a localized name can be shown
library/utils/peopleGroupName.js (pure) returns a group's name in the reader's
language only when the row's names are transliterations. When two or more of its
Latin-script names (fr pt es tl id de) are far from the English one (bigram Dice
< 0.5), the row was machine-translated as words (Balantak → "Balance",
Balo → "Sac à dos"), and every locale of that group shows English. That is
8,055 of 21,419 rows (37.6%). it and ro have no column and show English; zh
reads zh_CN. A fallback shows the caller's own English name.

check:pg-name-gate (gates:pre) proves it on the real table: the 4 known
translated rows show English in 7 locales, Balangao Bontoc keeps its names,
planted rows are two-sided, the whole-table fallback stays between 30% and 45%,
and no file outside the helper reads pg-names rows (pplr-data-maps is the
exception; the scanner is negative-controlled). Five mutants of the helper each
fail it. No consumer is wired yet.
2026-10-01 05:52:31 +00:00
Doxa Maps
802d7b5b8a P assembly: the widened census, stated neutrally
The case-insensitive census with every plugin agent's name found 30 more lines: library (ProfileLoader,
useMapProvider, ApproxPinNote, listSelection, colocation), the gallery's embed-contract card id, the gospel
plugins' source notes and build scripts, a regions script note and one gate description. Comments and notes
only, except the Gospel x Internet CSV export's provenance line, which no longer names who screened the links.
2026-10-01 05:42:24 +00:00
Doxa Maps
2f7eaae8e3 check:no-fleet-names: source names no build-team member, attributes no request to a user, carries no dated QA tag
A sweep brought tracked source to 0 such lines; this gate keeps it there. It scans every tracked text file
except research data, build output and the translation catalogues, and allows only named lines (a file plus
the exact line, with its reason), never a whole file; the list is empty, since the gate builds its own
patterns from parts. gates:pre runs it; gates:post runs its self-test, which plants each class and checks
the per-line allowance.
2026-10-01 05:36:29 +00:00
Doxa Maps
77751ddcb6 Merge origin/development (53fcf6d09) into studio/p-assembly 2026-10-01 05:32:16 +00:00
Doxa Maps
315dc92a65 Merge remote-tracking branch 'origin/development' into wall-of-people-groups-plugin/work 2026-10-01 05:31:29 +00:00
Doxa Maps
cfab45ff97 Merge remote-tracking branch 'origin/development' into wall-of-people-groups-plugin/work
# Conflicts:
#	package.json
2026-10-01 05:19:07 +00:00
Doxa Maps
f3851da57c PosterWall: sectionItemCountKey, so one section can be counted in its own words
The Country Posters India section headed "855 people groups · 37
countries", which is untrue: its cards are India's own and one per state
and union territory. sectionItemCountKey (default null: every section
counted with item-count-key, as before) lets a wall answer, per section,
an i18n plural key, or { key, n } to count n rather than the section's
card count, or null to keep the default.

Walls that do not pass it render byte-identical trees before and after
(a differential render of an upg100-style and a country-posters-style
wall). check:poster-wall-host-seams arm K: a named section counts in its
own key and number, an unnamed one keeps the default; it fails with the
prop ignored.
2026-10-01 05:10:55 +00:00
Doxa Maps
24f3e4176a PosterWall: a section head names its people-group total only when there is one
A section head read "<n> people groups · <items>" always, so a wall whose
cards count no people (Poster Design's gallery of templates) showed
"0 people groups · 4 lists" on every head. The total and its " · " now
leave together when the total is 0, the rule the cards already follow with
v-if="item.count". The head is still one string in one interpolation, so a
section with a total renders exactly the text it always did, whitespace
included: a differential render of an upg100-style wall and a
country-posters-style wall, before and after, gives identical trees.

check:poster-wall-host-seams gains arm J (a head with people keeps its
total; one without reads its items alone); the old head line fails it.
2026-10-01 05:10:55 +00:00
Doxa Maps
f526ef3d4f P: merge 9ed84bd56 2026-10-01 00:56:02 +00:00
Doxa Maps
2eaea5cedf P (repo): neutral wording in the gates and docs, and no private-process checks
Comments, messages and docs only; no check logic changes.
- The role-word gate is renamed check-no-internal-role-nouns (and its baseline fixture). Same
  logic: it builds the word from parts so the file does not carry it, and a planted line in
  title case and in capitals still fails it while WebDriver and "GPU driver" do not.
- public-hygiene-check: the two role patterns are built from parts (the regexes are byte-
  identical), the header loses the forge and date wording, and the prayer comment loses its
  decision date. Its output on the tree is identical in normal and --strict modes.
- landing-preconditions, check-no-public-binds, check-published-tree-weight: no fleet tool
  name, no "this box", no forge name in comments and messages.
- Removed: check-backlog-refs, check-backlog-status-enum, check-done-evidence,
  check-verification-tags and check-dxlife-untouched (they police a private backlog and
  workspace), with their scripts, docs, gate-list entries and orphan-baseline entry, and the
  committed needs-eyes pages (check-embedded-rtl --emit writes them on demand).
- package.json docs: no agent names. Docs: the stale check mentions and the snapshot
  references are gone (the snapshots themselves leave in the studio P commit).
gates:pre: PASS 115, FAIL 0, NOT MEASURED 1 (browser seat).
2026-10-01 00:25:38 +00:00
Doxa Maps
6bc7d70bf3 Wall of People Groups: the poster prints from 20 MB of photos instead of 133 MB, and says how far along it is
The print waits for every card's photo before the print dialog can open. 77 of the 939 photos are IMB
originals at 1024 px (~1.5 MB each), 114 MB of the 133 MB: on a home connection a minute or more
with nothing on screen but a busy button.

- Photos load at the size they print (photoUrl.js): the IMB host's own 320 px copies (1.3 MB for all
  77). 320 px covers the largest printed photo (5 x 8 ft, ~0.97 in, 291 px at 300 dpi) and the canvas's
  cap. A resized copy that fails falls back to the original, in print and on screen. Every other photo
  (Joshua Project, doxa, already 150-250 px) is untouched.
- Measured on the print flow, 3 x 4 ft: 133 MB -> 19 MB; 5 x 8 ft: 20 MB; 0 photos failed.
- While it prepares, the shared print card (MapPrintProgress) shows "Preparing the poster: n of N
  photos" with a clock and a bar, in the middle of the screen.

check:wall-photo-weight (gates:pre, headless) pins the mapping on both IMB hosts, the fallback, the
untouched hosts, and that the print HTML and the canvas both use it. It fails when either reverts to
the originals.
2026-10-01 00:03:56 +00:00
Doxa Maps
c870daa803 Pin hint: on a page that scrolls, the pill sits at the map's bottom instead of hanging at the fold
On a partner home page the map is a block in a scrolling page. The clamp floored the pill at the viewport
fold, so a map whose bottom was below the fold at load had its pill lifted to mid-map, re-set by the settle
timers and resize observers, and left there after a scroll, then dropped. The fold now counts only on a
page that does not scroll (a full-screen pane or leaf, where a phone's browser bar can cover the bottom);
the legend sheet and the licence credit still lift the pill everywhere. The floor is a pure function
(pinHintFloor.js), and check:pin-hint-floor proves both cases; its self-test refuses 5 planted defects.
2026-09-30 23:57:11 +00:00
Doxa Maps
1e300309c8 Country Posters: one poster for each of India's states and union territories
The unfiltered wall gains an "India — states and union territories" section: 34 cards, one per state or union
territory with people groups listed (from the shared india-states table, fetched and sha-checked only when the
section is shown), each with its poster, prayer packet and cards. Nagaland and Mizoram get a card saying no
unreached people groups are listed there, so no division silently disappears. A host's filtered wall
(countries/region) is unchanged and never loads the table.

src/indiaStateSheet.js draws a state's sheet through ONE path: its records by slug, its outline from the
ADM1 boundaries the renderer already loads (matched on shapeName; the shared collection is copied, never
mutated), India's hue, and the indiaState series ("India State Maps", map N of 34). Show on map for a state
frames India, since the countries map draws countries.

check:country-sheet-one-path grows the state arms: inside this plugin only the helper calls generateSheet,
the wall must use makeIndiaStateSheet, and over the served table + ADM1 file: 36 divisions, the 34 with groups
numbered 1..34 each with an outline, the rest named, the ADM1 collection unmutated. Self-test 10/10.
Rendered headlessly through poster-render for Gujarat (90 pins), Lakshadweep (1), Andaman and Nicobar (9):
the state's name as title, "India State Maps", its series position, no country-series text, no "Province".
2026-09-30 23:33:34 +00:00
Doxa Maps
25982d717f Shell header: a phone bar and a rail-bottom language slot for header-off shells, and a shared language menu that asks the host first
VerticalTabsLayout, header off: on a phone a 44px bar (a real grid row) replaces the floating corner
circle, with the rail toggle at the inline start and a #phone-end slot at the end; on a desktop a
#rail-end slot sits at the bottom of the rail (it receives `collapsed`). Neither renders with a header
on, so header-on consumers and desktop layouts without a filler are unchanged. The --map-fab-* footprint
is no longer published; its readers fall back to their plain insets.

ShellLanguageMenu fills either slot: the switcher's languages plus the current one, a native select
under a globe face. A pick dispatches one cancelable, composed, bubbling doxa-language-request on the
host element (library/utils/languageRequest.js). A host that cancels keeps the language; otherwise the
menu emits 'switch' and writes ?lang= only on a page the tree generated.

check:shell-header (gates:pre, Vite SSR) asserts where the bar and the rail slot render, the event's
shape and count, cancel and no-cancel, the ?lang= rule and the language list; its self-test (gates:post)
refuses 11 planted defects. check:nav-reachable now presses the bar's toggle.
2026-09-30 23:28:52 +00:00
Doxa Maps
85508b899d UPG-100: the list's total is never written into a sentence. The wall intro (upgMap.wallIntroA, {n} in 11 locales) and the fetching status take it from the funnel's last step (2,107 today; the intro printed a stale 2,106), formatted for the reader. New gate check:upg-count (gates:pre), mutation-tested 2026-09-30 23:27:02 +00:00
Doxa Maps
e1c79fb19f Poster sizes: gates:post runs the size ladder's self-test 2026-09-30 23:14:35 +00:00
Doxa Maps
aace7cf884 Poster sizes: one shared ladder for every print surface (paper, 3x3 ft, 3x5 ft, 5.5x5.5 ft, 5x8 ft)
library/constants/posterSizes.js gains POSTER_LADDER: five portrait sizes keyed WxH in inches, each with the
sheets a printer feeds (named as panelSheets.js planPanels() takes them), whether a photo slot fits, and how
it reaches paper (the browser's print for paper, a print-shop PDF for the rest). Legacy 24x36 and 36x48
still resolve, older names (3x3ft, 5x8ft, letter, a4...) alias to the ladder, and paperFor() picks Letter
or A4 from a locale's region. The existing map-export presets are unchanged.

check:poster-sizes (gates:pre) audits the table: the order matches the ladder, keys are dot-free, every
size is portrait, the sheets glue up to the finished size, photos on exactly the two large sizes, paths,
aliases and the paper fixtures. Its --self-test refuses 8 broken tables.
2026-09-30 23:03:39 +00:00
Doxa Maps
855e89db65 Poster Design: a new plugin with Styles and Sizes tabs
Every poster template the library prints, in one place, so the designs
can be compared side by side and handed to an agent by name.

- poster-design-mfe: the two-tab element (capability 4), the same shape
  as country-posters-mfe: the shared ViewSelect over Styles and Sizes,
  each tab its own element, one ShareButton for the composite.
- poster-styles: the library's PosterWall, one card per configuration
  (src/templates.js: country map sheets at each format, the packet page,
  the packet variants, every card stock, the Bless wall panels, the map
  print). Through PosterWall's host seams the header sticker reads "Show
  full details" and opens a dialog with the configuration name, size,
  file size, settings, sample and renderer, and each thumbnail is drawn
  from the library with sample data (Nepal) when it comes into view.
  Templates the gallery cannot draw yet say which part is missing. The
  dialog fits a phone (border-box), and in Arabic its file size and the
  configuration name stay whole (LRI…PDI isolates).
- poster-sizes: waits for the shared size table rather than keeping a
  second list of sizes.

check:poster-design-catalogue (gates:pre) keeps the catalogue true: its
sheet formats match poster-render FORMATS, names are unique, the card
entries are card-render STOCK, its keys exist, the sample can be drawn.
2026-09-30 22:42:05 +00:00
Doxa Maps
68ba3b3402 Posterverse: poster plugins only
UPG Neighbor and All People Groups leave the Experimental folder: they are
research maps, and Posterverse holds the plugins whose purpose is posters.
Experimental is now Wall of People Groups (and Bible Posters once it exists).
Their labels, composes refs and heavy-tag entry go with them; the shelf gate's
allow-list and its self-test follow. The Research Gallery is unchanged.
2026-09-30 22:35:34 +00:00
Doxa Maps
1d2b29d6b5 Merge origin/development into studio/land-pd-seam 2026-09-30 22:23:58 +00:00
Doxa Maps
323283a43c Merge origin/development (32715a5cc) into upg100/india-states; package.json keeps both sides' gates; rebuild my-upg-100-list from the merged source, and regenerate the tree and SRI 2026-09-30 22:17:44 +00:00
Doxa Maps
3f23274a30 Merge origin/development (f8de00edf) into upg100/india-states; package.json keeps both sides' gates; rebuild my-upg-100-list from the merged source, and regenerate the tree and SRI 2026-09-30 22:11:30 +00:00
Doxa Maps
356e1063dd Merge PanelSheets (wall-of-people-groups-plugin/work, 3ee5ed9fd..f5ec4a180) with the PosterWall host seams, for one capture 2026-09-30 22:09:52 +00:00
Doxa Maps
332aa002f8 Wall of People Groups: the Bless poster takes its sheets, glue bands and marks from PanelSheets, byte for byte
blessPoster.js now plans its 5 x 8 ft sheets, finished size, screen layout, glue band, trim line and sheet
tags through library panelSheets.js instead of its own copy. The print HTML, the screen layout and the plan
are unchanged: hashed for 3 presets and the default in all 11 locales (44 cases), before and after, all
identical; a 0.001 in change to the glue label moves all 44.

check:panel-sheets (gates:pre, headless) pins both geometries (60x96 stacked, 66x66 side by side, one
sheet; glue and bleed):
- the sheets tile the finished poster;
- glue slices tile it, and bleed slices repeat exactly the join;
- a bleed join prints a guide, not a solid band, and a screen preview always shows the red band;
- the Bless 5x8ft takes its bands and marks from the module.
It is two-sided: 4 planted defects are caught, and the Bless arm fails on the old private copy.
2026-09-30 22:03:40 +00:00
Doxa Maps
ffbb07e4a4 Country Posters wall draws its sheets through makeCountrySheet
The wall had its own inline wrapper around generateCountrySheet, a second
copy of @map/packet/countrySheet.js, which the Regions tab uses. Two copies
of one wrapper drifted twice (a teal Regions print, then India's series). The
wall now calls makeCountrySheet with the same country list, still handing it
the data's own English record rather than the localized card.

Behaviour: identical sheets for every country with records; a country whose
records come back empty now yields no sheet (the wall falls through to its
stored preview), as the Regions tab already did.

check:country-sheet-one-path (in gates:pre, with a self-test) parses every
plugin source: a direct generateCountrySheet call fails, every
makeCountrySheet call must pass the series list, both known callers must be
found, and seriesFor gives named answers over the real data (India 1 of 1,
each 3x3 country N of 173, an absent code no series). It fails on
development c4655072a for all three defects.
2026-09-30 21:57:25 +00:00
Doxa Maps
47a856c144 Merge origin/development (939db5b6c: P0 landed, posterverse, csv-guard) into upg100/india-states; package.json keeps both sides' gates; rebuild my-upg-100-list from the merged source, and regenerate the tree and SRI 2026-09-30 21:49:47 +00:00
Doxa Maps
f8de00edf7 Merge remote-tracking branch 'origin/development' into wall-of-people-groups-plugin/work
# Conflicts:
#	package.json
2026-09-30 21:47:28 +00:00
Doxa Maps
939db5b6c7 Merge origin/development (e9805a5c8: csv-guard, posterverse) into upg100/batch-select; package.json keeps both sides' gates; rebuild my-upg-100-list from the merged source, and regenerate the tree and SRI 2026-09-30 21:42:50 +00:00
Doxa Maps
39ded339fa Merge upg100/batch-select (P0 batch selection, 2bf2607fc) into upg100/india-states: P0b's India detection builds on P0's selectionPeople. package.json keeps both new gates 2026-09-30 21:33:24 +00:00
Doxa Maps
5a6b226a19 Posterverse: a capability-5 app shell for every poster plugin
<posterverse-app> is a vertical-tab rail of whole poster plugins, each mounted
as its real element from the manifest: UPG 100 List Posters (upg-100-mfe),
Country Posters (country-posters-mfe), Poster Design, and an Experimental
folder of Wall of People Groups, All People Groups, UPG Neighbor and Bible
Posters. It reuses VerticalTabsLayout, loadScriptOnce/whenTagDefined and the
Research Gallery's profile-config contract (urlSync:false, mapTitle, token
ladder, treeBase, assetsBase).

The whole rail is an allow-list (src/tabs.js), so a gated plugin such as
key-cities can never appear. Rows whose bundle is not in the tree are pruned;
Poster Design and Bible Posters carry provisional ids until their bundles land.
meta.json declares nested-mfe-spa and composes the five rows that exist, so
the Plugin Studio lists it under capability 5.

check:posterverse-shelf (in gates:pre) asserts the allow-list, the key-cities
exclusion, composite-not-leaf resolution, composes/rail agreement and
11-locale string parity; --self-test plants one defect per arm.
2026-09-30 21:32:51 +00:00
Doxa Maps
db1e6c978f Wall of People Groups: the wall pans and zooms with the pointer, 1:1
d3-zoom on a canvas reports its transform in the canvas's own CSS px, but the renderer applied it inside
the "meet" fit (a leftover from the SVG, whose viewBox made it world units). Every pan therefore moved
the wall by only the fit scale: a 200 px mouse drag panned 34 px at 1440, a 120 x 200 px finger drag
14 x 23 px on a phone, and a wheel zoom drifted about 90 px off the pointer. The transform now applies
after the fit, in Bless and Social mode alike.

Also: a trackpad pinch (ctrl-wheel) zooms the wall instead of falling through to a browser page zoom,
with a capped step; and a fling can no longer leave an empty table (the wall always covers the centre
of the view). check:wall-pan (gates:pre) runs the real WallCanvas methods with d3's transform
arithmetic and catches the old formulas.
2026-09-30 21:29:56 +00:00
Doxa Maps
d9b237ac19 PosterWall seams: gate the second-base retry, observe the wall's scroller
check:poster-wall-host-seams gains arm H: a default wall's broken stored
webp retries the tree's own copy before the placeholder. Arm I: a generated
preview that fails shows the placeholder at once. The gate serves embedBase.js
as if from a built tree, because the second base is derived from that URL and
is empty from source, which let a mutant that skipped the retry pass.

previewSrc's observer now uses the wall's own scroller as its root (the
viewport when the wall is embedded and the page scrolls it). A pane wall
scrolls inside .wall, which clips its cards, so with the viewport as root the
200px margin never looked ahead. Arm E asserts the root.

PosterWall states the two limits of previewSrc: a timeout ends the wait, not
the render, and the page cache never revokes the URLs it holds.
2026-09-30 21:29:17 +00:00
Doxa Maps
f88a7bbe69 India's states and union territories: register check:india-states in gates:pre (mutation-tested: a dropped slug, a slug in two states, a stale input, a tampered served copy each FAIL), and the table's README (what reads it, its shape, how it is regenerated) 2026-09-30 21:29:11 +00:00
Doxa Maps
7cc28b7aee PosterWall: a previewSrc seam so a host can supply each card's thumbnail
A card's thumbnail was always the stored previews/<id>.webp. A gallery of
poster templates drawn from the library has no stored file, and must show a
library change the moment it lands, so the thumbnail can now come from the
host: previewSrc: async (item) => an image URL | null.

Default null keeps the stored webp and creates no observer, so every current
wall is unchanged. When set, a card renders only once it is in view, one
render at a time, cached on the page by instanceId + item id. Until it answers
the card shows its label; null, a throw or no answer within 20 s shows the
existing "preview unavailable" placeholder.

The header-action gate becomes check:poster-wall-host-seams and covers both
seams: arms D to G for previewSrc (default webp and no observer; lazy, one at
a time and the null fallback; the timeout; the cache). Six mutants of the new
code each fail it.
2026-09-30 21:21:27 +00:00
Doxa Maps
b3d6db3c59 PosterWall headerAction: a countless item's sticker reads the label alone
A template card (Poster Design) carries no people-group count, and the new
branch printed a bare ' UPG ·' before its label. The count and separator now
render only when item.count is set, in the headerAction branch only; the
default toggle is unchanged. check:poster-wall-header-action gains arm C for
it, which fails with the guard removed.
2026-09-30 21:11:33 +00:00
Doxa Maps
58242987c8 PosterWall: a headerAction seam for the card's header sticker
The sticker on every poster card was hard-wired to "Show on map". A wall with
no map beside it (a gallery of poster templates) needs the same card with a
different action, and a fork of the card would drift from the wall.

headerAction null (the default) renders the existing toggle unchanged: the
same markup, aria-pressed, the is-last highlight and the window event, so
every current wall is untouched. { labelKey, ariaKey } renders a plain button
labelled from those keys that emits 'header-action' with the item and
dispatches no window event.

check:poster-wall-header-action (gates:pre) mounts the real PosterWall
headlessly on a recording renderer and calls the sticker's onClick in both
modes. Four mutants fail it: no seam, a new branch that also dispatches, a
default that emits, and aria-pressed on the new branch.
2026-09-30 21:07:37 +00:00
Doxa Maps
dd913f7269 UPG-100 batch selection: recolour, Copy and Export CSV are back. The colour swatches return; Copy puts the territory line apply-grouping parses first, then the selection's people groups as CSV; Export CSV downloads the same CSV. One detection feeds every action, prefetched when batch mode opens so a tap copies from cache. New gate check:batch-selection-export (gates:pre) 2026-09-30 20:53:19 +00:00
Doxa Maps
f3fdf4bf37 Mobile apps from plugins: a how-to, with Deep Research as the worked example
documentation/how-to-build-mobile-apps-from-plugins/README.md explains why Deep Research
(doxa-maps-spa, its app preset) is the plugin to wrap first: the seven production maps in one
phone-ready shell, token-free on MapLibre. It then walks the whole path: tools, the wiring check,
one command, what each step does, another plugin as the app, making it your own, and fixes.

The one command needed two small changes; the default app is unchanged:
- internal/set-app-start-page.mjs takes APP_START=<plugin>/<screen>[/<preset>] and opens that page
  instead of the UPG-100 list. Unset, it behaves and marks the page exactly as before. A malformed
  value, a page missing from the build, or links that would not resolve are refused, as before.
- package.json: phone:from-tree is sync, start page, verify, assemble, install from the committed
  tree, with no full rebuild (bun run build refuses without BUILD_ALLOW=1).
  phone:deep-research is phone:from-tree with Deep Research as the start page.

how-to.md links the guide, and two of its lines are corrected: webDir is public/dx-maps/, and
apk:debug's first step is the full build, which asks for BUILD_ALLOW=1.

Checked, with no Android tools: the start page written for doxa-maps-spa/doxa-maps-spa/app, then
the app root served and loaded in the shared browser at 390 and 1280. It showed the gallery
element, 15 tabs (the seven maps and the experimental ones) and a drawn map, with 0 console errors
and 0 failed requests. --check passed for APP_START and failed for the default until it was
restored; the default run still writes the UPG-100 page with its original marker. The Gradle and
adb steps are those bun run phone already uses; they were not run here.
2026-09-30 10:04:34 +00:00
Doxa Maps
fa69209035 The gallery's published section order: Prayer Progress before the UPG-100 List
The Deep Research page publishes the rail as Dashboards (Big Picture, Simple Research, Regions),
Prayer Mobilization (Prayer Progress, UPG-100 List), Gospel Research (Cities with Internet, Gospel
Resources), then the Experimental folder. Only the Prayer Mobilization pair moves. Big Picture now
says "desktop only" on every width, not only once a phone has disabled it.

check:spa-tab-order asserted the old four-name order with UPG-100 first; it now asserts all seven
published rows in order (still relative, with tab 1 positional). It needs a browser: rega runs it.
2026-09-29 20:00:36 +00:00
Doxa Maps
2b210b78f0 Merge remote-tracking branch 'origin/development' into regions/country-map-refetch
# Conflicts:
#	package.json
2026-09-29 19:36:16 +00:00
Doxa Maps
656503a28d Merge remote-tracking branch 'origin/development' into regions/pane-config-land
# Conflicts:
#	package.json
2026-09-29 19:12:46 +00:00
Doxa Maps
e643e8a30e country-map: key the refetch by value, let only the newest response win, show the wait
Three defects on country-map/country-map, which is production -- DX Life's
country pages. Found by auditing the element rather than from a report.

1. ANY profile-config change refetched all 2,106 records. The watch getter
   returned a NEW ARRAY literal, and Vue compares a watch value with Object.is,
   so a fresh array always reads as changed; both computeds read the injected
   cfg, so the effect re-ran on every config change and the callback then fired
   unconditionally. MEASURED with real Vue: flipping isDark refetched the whole
   endpoint and redrew the map, and so did a host adding pinHint. A joined
   STRING compares by value, so only a real language or country change refetches
   -- which must still refetch, because the API localises the names.
   This was latent waste until the composite re-forward made config writes
   routine. It is now on every upstream locale switch, so the two changes are
   complementary rather than independent.

2. A SLOW EARLIER RESPONSE OVERWROTE A NEWER ONE. loadPoints is async and had no
   sequencing at all -- 0 abort or generation tokens in the file -- so two runs
   could be in flight and the one that STARTED first could FINISH last. Both
   assigned points, so the map showed the previous country's pins with no error
   and nothing for the reader to notice. Every run now takes a ticket and a stale
   ticket writes nothing: not the points, not loadFailed, not the spinner the
   current run is still using. fetch is left to complete rather than aborted,
   because the response is cached for the next switch back.

3. `loading` WAS WRITE-ONLY: assigned in three places and read in none, zero
   template references. A reader on a country page waited on an empty map -- no
   outline, no pins, no message -- which is indistinguishable from a country that
   genuinely has no people groups, and this element fetches the whole list before
   it can draw one pin. "Loading results..." was already in the catalogue in all
   eleven locales, so saying so cost no new string and no parity debt.

check:country-map-refetch guards all three. It drives the REAL reactive chain
with real Vue and takes the watch key expression OUT OF THE SHIPPED SFC, because
bug 1 is entirely a question of what Vue considers changed and is invisible to
any test that fakes reactivity or re-types the watch.

Mutation-tested, 5 mutations, all caught by name. The first version of the gate
MISSED one: a bare /if \(!current\(\)\) return/ grep matched the guard in the
catch block, so removing only the guard before the points write still passed.
The assertions are positional now -- the guard must sit immediately before the
assignment it protects -- and the catch guard is asserted separately. That hole
existed because the behavioural half of the test reproduced the guard's shape
inside the gate instead of reading the component, which is the self-consistent
oracle this repo's other geometry gate explicitly warns against.

Registered in scripts and gates:pre, verified by re-parsing: one key, 107
checks. Bundle rebuilt (BUNDLE=country-map), then tree, then fix:sri.
2026-09-29 19:11:37 +00:00
Doxa Maps
e7962e4ef7 Merge origin/development into studio/hygiene 2026-09-29 18:47:01 +00:00
Doxa Maps
2a4c3ddb3d The full build also copies the maps into the sibling DX Life site, when it is there
`bun run build` now ends with `sync-dropin --from-config --if-present`. It copies public/dx-maps into the
ONE configured host path (vite.doxa-maps-build-config.json dropin.dest, default ../doxa-life/public, or
DOXA_DROPIN_DEST). No site at that path: it prints "skipped" and exits 0. Nothing else on the filesystem
is searched for repos or sites. DOXA_DROPIN=off skips it even when the site is there. Without
--if-present, a missing host is still an error, as before.
Tested: no host skips (exit 0); a host gets 1,209 files; DOXA_DROPIN=off copies nothing; the plain
command still refuses a missing host. The container's /cdn build (build-cdn.sh) does not run this chain.

On this box ../doxa-life does not exist beside the doxa-maps checkouts (DX Life lives in root-dx-life), so the step is a no-op for the fleet's builds.
2026-09-29 18:39:36 +00:00
Doxa Maps
cc65f40c1e Gate hygiene: three gates:post reds on development, and two things that hid failures
- check:no-orphan-gates stopped at internal/check-grid-merge-defaults.mjs, which had no script. It
  asserts defaults of three grid props that are not on development (the grid merge has not landed),
  so it is registered ON DEMAND with that reason, not chained red. Past that first arm, its chain arm
  found check:grid-image-loading and check:retry-fetch run by no chain; both depend only on code and
  pass in under 300 ms, so they join gates:pre.
- A tracked vite.config.js.timestamp-*.mjs (395 KB, eight absolute paths) failed check:no-abs-paths.
  Removed, and ignored at the source: vite writes one beside its config while it bundles it.
- check:library-index: documentation/reference-library-index.md regenerated for retryFetch.js.
- run-parallel printed a failed step's output only with --verbose: the unbraced else bound to the
  inner if. Braced; a failing step now prints why.
- The Pages workflow ran bun run build without BUILD_ALLOW=1, so build-lock refused it with exit 75
  and the workflow ran no gate at all. A CI runner is the only builder on its machine; it opts in.
2026-09-29 18:35:37 +00:00
Doxa Maps
9ccec24d29 Generalise the pane re-forward to attribute-configured panes, and gate it
sr asked to adopt usePaneConfig for research-map-plus-grid rather than write a
second fix, and found the one shape it did not cover: people-groups-grid takes
its filters as THREE separate attributes (wagf-region, religion-family,
primary-language) instead of one profile-config blob, so that composite has one
pane of each kind. The helper hardcoded 'profile-config' and gave the grid
nothing.

registerAttrs is the sibling for that shape. It shares the same entries list,
the same watchEffect, the same prune and the same write-only-on-change -- one
mechanism for all four composites rather than a fork per adopter. A helper with
a single caller has not been shown to generalise at all; this is the second
shape that showed it.

Two decisions worth recording. Each attribute is compared and written
independently, so a getter whose region changed does not rewrite the two that
did not and re-trigger the child. And the attribute NAME LIST is pinned at
registration: a later getter that omits a key CLEARS that attribute rather than
leaving a stale filter silently narrowing the grid, which is the same class of
lie as the frozen config this helper exists to fix.

The gate runs the shipped helper with real Vue, not a re-typed copy, because the
dependency-tracking hole found in review is invisible to any test that fakes
reactivity. 28 cases over both shapes. Its subject is the measurement on
development 92ca8b6e6: the outer element reached locale ar by +1.5s while every
nested host carried NO locale key at boot and still none at +15s, across four
composites -- so case 1b asserts that an ABSENT key is still a change.

Case 7 is the adopter's property rather than mine: a re-forward triggered by a
LOCALE change must produce the SAME primary-language string. gridLanguage() can
fall back to a display label, and the grid compares whole normalised values, so
a name fetched in one language matches no row in another. It does not bite today
because the surviving fallback is a value the grid itself produced, but the gate
asserts the class, not today's instance.

Mutation-tested, 7 mutations, all caught: plain array, no write-on-change guard,
skip-instead-of-prune, escaping getter throw, no per-attribute compare, omitted
key left stale, and the label-instead-of-code regression. The first two crashed
rather than naming a case, so cfgOf is null-safe now -- a gate that dies reports
"something went wrong" instead of naming the pane that stopped following.

Registered in package.json scripts and in gates:pre, verified by re-parsing the
file: one key, 104 checks. gates:pre PASS 103 FAIL 0 NM 1.
2026-09-29 18:34:49 +00:00
Doxa Maps
99816626af Every shadow stylesheet gets its own label, and a gate keeps it that way
useShadowStyles keys its <style> by the label and overwrites an existing
tag with the same key, so two components sharing a label keep only the
last one's CSS in a shared shadow root. The regions list lost its sheet
this way to the region card, which had kept the list's label when it was
extracted.

check:shadow-style-labels-unique (gates:pre) fails any label two calls
share, globally rather than per shadow root, so a copied or forked
component is caught when it is copied. It reads the real parsers,
classifies every reference to the function (a rebinding, .call, a
re-export or a namespace import fails instead of hiding a call),
cross-checks the AST against the token stream, and covers the other
ways two calls share a key: unlabelled calls under the dev server's
per-component key, and a fixed label over CSS that varies per caller.

countries-map.vue, a fork of research-map.vue, carried the same label;
it is now 'countries-map', with the country-posters bundle rebuilt and
the tree regenerated. The composable's JSDoc called the label a
debugging name; it now says it is the key.
2026-09-29 17:42:44 +00:00
Doxa Maps
de6d60378e Merge origin/development into studio/integration 2026-09-29 16:38:05 +00:00
Doxa Maps
2ff7d2f17b Merge origin/development into studio/integration 2026-09-29 16:01:38 +00:00
Doxa Maps
0417e46fbd Merge remote-tracking branch 'origin/development' into bundler-2/handoff
# Conflicts:
#	internal/check-poster-layout.mjs
#	library/composables/poster/useMapPoster.js
#	package.json
2026-09-29 16:00:18 +00:00
Doxa Maps
212ec20816 Merge remote-tracking branch 'origin/development' into bundler-2/handoff
# Conflicts:
#	package.json
2026-09-29 15:54:16 +00:00
Doxa Maps
14096e19c2 Merge remote-tracking branch 'origin/development' into upg100/merge
# Conflicts:
#	package.json
2026-09-29 15:54:05 +00:00
Doxa Maps
8eccda7752 Merge origin/development into studio/integration 2026-09-29 15:37:45 +00:00
Doxa Maps
9d00aa3db4 check:snippets-draw: every published snippet, pasted verbatim on another origin, must draw (awaiting its first run)
Ported from rega's snipgate and mbxcheck probes (card GATE-EVERY-PUBLISHED-SNIPPET-DRAWS-A-MAP). Two scripts so the keyless arm never waits on a token: check:snippets-draw pastes each production embed.script and requires a MapLibre map to draw (a hooked instance with its style loaded via a setter trap on the engine global, a sized canvas, tiles 2xx, no failure note, 0 page errors); check:snippets-draw:mapbox does the same over embed.scriptMapbox with only the token filled from MAP_TOKEN, NOT MEASURED without one and never printing it. Composites that open on a list (regions-mfe, upg-100-mfe) are pressed to their map view under the settle rule first, since a hidden pane still draws a canvas. The browser slot is checked before any server starts, and the tree server is spawned --exit-with-parent. Registered and declared on demand until rega's first run; it joins verify-browser-all after.
2026-09-29 14:56:52 +00:00
Doxa Maps
65729d608d Register and chain check:list-pattern-doc
The list-pattern gate (internal/check-list-pattern-doc.mjs) came in with
its doc but was never registered, which check:no-orphan-gates refuses.
Registered, and chained into gates:pre: it is headless and re-derives the
7 numeric claims in PATTERN-A-LIST-OF-ZONES.md from the data files.
2026-09-29 14:54:47 +00:00
Doxa Maps
023afcb55e Restore the gate chains after merging eleven branches
Merging the branch backlog cut gates:pre to 49 entries of 102 and gates:post to
86 of 108: package.json holds each gate on its own line, but the chains are
single long strings, so a line-wise union takes one side wholesale while every
definition survives. A run then passes and means almost nothing.

Both chains come from development, with the one gate these merges add appended
rather than replacing them. Every gate named in a chain is defined: checked,
not assumed.
2026-09-29 14:51:12 +00:00
Doxa Maps
bdb74ff69f Merge remote-tracking branch 'origin/development' into bundler-2/handoff 2026-09-29 14:50:51 +00:00
Doxa Maps
4d65d399b1 Merge origin/development into the handoff integration 2026-09-29 14:50:47 +00:00
Doxa Maps
6156895e54 Merge branch 'bundler-2/driver-1650' into bundler-2/handoff
# Conflicts:
#	internal/check-embed-url-absolute.mjs
#	internal/check-legend-top-offset.mjs
#	internal/check-map-menu-anchor.mjs
#	internal/check-mercator-slice.mjs
#	internal/check-one-scroll-container.mjs
#	library/components/map-controls/MapControlMenu.vue
#	library/composables/poster/useMapPoster.js
#	library/composables/usePosterPrints.js
#	library/maps/mercator-slice.js
#	library/utils/embedBase.js
#	package.json
#	plugin-designs/doxa-research-map/research-map.vue
2026-09-29 14:50:44 +00:00
Doxa Maps
336a1e0a18 Merge branch 'bundler-2/script-registry' into bundler-2/handoff
# Conflicts:
#	internal/check-legend-data-contract.mjs
#	internal/check-map-control-overlap.mjs
#	library/composables/poster/useMapPoster.js
#	library/composables/useMapInstance.js
#	package.json
#	plugin-designs/doxa-research-map/research-map.vue
#	plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue
2026-09-29 14:50:42 +00:00
Doxa Maps
85252c8d47 Merge branch 'bundler-2/he-sr' into bundler-2/handoff
# Conflicts:
#	internal/check-hardcoded-english.mjs
#	internal/check-label-key-coverage.mjs
#	internal/check-packet-variants.mjs
#	library/ProfileLoader.vue
#	library/i18n/locales/ar/search.json
#	library/i18n/locales/de/search.json
#	library/i18n/locales/en/search.json
#	library/i18n/locales/es/search.json
#	library/i18n/locales/fr/search.json
#	library/i18n/locales/hi/search.json
#	library/i18n/locales/it/search.json
#	library/i18n/locales/pt/search.json
#	library/i18n/locales/ro/search.json
#	library/i18n/locales/ru/search.json
#	library/i18n/locales/zh/search.json
#	package.json
#	plugin-designs/doxa-research-map/research-map.vue
#	plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue
2026-09-29 14:50:40 +00:00
Doxa Maps
97ffa4e8df Restore the full gate chains after the merge took an older package.json
The union resolution kept every script DEFINITION but took the older branch's
gates:pre and gates:post chains, which are single long strings rather than one
line per gate -- so a line-wise union picked one side wholesale. That silently
dropped 95 gates from the pre chain and 30 from post: every definition present,
almost nothing actually run.

Both chains are restored from development, with any gate this merge adds
appended rather than replacing them. Caught by counting the gates in the chain
against development, not by the run, which passed 7 of 7 and looked green.
2026-09-29 14:43:13 +00:00
Doxa Maps
bb66d4a03b Merge branch 'bundler-2/p0-setup-order' into bundler-2/handoff
# Conflicts:
#	internal/check-setup-order.mjs
#	package.json
#	plugin-designs/doxa-research-map/research-map.vue
2026-09-29 14:40:37 +00:00
Doxa Maps
8fcd6ad834 Merge branch 'bundler-2/poster-layout-optin' into bundler-2/handoff
# Conflicts:
#	package.json
2026-09-29 14:40:36 +00:00
doxa-maps
fcebf2c589 A9 lever 1: engine-aware resource hints on map leaves
A cold map ran serially: leaf HTML, then the bundle (250-470 KiB gz, parsed),
then only the engine (from cdn.jsdelivr.net or api.mapbox.com), then the style
host, tiles, and data. Four origins discovered one after another, none
pre-connected. That waterfall is the shape behind A9's 3-5 s cold map.

generate-tree now emits, in each MAP leaf's <head>, a preconnect for the
engine origin, the style host and the data origin, and -- only on the 'auto'
leaf, which carries no engine <script> today -- a preload of the engine, so
DNS/TLS and the engine download start alongside the bundle instead of after
it. A non-map leaf (grid/card/chart-table/app-shell/composite) gets nothing.

The engine is decided at build time from the SAME inputs the runtime ladder
reads (profile-config.provider, then a baked token or a token endpoint; 'auto'
with no token path is MapLibre keyless), so the hint and the ladder cannot
disagree. Every URL, integrity and crossorigin comes from
library/constants/cdn.js, so a preload cannot drift from ensureEngineScript's
tag. The one case a static hint cannot foresee -- an explicit ?provider= or
window.MAP_PROVIDER overriding the build default at runtime -- costs one unused
preconnect.

New gate check:leaf-hints (gates:post): a map leaf without hints fails, a
non-map leaf carrying a map-engine hint fails, and a hint origin must be one
the code uses. PASS: 36 map leaves carry hints, 28 non-map leaves carry none.
Poison-tested both sides.

Unlanded (dx-base/a9-leaf-hints); the bundled-vs-shared-vs-today trade-off
report and any landing wait on rega's timing and regions' tiles.
2026-09-29 14:40:27 +00:00
Doxa Maps
4b1bfdb23c Merge branch 'bundler-2/poster-attribution' into bundler-2/handoff
# Conflicts:
#	library/composables/poster/useMapPoster.js
#	package.json
2026-09-29 14:40:26 +00:00
Doxa Maps
4571596cf1 Merge branch 'bundler-2/grid-lazy-images' into bundler-2/handoff 2026-09-29 14:39:13 +00:00
Doxa Maps
1b19b7478a Register the control-inset gate, now that it has been run and proven
verify-browser-all's rule is that every gate listed there was run before it was
listed, so this waited for a real run rather than being registered on the
strength of the code reading correctly.

Measured, on the browser seat: the positive arm PASSES 9/9, and the mutation arm
-- the maplibre selector inside bottomControlInset broken to a name that matches
nothing -- FAILS on cases 1 and 5, the two whose pages carry only a maplibre
attribution. Cases 2, 3, 6 and 7 pass under the mutation by design: they assert
the inset is 0, so a selector that stops matching cannot make them fail. They
guard the opposite error, reserving space that is not there.

Registered in all three places: the script, its //scripts description, and
verify-browser-all's list. Deliberately NOT in gates:pre -- every assertion rests
on getBoundingClientRect, so it needs a browser and exits 75 NOT MEASURED
without a slot, which is never a pass.
2026-09-29 14:35:29 +00:00
Doxa Maps
12078103d1 check:no-orphan-gates sees gate FILES outside internal/: a plugin's own gate that nothing runs fails
The file walk read internal/ only, and the chain arm counts package.json NAMES, so a plugin-local
check-*.mjs that nobody registered was invisible to both: on 2026-09-29 six were run by nothing
(dx-shepherd's census; exp's four have since been registered). Every tracked gate-shaped file under
plugin-designs/ or library/ must now be run by PATH from a script or another file's code, carry
@one-shot, or sit in the shrink-only `files` baseline for its owner.

Two rules keep it honest. Comments are stripped before searching, since a file mentioned in prose is
not run by it. And a basename counts only where no other gate file shares it:
library/scripts/research/verify-map-paint.mjs (199 lines, run by nothing) shares its name with
internal/verify-map-paint.mjs (342 lines, what verify:map-paint runs), so a name match let one file's
registration vouch for the other. Self-tested on planted cases (unregistered, comment-only, shared
basename). On the real tree it finds 4, now baselined for their owners:
  library/scripts/research/verify-map-paint.mjs            likely a stale copy
  plugin-designs/doxa-research-map/scripts/check-reduced-motion-jumps.mjs
  plugin-designs/gospel-resources/scripts/verify-translator-org-links.mjs   a data tool? @one-shot or register
  plugin-designs/research-map-plus-grid/scripts/check-ui-strings-have-keys.mjs
The chain arm's writer now keeps every key of the shared baseline file.
2026-09-29 14:35:28 +00:00
Doxa Maps
5a73e4d37d Guard the pan's control clearance, which a cut-height check cannot see
The popup auto-pan had no gate. Its founding bug was invisible to the obvious
one: measured at 390 on a 366x732 map, the pan fitted the popup inside the
container, the cut really was zero, and a real click on Full Profile still did
nothing, because the top element at the button's centre was
div.maplibregl-ctrl-attrib-inner on one pin and span.pin-hint-pill on the other.
Two pins that looked fixed and were not.

So this asserts the property that matters -- where the popup ENDS UP relative to
the band that takes the click -- rather than whether it was clipped.

It runs the SHIPPED panPlanFor, read out of the SFC with new Function because it
is not exported, the same way check:optional-t-equivalence reaches useOptionalT.
A re-typed copy of the arithmetic would pass forever while the component drifted,
which is the one failure a geometry gate must not have. POPUP_OFFSET is read from
the source too rather than assumed.

Each case states the resulting geometry independently instead of re-deriving the
expected number from the same formula: a self-consistent oracle cannot see an
error that shifts both sides, which is how a 12px offset hid here once already.

The band is measured at runtime, never hard-coded, so the rule is swept across
0-120px rather than pinned to one number -- 120 is far beyond anything observed
and is there to prove the rule, not the value. Also swept across every width the
component renders at, since below 768px the container is 2x width.

Mutation-tested three ways, each failing the case written for it: ignoring
bottomInset (the pre-fix behaviour) fails 15 cases; reserving only half the band
fails; dropping the Math.max clamp fails exactly the negative-inset case.
24 cases, registered in all three places, verified by re-reading the parsed file.
2026-09-29 13:31:24 +00:00
Doxa Maps
dd0428823c exp's four headless guards are registered, described and chained in gates:pre
check:wall-wysiwyg (0.9 s), check:wall-print-watch (0.7 s), check:wall-no-bg-photos (13 s) and check:template-i18n (9.7 s), each run once on this round's tip before chaining (all PASS) and each two-sided on its known-bad (dx-base poisoned all four independently). Placement read, not assumed: the two Vite runs use middlewareMode and bind no port, and wall-no-bg-photos serves a committed fixture with fetch stubbed, so no answer depends on the network, the machine or the moment. The orphan arm now counts 219 of 330 reached.
2026-09-29 13:31:14 +00:00
Doxa Maps
6a1860539c Chain the region-paint gate now that both maps take the library's expression
The gate asserts the regions idle paint has one source. It could not be chained
while only one of the two maps had adopted the library export, because it was
legitimately red on the other plugin owner's unlanded work, and chaining a red
gate holds the shared run for everyone.

Both halves are now in: this map took the export, and the other adopted it too,
so the gate reports all ten assertions green and can run with the rest.

This replaces the on-demand declaration rather than following it. Declaring a
gate as run-by-hand is a decision that costs a reader something to understand,
and it is only worth making while it is true -- the second half landed sooner
than the declaration assumed, so the honest end state is a chained gate and no
entry at all, not an entry added in one commit and deleted in the next.

Verified on the combined tree: the gate passes, the orphan guard passes with it
counted among the chained scripts rather than the declared ones, and poisoning
the gate still fails it.
2026-09-29 13:29:18 +00:00
Doxa Maps
4ae1bbf4f5 Gate: the regions idle paint must have one source, not a copy per map
Two production maps paint the doxa-regions layers directly -- 35
setPaintProperty('regions-fill'|'regions-border') calls between them, 10 in one
file and 25 in the other. Twenty-one pass their value through a one-line helper
that wraps it in the same ['case', ['has','iso_3166_1_alpha_3'], v, v]
expression, and each map defined its OWN copy of that helper while already
passing it into the library composable that paints with it. The library owned
the paint logic and not the expression, so the two copies could be edited apart
and the same "idle" would render differently on the two maps, with nothing
failing. No existing check covers this: the nearest one reads a different
component entirely.

The gate asserts the idle paint is single-sourced: the library exports the
helper, neither map keeps a local definition, and neither inlines the
expression. It deliberately does NOT require all 35 writes to route through the
library. The raw multi-line writes are highlight states and outline resets that
differ by design and by map; demanding uniformity there would make this red on
the day it was written, which is how a gate earns being ignored. It also does
not assert how OFTEN each map repaints -- one repaints the idle state more
than the other and has a dynamic call the other lacks. That is a real
difference in when, and flattening it is how a map goes blank. This checks that
the maps agree on what idle looks like, never on how often they say it.

Two-sided. Poisoning it un-exports the library helper and re-inlines a local
copy in a consumer; three assertions flip from ok to FAIL, each catching its
own planted regression, and the run exits 1.

Registered but NOT chained yet: it is legitimately red until the second map
adopts the library helper, and chaining a red gate would break the shared run
for everyone else. It joins the chain with that change.

Building it found a third copy nobody had counted, under a different name, and
two bugs in the gate's own first draft: a block-comment stripper that ate the
declarations it was meant to find (so every single-source assertion passed
while both files plainly defined the helper), and an exemption for lines
mentioning the helper, which hid a one-line definition that mentions it. Both
were caught by the rule that a gate must fail when poisoned.
2026-09-29 13:29:18 +00:00
Doxa Maps
6a0aade5c1 The browser/Node sheet equivalence check can measure on a fresh checkout
check:poster-browser-equiv is chained in gates:pre and exited 75 NOT MEASURED
every run. Its input, out/_records.json, is gitignored and written only by
generate.mjs against a live API, so on CI and on any box without credentials the
gate never looked at anything. The runner reported that honestly rather than as
a pass, but an identical NOT MEASURED every run is indistinguishable from an
unwired gate -- and this is the gate that proves a sheet generated in the browser
is byte-identical to the one Node writes.

An equivalence check needs the SAME input on both sides, not fresh input, so a
committed fixture loses nothing it was measuring. The fixture is the 124 public
records the two arms actually consume: Nepal's 34 for the country arm, and the
90 slugs the Gujarat zone selects for the zone arm. Same fields the served maps
show; no other data.

Verified equivalent to the full 2,106-record cache: identical zone pins in the
same order, identical Nepal set, count 90, pop 3,164,770, series {2209, 28, 1}.

Record ORDER is load-bearing -- it drives draw and label order -- so the fixture
keeps the cache's order. A first cut sorted it: every aggregate matched and the
pin SET matched, while the order differed. Sorting a fixture would have quietly
changed the sheet it produces.

generate.mjs gains RECORDS_FILE so a caller can name the offline input instead of
only the API-written cache. It still fails closed without an API when no offline
file is given. The gate passes it the very file the browser arm read, so both
sides are fed the same records by construction.

The gate now says which input it measured, because the full cache widens coverage
to all 175 countries where it exists and the fixture is the floor everywhere else.

Two-sided, on this tree: with the fixture it PASSES byte-identical on both arms
(834,174 and 921,522 bytes). Dropping the province boundaries at
posterSheets.js:206 -- the defect this gate's header records, where a country
beside India or China printed without them -- makes the country arm 251,576
against Node's 921,522 and the gate exits 1. Restored, it passes again.
2026-09-29 12:44:25 +00:00
Doxa Maps
7ae80e3810 The coverage gate's poison self-test runs beside its chained arm
check:coverage-consistent:chained sets the link-age threshold out of reach. The proof that this narrows its scope and does not disarm it is the poison arm: its clock is computed RELATIVE to the threshold, so all 15 clauses (A-O, L included) still fail when poisoned at any setting (regions measured it at 99999). That proof was an orphan; it now runs on every landing, like the other gates' self-tests. 1.6 s.
2026-09-29 11:56:47 +00:00
Doxa Maps
b142fe1178 Two more gates join gates:pre honestly: dxlife walks only the clones, and the calendar stays on demand
check:dxlife walked the whole root-dx-life umbrella, so git worktrees of doxa-life (wt-rg-*) and
a dangling one (research-map-clean, whose .git points at a path that no longer exists) read as
+4,524 "added" files and held it red while the clones were untouched. It now skips any directory
whose .git is a FILE beginning `gitdir:` (keyed on that file, not on `git worktree list`, which
cannot see the dangling one) and .cache with the other generated dirs, and compares the baseline
only within that scope. Result against the 2026-09-20 baseline: 2,844 clone files, +0 -0 ~0. So the
law has held since that date, and the baseline stays as it is: re-taking it would reset the evidence.
Nothing is deleted; research-map-clean may hold the only copy of its work. With no clone on the
machine it reports NOT MEASURED (75) instead of exit 2, so chaining it cannot fail other machines.

check:coverage-consistent fails when the oldest verified link is over 45 days old: its verdict
depends on the calendar, and chained it would turn gates:pre red on 2026-11-08 with no change
(regions measured the margin). Every other clause depends on code and data and is worth running
on every landing, so gates:pre runs check:coverage-consistent:chained (the age knob set out of
reach). The plain script is declared on demand as the freshness run, with that reason. Its poison
self-test still proves every clause can fail.

gates:pre 94/0/2 of 96. The orphan baseline shrinks to 105; 7 checks are declared on demand.
2026-09-29 11:56:47 +00:00
Doxa Maps
9226aa7cff Twenty orphan checks join the chains; five more are declared on demand, each with its reason
From regions' triage of the headless orphans, every candidate re-run on this round's tip before
it was chained (all exit 0; the slowest, no-conflict-markers, 8.2 s):
  gates:pre   click-sheet coverage-consistent dashboard-filter-a11y filter-multivalue hover-channel
              kit-options-reachable layout-key-namespaced legend-mirrors map-unmount-race
              no-conflict-markers series-isolation snapshot-guard stale-sweep-claim
              surfaces-read-the-tier translator-contacts   (source readers)
  gates:post  embed-base link-coverage no-public-binds shared-reached tree-weight   (built tree)
stale-sweep-claim reports SKIP while 1 of 1184 gap codes is undecided: it measures once the pool is
fully read, and the wave shows it as not measured until then, never as a pass.

Declared on demand instead: catalog-currency and published (they read the published host),
composite (minutes of rebuilds; landing's fresh step does this per landing), hardcoded-english
(report-only while its findings are triaged; its self-test is chained), and shot-fonts (it checks
the SCREENSHOT MACHINE's fonts, so chained it would fail a contributor's gates for their fonts).
Two proposals were declined: check:settled stays on demand (it fails inside any build by design,
and passing standalone is how it was wrongly chained before), and shot-fonts as above.

The orphan baseline shrinks 131 -> 106. gates:pre 93/0/2 of 95, gates:post 91/0/16 of 107.
2026-09-29 11:56:47 +00:00
Doxa Maps
8a79e0f58a check:viewport-meta and its self-test are described, in regions' words
The two scripts arrived without //scripts entries. The text is regions', written to match the neighbouring entries; its '8 self-tests' was re-read against the file this round carries (sha256 a886af2dca66e012, 124 lines).
2026-09-29 11:56:47 +00:00
Doxa Maps
afc3e9edda Every check is run by a chain, declared on demand with a reason, or listed for its owner
check:no-orphan-gates only REPORTED scripts outside the chains, and the list grew to 87 main checks.
That day two of them hid real defects: check:i18n-never-translated, while a dead key identical in all
11 catalogues sat on development for hours, and check:auto-engine, which asserted a retired engine
rule that nothing ran. The report also matched by substring, so check:hardcoded-english counted as
run because its :self-test is in gates:pre.

The arm now decides. Every check:/verify:/smoke: script must be:
  reached    by exact name from gates:pre, gates:post, the browser suite's GATES or the build,
             following what those scripts `bun run`;
  on demand  in internal/fixtures/on-demand-checks.json WITH its reason (check:settled, whose
             reason this gate already documented: it fails inside any build by design);
  baselined  in internal/fixtures/orphan-checks-baseline.json, today's 131 orphans, for their
             owners to chain, declare or delete. That list only shrinks (--shrink).
A new unchained script fails by name. A self-test runs first on every invocation. Proven two-sided on
the real tree: a planted unchained script exits 1 naming it; swapping exact matching for the old
substring test makes the self-test refuse; restored, it passes.
2026-09-29 11:56:46 +00:00
Doxa Maps
eb3f48ec14 Guard the viewport meta, because without it a "390" run is a 980px run
A browser given a 390px viewport still lays the page out at the 980px desktop
fallback unless the document declares <meta name="viewport">. Measured in
Chromium at 390x844 with isMobile:true:

  without the meta:  window.innerWidth = 980  (body 964px)
  with the meta:     window.innerWidth = 390  (body 374px)

Nothing warns. The page renders, the probe reads its numbers, and the check
goes green having measured a viewport no phone has. A full set of mobile runs
was taken this way before the layout width was ever compared to the requested
one, so the conclusions drawn from them described the wrong viewport.

Three gates already read window.innerWidth and none compares it to the width it
asked for, so all three would pass at 980 while reporting 390. This asserts the
property that makes their numbers mean what they say.

Checked on the built artifact rather than the generator template: a template
containing the right string proves nothing about the HTML that shipped. No
server needed.

width=device-width is required specifically, not just the tag: a meta carrying
only initial-scale still lays out at 980, so matching the tag name alone would
leave the gate unable to fail on its own founding bug.

The 28 sheets under assets/posters/ are exempt by their stated shape - a fixed
paper canvas (.stage{width:1500px;height:2000px}) has no mobile layout to get
wrong. Keyed on that property, not the directory, so a poster keeps its
exemption if it moves and an app page in that folder does not inherit one.

Mutation-tested against the real tree: stripping the meta from a shipped page
fails it, and replacing it with an initial-scale-only tag fails it. 8 self-tests
cover both, plus the exemption and its inverse.
2026-09-29 11:56:46 +00:00
Doxa Maps
18e5dc8fdf Guard the map option a de-duplication can silently invert
check:gospel-whiteboard does not cover the adoption in the previous commit. I
poisoned GiMap into whiteboard:true — the exact regression that blanks the
production geographic map — and it still exited 0. It asserts chart geometry
(projection, radii, the bounded gap) and imports no component, so a call-site
option is invisible to it. Its pass was never evidence for this change.

check:board-map-options asserts what actually regresses: every
createWhiteboardMap call states `whiteboard` explicitly, and a geographic caller
(whiteboard:false) also states `boundaries`. A caller that inherits defaults
gets the whiteboard's, which are the inverse of a real map's.

Two-sided, because a check that cannot fail is not evidence: poisoned, it exits
1 naming GiMap.vue; restored, 0. Both negative controls run in the gate itself —
a call omitting the option must be judged non-compliant, and a compliant call
must not trip it, or the gate would just ban the helper.

Registered in all three places: script, gates:pre, //scripts. gates:pre is 77.
2026-09-29 11:56:46 +00:00
Doxa Maps
58d65ba509 check:i18n-never-translated runs in gates:pre: a dead key sat untranslated through every landing
The check was defined and in no chain. So gospel.json's sheet.isoLine, identical in all 11
catalogues and referenced by no code, stayed on development through every landing since it
arrived; it surfaced only when someone ran the check by hand. This round deletes the key and
chains the check beside check:i18n-keys-exist, so it lands green and stays guarded.

Run before chaining, on this round's tip: PASS (18.8 s in the chain), 2 keys deferred pending a
ruling. On development without the deletion it fails on that one key, as gospel measured.
2026-09-29 11:04:22 +00:00
Doxa Maps
f574c7586f The browser suite runs both engine gates, each listed after its first real run
The suite lists a gate only after it has been run, so these waited for one. On 5b05f019e, in the
browser slot:
  check:auto-engine      PASS. no token -> MapLibre, 0 Mapbox requests; a host token alone ->
                         MapLibre, 0 Mapbox requests; ?provider=mapbox + the token -> Mapbox,
                         carrying it
  check:engine-contract  PASS. 2 providers x 3 load states: the engine asked for, or a visible
                         failure, never a substitute
Both entries record those results. check:engine-contract cannot demonstrate a failure at runtime
without the defect rebuilt into a bundle; its falsifiable arm is the source gate's self-test, so
check:no-silent-engine:self-test now runs in gates:post beside the gate it proves. It was chained
nowhere.
2026-09-29 11:04:22 +00:00
Doxa Maps
10eaceedf2 check:auto-engine asserts the rule that holds, and runs: it sat in no chain asserting the old one
Its case 2 expected a host token alone to draw Mapbox, the rule until 2026-09-28. It was in no
chain, so it never ran against the change that made that false, and nothing else measured the case
the change is about. The cases now:
  1 no token: MapLibre, zero Mapbox requests, painted, no prompt (unchanged)
  2 a host token alone: still MapLibre, painted, zero Mapbox requests
  3 ?provider=mapbox with that token: Mapbox, carrying that token
It joins gates:post beside check:leaf-engine-per-provider, together with check:engine-contract
(gospel's resources-map uses the engine it was asked for, or fails visibly), which was also in no
chain. Both are browser gates: NOT MEASURED outside the browser slot, measured in it.
2026-09-29 11:04:22 +00:00
Doxa Maps
64a65c87ab Both poster print gates measure the product again: the generator is forced missing too
A wall whose host passes `sheet` now generates its poster first, from the served
lib/poster-render.js, and never requests posters/ at all. The two gates for the stored-file path
predate that, and neither measured anything since:

- check:poster-print-fallback blocked only the .webp, so the generator printed and the gate saw
  0 .webp and 0 .html requests: a FAIL on two served trees that said nothing about the fallback.
  It now aborts lib/poster-render.js and its classic twin as well, so rasterising posters/<id>.html
  is the one way through, which is what a reader gets when the generator cannot load.
- check:poster-degrades looked for a tree serving neither stored file and SKIPPED with exit 0 on
  any other. The served tree ships the sources now, so it skipped every time and the skip read as
  a pass. It now forces the worst case in the browser (every posters/*.webp and *.html answers 404,
  the generator is aborted) and asserts the honest refusal on any tree. No wall url is NOT MEASURED.

The fallback itself is live code, not dead: it is the path whenever the generator is absent or fails.
2026-09-29 10:07:29 +00:00
Doxa Maps
cafee0d432 The default embed snippet preloads MapLibre, the engine it draws with; Mapbox gets its own snippet
Every map profile's copy-paste snippet began with a preload of
api.mapbox.com's mapbox-gl.js, while its own profile-config said provider
"maplibre", and since 2026-09-28 only an explicit provider "mapbox" selects
Mapbox. So an unedited paste downloaded the Mapbox engine on every view,
never used it, and made a Mapbox request from a keyless page. Its only
guard was a stale comment asking the partner to delete the line.

- embed.script now preloads MapLibre's script and stylesheet, pinned the
  way useMapInstance loads them so the preloads are reused, and its note
  points a host who wants Mapbox at the variant.
- embed.scriptMapbox is that variant: the Mapbox preloads and the same
  element with "provider":"mapbox".
- check:snippet-engine (gates:post, with a self-test) reads the built
  manifest: no default snippet names api.mapbox.com, every map snippet
  preloads MapLibre, and every Mapbox variant preloads Mapbox and sets its
  provider. Planted: the old snippet and a provider-less variant both fail.
2026-09-29 09:18:39 +00:00
Doxa Maps
f9c621c647 The root build files keep the reason and drop the speaker
package.json's gate descriptions, vite.config.js, the build config's
comment keys, capacitor.config.json and .gitignore named or quoted the
Driver in 13 lines. They sit outside check:no-new-driver-references'
areas, but they ship in the giveaway all the same, so they are reworded
the same way as the sweep. The two left are the reference gate's own
description, which has to say what it checks.
2026-09-29 08:11:49 +00:00
Doxa Maps
f30006d02a A print frame is kept until it has printed: its 60 s clock starts at load, not at creation
openPrintFrame's autoprint prints on the frame's 'load', which waits for every image, but the frame was removed 60 s after it was CREATED, loaded or not. The Wall's poster loads 939 photos (132 MB), so on a connection below about 18 Mbps the frame was gone before 'load': no print dialog, and nothing to say so. Now the frame is reclaimed 60 s after its load, at afterprint (as before), or at a 10-minute ceiling for a load that never ends. printImageBlob revoked its object URL on the same creation clock, which blanks an image that has not loaded; it now follows the frame's clock too.

New gate check:print-frame-reclaim (gates:pre), fake timers and a fake frame, so 61 s and 10 minutes take no time: a load at 61 s still finds the frame (and the object URL), 60 s after load it is reclaimed, a frame that never loads goes at the ceiling and not before, afterprint reclaims at once. Two-sided: the copy before this fix fails 6 of its 12 cases (the load-after-61-s and ceiling arms); the controls pass on both.
2026-09-29 08:11:49 +00:00
Doxa Maps
9a83077061 Fetch the catalogue for a host-supplied locale, and hold RTL text while it lands
A locale can arrive two ways. ?lang and <html lang> are read at script
evaluation, and the preload in i18n/index.js covers them. profile-config —
the channel a host page uses — reaches ProfileLoader's watchEffect and
nowhere else, which is the channel's whole purpose; the comment there
already records that an earlier document-level fix never saw it. So with
the build shipping one locale, a host-configured embed set its locale,
mirrored its direction, and then rendered English with no error anywhere.
One line beside the setRuntimeLocale that marks that path.

The RTL hold is the second half. When the catalogue is fetched rather than
inlined, nine locales get a re-render — the words change, nothing moves —
and Arabic gets a layout flip: English left to right, then the whole chrome
mirrors. So the text waits and the map does not.

Not a v-if on the profile: <component :is="ProfileComponent"> is the entire
plugin, map included, and gating it would hold the one thing that must
never wait. A data attribute on the existing root plus a scoped rule over
named chrome classes leaves every component mounted, re-enters nothing, and
lets the map paint on its own schedule. The classes are named rather than
matched by a blanket descendant rule because two of the text-bearing
components also carry map objects. visibility rather than display, so the
chrome keeps its box and nothing reflows when the text appears — a reflow at
swap time is the same defect as the flip, only quieter.

The gate asserts the seam. "ProfileLoader mentions loadLocale" would pass on
an import nothing calls, so the watchEffect's own body is read by brace
depth and the call has to sit inside it. It also refuses a hold that names
anything map-shaped, a hold applied to the whole subtree, and a fetch that
could run without a pin. Poisoned both halves: removing the call and
pointing the hold at a map container each fail with the message that names
the defect.

(cherry picked from commit 5c54750d58)
2026-09-29 07:31:16 +00:00
Doxa Maps
51ef6f7ec4 One phone flag per map, and the leaf page follows it as well as the Gallery
A map says how it behaves on a narrow screen once, in its <profile>.meta.json:
"mobile": "desktop-only" (blocked on a phone) or "larger-screens" (opens,
under a dismissible notice). Absent means phone-ready. Until now the rule
lived only in the Research Gallery, as two hard-coded id sets, so a map
opened standalone on its own leaf page on a phone got neither the block nor
the notice.

- profile-meta.mjs knows the vocabulary; generate-tree and the dev manifest
  carry the flag into each profile's manifest entry, and generate-tree into
  the leaf page's BASE_CONFIG (with the map's name for the desktop-only
  sentence).
- ProfileLoader mounts library/components/LargerScreenNotice.vue on a screen
  under 640px: for desktop-only the profile is never mounted and a centred
  message says why; for larger-screens a slim in-flow bar sits above the map
  (it cannot cover the map's controls), dismissible per profile for the
  session, in the element's own locale and direction. Inside the Gallery it
  stays quiet: every Gallery pane now carries data-doxa-gallery.
- One text: the Gallery's two sentences moved into the library catalogue as
  messages.largerScreenNotice / desktopOnlyNotice (+ noticeDismiss), all 11
  locales moved as they were, and the Gallery reads them from there.
- The Gallery derives its rules from the manifest's flags. On a phone it
  mounts nothing until the manifest has landed: its baked first tab (Big
  Picture) would otherwise mount there before the flags say it is
  desktop-only.
- dashboard-plus-grid is desktop-only and gospel-internet/board is
  larger-screens, the two sets the Gallery held; nothing changes for either.
- check:mobile-gating now asserts the vocabulary, that each flagged leaf page
  carries its flag, the Gallery's derivation and its closed-before-manifest
  rule, that Big Picture stays desktop-only, the 11 locales, and the
  notice's mounting. Its self-test plants one failure per rule.
2026-09-29 07:31:16 +00:00
Doxa Maps
0be5f07ab6 The served sheet generator is integrity-pinned: a browser runs it only when its sha384 matches
Code loaded at print time executes on partner pages, so it is pinned like every embed. The bundle
carries the generator's sha384 (library/packet/poster-render.integrity.js, written by
`bun run pin:poster-render`); posterSheets fetches <assets>/lib/poster-render.js as TEXT, hashes it
with WebCrypto, and on a match imports the verified text from a blob: URL — never the network URL a
second time. A mismatch refuses to run, with a message naming the URL and both hashes; the print falls
back to what it can do without the generator.

The page base is captured when posterSheets evaluates, and every URL it resolves (the generator, the
boundaries, the places) resolves against it — not the bundle's location, and not a URL a single-page
host changed after load.

check:poster-render-pinned (gates:pre), two-sided: the pin equals the sha384 of the source and of the
served copy (a stale pin names the command — proven by an unpinned edit); a TAMPERED copy is refused
and its planted side effect never runs; the genuine copy loads and draws. check:poster-browser-equiv
still passes byte-identical through the pinned path. country-posters 1,472,734 bytes (ceiling
1,500,000), my-upg-100-list 1,232,508, both carrying the pin; verify-bundles-shipped PASS; gates:pre 74/74.
2026-09-29 06:23:20 +00:00
Doxa Maps
b95c40e0f9 Map sheets are generated in the browser at the click, by the same generator the Node script runs
The Driver's print standard: every export is generated when it is asked for, and only previews are
stored. The zone and country map sheets — the poster, and the map-only sheet on a packet's page 1 —
existed only if library/scripts/posters/generate.mjs (Node) had written them ahead of time; no country
had a print-resolution sheet at all.

- library/packet/poster-render.js is generate.mjs's drawing layer (2,645 lines) as a pure factory,
  createPosterGenerator({ env, SERIES_DEF, ISO3_NAME, adm0, adm1Ind, adm1Chn, nePlaces, series }). Moved
  VERBATIM (template literals carry their whitespace into the output), `process.env` read through a
  local shadow; its inputs are its free variables, found by a scope-aware parse, not a grep (a grep
  missed adm0, and the proof caught it). generate.mjs is now its Node driver.
- PROVEN BYTE-IDENTICAL: all 118 sheets of a pinned offline matrix — the 28 zones in 3x4, 3x3, letter
  and packet, and a country sample at both sheet sizes — hash the same before and after.
- data/zone-sheet.js is which group pins in which zone (first claim wins), now ONE function both the
  Node driver and the browser call, so a group cannot pin in one zone on paper and another on screen.
- library/packet/posterSheets.js feeds the factory in the browser: the served full-resolution
  boundaries, the shared series and places tables, a generator per call (caching by format pinned the
  first caller's data on every later sheet). generateCountrySheet does a country end to end;
  data/zone-sheet-print.js a UPG-100 zone. ISO3_NAME moves to library/data (territory-counts re-exports).
- usePosterPrints gains opts.sheet: the poster print is the generated letter sheet as a VECTOR page,
  and the packet's page 1 the generated map-only sheet (item.mapId honoured); stored images remain the
  fallback when generation fails. PosterWall forwards it; the Country Posters wall, the UPG-100 wall and
  the UPG-100 map pass it. The UPG-100 wall and map drop their own record fetches for the shared
  provider, and the map's legend and selection HUD offer the card deck (production).
- check:poster-browser-equiv: the browser feed's sheet is byte-identical to the Node generator's for an
  India zone (packet) and a country — proven two-sided (it fails when the feed drops the city dots).
  check:selection-kinds reads the handler's branches instead of a fixed poster/packet pair, anchored on
  `if (` (proven: it fails with the cards branch deleted). library/packet/README.md: the standard.
gates:pre 68/68; both bundles build.
2026-09-29 05:26:27 +00:00