76 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b2ad4607ee |
Title each packet in its reader's language where the title can be generated
Every packet was titled with its zone's hand-written English label, so a Spanish Sudan packet said "Sudan" and an Arabic one printed Latin script on page 1, on every profile page's header and in the document's own title. A one-country zone's title is that country's name, and Intl supplies it in every language: the packets now carry it - Sudán, السودان, सूडान, 苏丹 - through zone-title.js's tier A, on both print paths. Every other zone keeps its hand-written label, since a region's name is not a list of its countries (tier B and C are a pending product decision). The selection packet names its cover the same way. In the browser the title uses the library's country-name resolver; the offline generator cannot load that module under Node, so it reads the ISO-3 to ISO-2 table and injects it, the path zone-title.js was built for. A data set without zone-title.js keeps its labels. check:zone-title-tier-a was committed unregistered and so never ran; it is now in gates:pre. Its comments, and zone-title.js's, no longer cite people or dates. |
||
|
|
bad51cef2b |
Print the Chinese prayer packet in one script
The app's Chinese is Simplified, and so were 39 keys of the packet's zh
block. The other 18 were Traditional - page 1's subtitle, the list
header, the card labels, and the whole prayer wheel and all twelve
prayer points - so a Chinese packet switched script partway through.
The block had been extended by matching the neighbouring line, which
was itself the drift; the missing-map sentence added in
|
||
|
|
2367c2ef64 |
Build the prayer packet page by page to its specified structure
FULL DETAIL is four front pages, the profiles, four back pages:
map, triangle, life calendar, % evangelical, profiles,
bounded set, centered set, prayer wheel, 12 prayer points.
LIGHTWEIGHT is two front, the profiles, two back:
map, triangle, profiles, prayer wheel, 12 prayer points.
Both are generated from data, in every packet language, from the
browser and from the offline generator.
PAGE 1 - the map, on a vertical page. One lift for every caller
(packet-render.js liftMap): the browser composable, the UPG 100
profile and the offline generator each carried an identical copy of
the old wrapper (250px down, scale 0.62), and three copies of a layout
decision is how page 1 drifts between print paths. The lift prefers
the map-only vertical sheet (mapSheetFiles: <zone>.packet.html, then
the poster) and places it so its names land on the same 60px margin
every other page keeps; a poster falls back fitted under the title at
0.81 rather than 0.62. The title block is drawn after the map, since a
map-only sheet begins with a full-page background. Anything that is not
a map sheet - an icon-sized <svg> from a host that answers a missing
file with a 200 page - is refused, and page 1 says the map is missing.
Page 1 now states which set it belongs to ("One of 28 UPG 100 prayer
lists"), only when the caller supplies the count.
PAGE 2 - one inverted triangle in the Vision Card's look. The card's
steps are the top (every people group, the unreached, the frontier,
the network's list), narrowing to the UPG 100 packets and, at the tip,
this packet. The card's greens, darkening as it narrows, walked
through its own colour stops; every numeral picks white or ink by the
WCAG ratio (all at least 3.4:1, large bold type). Tier widths are
geometric, not proportional - 2,107, 28 and ~100 against 12,379 would
be hairlines - so where two sets truly nest the caption states the
step removed, and the network's list, which does not sit inside
frontier, gets the card's "N of them are frontier" note instead of a
false subtraction. The packets tier and its count appear only when the
caller supplies one.
BACK PAGES - the bounded and centered sets get a page each at twice the
width, under the one page key, so lightweight drops the pair together.
THE WHEEL - the Metacamp Module 1 prayer wheel: its twelve colours,
sampled from the fill operators in the source PDF's content stream; its
orientation, the opening PRAISE at nine o'clock and READ THE WORD at
twelve; white labels with a shadow and a faint outline, so a print
path that drops SVG filters still leaves them legible on the palest
segments; "5 minutes" badges; the attribution with the website. The
labels stay the packet's own rhythm names, translated in every
language and matching the 12 points page.
The offline generator makes both packets (PACKET_VARIANT=lightweight;
the full packet keeps its names, lightweight is <zone>.lightweight and
stamped with its variant).
STRINGS: four new keys, in all eleven blocks - each block's own words
for "packet" and "prayer list", the frontier note aligned to the word
the frontier tier uses on the same page.
GATES: check-packet-a1-structure (registered in gates:pre) reads every
sheet's CONTENT for the order of both variants, page 1's lift in all
four cases, the triangle's tiers and steps, the wheel's colours and
orientation, and all 28 zones. Mutation-tested eleven ways, each caught
by its own message. check-packet-variants now asserts both sets pages
and a four-sheet difference. The browser gate check-packet-render
counted funnel rows and bars by inline-style substrings, which an SVG
triangle has none of - both would have read zero and passed - so it
now counts .funnel-n against .funnel-tier, its sheet arithmetic counts
the sets pages, and its Latin allowlist carries the renamed product
name. check-packet-browser used a copy of the old wrapper; it now uses
the product's lift.
|
||
|
|
c121c63834 |
The map template draws a map-only vertical page for the prayer packet
Page 1 of a prayer packet is a lightweight vertical one-pager showing the zone's geography and every pin with its numbered name - not the poster. Until now the packet lifted the 3x4 poster (1500x2000, its own title, facts table, vision columns, networks and closing verse), shrank it to 62% and pasted it under the packet's title: a small poster on a page, with the English poster prose baked into the artwork. FORMAT=packet is a new sheet format: 1500x1941, the packet's own page, with the top band left EMPTY for the title the packet draws in the reader's language, a slim foot, and everything between given to the map and its label columns - about 1,600px of height against the letter sheet's ~1,200, so the names get the larger type the sparse posters use. It omits all the poster furniture, and its root <svg> says data-sheet="map-only" so the packet can tell it from a poster. Paper formats no longer overwrite the poster. Every format used to write out/posters/<zone>.html, so FORMAT=letter silently replaced the 3x4 the wall and the site serve. packet and letter sheets are now <zone>.packet.html and <zone>.letter.html, with their own manifest; 3x4 and 3x3 keep the bare name, which every consumer fetches. publish-html.mjs read everything before ".html" as the zone id and so skipped every paper sheet as an unknown zone; it now strips the suffix. The two fail-loud guards (header overflow, verse off the sheet) stay armed for posters and stand down only for a sheet that does not draw the furniture they measure. Proof that posters are untouched: the default 3x4 output for sudan, asia-india-kerala, deaf and west-africa is byte-identical before and after, generated with POSTER_DATE pinned. The first attempt was NOT identical - an HTML comment added inside the SVG template literal is emitted into every poster - so the notes live in JS comments instead. check-zone-census counted paper sheets as posters (it would read 56 for 28 zones); it now counts posters and reports paper sheets apart. |
||
|
|
502d7107e3 |
Every zone a reader can pick resolves to its groups, Deaf included
The legend offers 28 zones; every place that turned a picked id back
into a zone spread clusters.json's three geographic buckets, which is
27. The missing one is Deaf, whose two deaf lists the legend collapses
into a single affinity-bloc row. So in the browser, silently:
- the Prayer packet for Deaf returned on "if (!zone) return";
- the Deaf poster titled itself by its slug;
- a selection containing Deaf dropped it in .filter(Boolean);
- the packet count read 27 while 28 packets exist, and that count
prints on page 1 and at the tip of page 2's triangle.
The packet fetch also lacked rop1, the field the Deaf selector reads,
so even a found Deaf zone would have resolved to nobody.
The fix is one enumeration. legend-tree.js now exports DEAF_SELECTOR
(the unified row's selector, which exists nowhere in clusters.json) and
zonesWithSelectors(clusters): the legend's leaves with their selectors
attached. The plugin's printableZones() keeps the 27 catalogue objects
exactly as they were and adds only what the catalogue lacks, so the
packet, poster, selection and count all see the same 28. rop1 joins
both packet fetches.
The three hand-typed copies of the Deaf literal (the map, the poster
generator, the offline packet generator) now import the one
definition; the offline generator's own id-to-selector table is
replaced by zonesWithSelectors. The copies left are frozen snapshots and
proof scripts, which keep independent literals on purpose.
The offline sweep gate's coverage had the same 27: it never required a
Deaf packet. It now counts through the legend.
Verified: the live API carries exactly 123 A017 rows, matching the
legend's Deaf count; the default 3x4 posters for sudan, kerala, deaf
and west-africa are byte-identical before and after.
|
||
|
|
646c23cc0e |
Put the missing-map sentence in every packet language
The page-1 notice for a missing map existed only in English, which turns check:packet-strings-parity red: the packet catalogue must be whole in every language, and English-by-choice is not one of its declared exceptions (only the licensed Mark 5 passage is). All eleven blocks now carry the sentence, reusing each block's own page-1 wording for "prayer list" so the notice reads like the line above it. The English says "prayer list" rather than "zone": zone is an internal term, and the page itself calls it a prayer list. The Chinese block is Traditional script, so the sentence is too, with the Taiwan term for printing. The loud gate's note described the old English-only state; it now reports which locales fall back, or that none do. |
||
|
|
ef6000c5d7 |
Say so on page 1 when a prayer packet's map is missing
Page 1 of every packet draws the zone map from mapSvg. When the map fetch fails, the page interpolated an empty string: a title, a subtitle, a privacy footnote, and nothing where the map belongs. A failed fetch and a successful render were byte-indistinguishable in the printed PDF, and the packet read as undeveloped. It is not a rare edge. The served asset pool ships boundaries, fonts, images, people-groups, pplr, previews, tables and vendor, and no posters/ directory at all, because generate-tree gates it behind posterSources and bundlePosters, both default false. Over live HTTP against the served tree: previews/sudan.webp 200, boundaries/world-adm0-web.geojson 200, posters/sudan.html 404. So on a default build every packet's page 1 has been blank. The browser path's catch around the poster fetch is right to keep printing -- a missing map must not cost the reader the people pages -- but degrading and degrading silently are different things, and only the renderer knows the map is absent when it draws the page. It now prints one quiet sentence in the map's place. The sentence is English-only on purpose. t() falls back to English per key, so the other ten locales print it in English until a translator supplies theirs, rather than ten invented translations nobody can check. The real fix for the blank map is shipping the poster sources in the asset pool; that is a tree-config change and goes separately. check-packet-map-missing-loud is two-sided: loud when the map is missing AND quiet when it is present with the SVG intact, in en/es/ar, both variants. Shown capable of failing four ways: the silent cover, a notice that always prints, a notice that displaces the map, and the raw key printed instead of a sentence. |
||
|
|
9799ebdba4 |
The full packet's sets page gets its data, from every print path
setsPage landed as a renderer capability with no data supplier: it returns '' without data -- correctly -- which meant the FULL-DETAIL packet had never once printed its distinguishing back page in real use. The full-vs-lightweight split existed in code while the full packet was missing the page that makes it full. packetSets.js is the supplier: a pure decode+join with zero imports, so the identical function runs under Node (the generator reads the table from disk) and under Vite (the browser fetches it) and the join cannot fork between the two. The table stays with the caller -- the same seam as strings, christian and describe. The join is measured, not assumed: 2,091 of 2,107 groups match by slug, rings distribute 1-10 with real shape, and the 16 unmatched are inked as UNKNOWN on the bounded figure rather than guessed onto a side -- an un-inked category reads as zero on paper. Wired at every path that prints a packet: the offline generator (all 28 zones verified carrying the page, e.g. laos: 0 inside, 94 outside, 1 unknown; 94 placed with "25 same dialect, same people"), the legend Export menu, the poster wall, and the selection packet -- the last three through one opts.sets loader fetching the SAME served asset-pool copy the generator reads, so an embedded bundle prints the same page as the dev tree. The decode is a third reading of near-neighbors.json's cols/dict format (nearRows.js documents being a copy of upg-neighbor's, pending promotion to library/). Flagged in the header rather than hidden: when the loader is promoted, the decode here collapses into it and only the join remains. |
||
|
|
4b45285b7d |
Hold the packet's ring names to the screen's, and adopt its exact words
The agreed ring vocabulary now lives in two catalogues in eleven locales each -- gospelInternet.json for the screen legend, packet-strings.json for the printed sheet -- and nothing held them together. They legitimately want different SHAPES (the screen renders the axes as standalone labels, the packet composes them into one string per ring), so neither can import the other; but the WORDS must match. The failure that makes this worth a gate is quiet: one cell is provisional on the Driver's row 89, and it appears in rings 1, 4 and 7. Answer it in one file and not the other and the sheet and the screen state different things about the same ring, each correct in isolation, with no error anywhere. Only a reader holding both would notice. Measuring first showed 36 of 77 cells already differed, in two classes. Most were grammar -- the packet's culture terms follow a comma mid-sentence, so they are lowercase -- but some were genuinely different words: "Same family" against "Same language family", German "verwandte" against "Nahe", Hindi "वही जन" against "वही जनसमूह". Those are drift, and the screen's wording wins because it is what readers already see. The packet now adopts every term verbatim, lowercasing only the first character of the culture half, which is a no-op in zh/ar/hi and leaves German common nouns capitalised. That adoption caught a bug in my own assertion: I had hardcoded ring 7-9 as "Same family" from CenteredSetTarget.vue's header, while the shipping string is "Same language family". A summary in a comment is a description of the data, never a second source of it. The gate also refuses to pass when it compared nothing -- rename a key on either side and every lookup returns undefined, every pair is skipped, and a silent check reports success. Mutation-tested both ways: answering row 89 on the screen only, and renaming a packet key. |
||
|
|
fccf6ee2fe |
Name the rings on the packet's centered set
The figure printed rings without saying what they mean -- honest but thin.
It now names each one with the vocabulary gospel and exp settled, the same
words the screen legend renders, so a printed sheet cannot disagree with
the screen.
COMPOSED FROM band x culture, NOT A FLAT 1..10 TABLE, and that is the
whole design decision. "Same people" in column 1 is provisional on the
Driver's row 89 -- and it appears in rings 1, 4 AND 7. A ten-string table
would need three edits kept in step, which is exactly how two of them end
up saying different things. Composed, it is one string
(ring_culture_same), and if he picks "culture" the ring numbering, the
columns and the colours are all untouched.
Ring 10 stands alone ("Nearby only") because it is not a band-and-culture
reading at all -- it is what is left when no linguistic relation was
found. The gate asserts that separately, and a mutant that composes it
like the other nine fails.
ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure
is a glossary, not a legend, and a named ring with no dot on it invites
the reader to hunt for something that is not there. ringTally's per-ring
counts drive the list, so it states what this zone actually contains.
Mutation-tested: a wrong band order, listing all ten rings regardless of
count, and composing ring 10 like the others each fail with their own
message. Eight new strings x eleven locales; packet-strings-parity green.
|
||
|
|
3c1d513cc3 |
The packet gets its bounded and centered sets page
Brief A1 back matter: after the profiles, before the wheel, full detail only. The lightweight packet is map, triangle, profiles, wheel, points -- these two figures are the part a reader sits with rather than carries. The layouts are regions' library/components/charts modules, imported rather than injected: unlike describeGroup's country resolver they have zero imports and run under plain node, and packet-render already imports scatterCells from that directory. Verified by importing and CALLING them, not by reading them. They return data, so the SVG is ours. Gospel's non-negotiable is carried through and checked rather than trusted: the bounded set keeps a deliberate gap either side of the boundary because a dot ON the line is the one thing a bounded set cannot express, and print has no hover to disambiguate it. Measured on the real module with 40 groups -- inside max 85.3 of 92 allowed, outside min 119.8 of 112 required. The figure scales through the viewBox, so the gap lives in coordinates the scaling cannot touch; shrink the figure, never the gap. UNKNOWN POINTS ARE STATED, NOT PLOTTED. layoutBoundedSet gives them no x/y, which is the module being right: `reached` undefined means we do not know which side they belong on, so there is no honest place to draw them. My first version mapped them through the same dot() helper and crashed on p.x.toFixed -- a better outcome than silently drawing them at (0,0), which would have put "we don't know" inside the circle. They go in the tally, because on a printed page a category the reader cannot see reads as zero. check-packet-variants' sheet delta goes 2 -> 3, updated in this commit rather than relaxed into a range. It also now SUPPLIES the sets fixture: setsPage returns '' without data, so a build() that omitted `sets` exercised the filter for every page except the newest one, and the gate sat green through the whole rewrite while asserting a delta that had become wrong. A fixture missing an input silently narrows what the whole file covers. Seven strings x eleven locales; packet-strings-parity green. Gospel's own ring names live in gospelInternet.json, a plugin catalogue, so there is no collision with these. |
||
|
|
d3ee0bb6ec |
The whiteboard is a layer of tiered points; only the edges were ever a tree
Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom, label priority and tiling from the map engine instead of re-implementing them in SVG. READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The parent->child links are sourced from linkFC; the points, the selection and hover rings, the dish behind a cluster and EVERY label layer come from one point collection and know nothing about trees. gospel re-read that independently and found the same split, which is the only reason I trust it -- "the seam is generic" is the claim that should never travel on one reader. So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose features carry id, label, tier and priority, and gives each TIER its own symbol layer with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group names in": the engine is told which labels may exist at which zoom and resolves the collisions itself. Paint expressions pass through unread -- the layer has no opinion about what a colour means, which is what keeps it reusable. Selection and hover are FILTERED layers, so hover costs one setFilter and structurally cannot move a camera: there is nothing in here that could. AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong. gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the labels a reader compares are. library/maps/whiteboard-projection.js is the inverse conversion, EXTRACTED from build-language-tree.mjs rather than written, because the second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996 of the width at the centre to 0.6794 at the edge; that shrink IS the correction the renderer then cancels. TWO TESTS, EACH GUARDING A DIFFERENT RISK. check:whiteboard-projection reproduces the old private implementation VERBATIM as a reference and compares over 1,734 grid points EXACTLY -- the language tree is a build-time artifact, so a rounding shift would move 4,000 coordinates and look like nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND that a linear scale FAILS that same assertion, because an assertion the defect can pass is measuring nothing. check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers, priority by population, and NO EDGES. The absent links layer is the evidence -- if the whiteboard draws this chart with no line layer anywhere, edges are separable in fact rather than in my reading. The map is a recording fake, so the assertions are about what the module DOES; a regex over the source would pass on a module that never called addLayer. Negative-tested: making the projection linear, changing its rounding, dropping the label priority, or giving every tier the same minzoom each turn one of them red. gospel's UI is NOT wired -- their component is theirs to write, later, against a module that now exists. Their boardEngine.js stays where it is until the chart lands. |
||
|
|
aedc517cec |
The table row is a border-box, and the offline packet gets its description
Two fixes from measuring what I had only modelled. 1. THE ROW RENDERED 81px AGAINST A 73.25px MODEL. rega measured it: no box-sizing, so the declared height was the CONTENT box and the 6px padding plus 1px border were added outside it. Because `top` still advanced by the modelled height, every row overlapped its successor by the difference. The stack still fitted the sheet -- it ended 8px inside the 60px bottom margin instead of on it -- so nothing clipped, nothing ran off the page, and no headless check could see it. box-sizing:border-box makes the declared height the rendered height. The ladder still fits: the content box is 66.25px and a name plus two lines at the default 17px needs 61.8px. 2. THE OFFLINE GENERATOR WOULD HAVE PRINTED A ONE-CLAUSE DESCRIPTION. Its fetch never asked for primary_language or imb_gsec -- measured on the cached records, 0 of 2107 carried either, while country_code_label was present on all 2107. So the browser packet printed two full sentences and the offline packet printed "The Amahuaca live in Peru." from the same data. Same defect class as the comment already above that field list: it reads as missing DATA and is missing FIELDS. The generator now injects the describer too, deliberately WITHOUT a country resolver: library/utils/countryName.js cannot be imported under node at all (it imports JSON without an import attribute -- measured), which is the constraint that made this seam an injection. It is not needed, because describeGroup falls back to country_code_label and every cached record has one. Verified end to end: 28 packets, 63 sentences on the first page, zero fallbacks, country localized correctly. The gate now budgets against the CONTENT box rather than the declared height, asserts the DEFAULT 17px step fits and not merely the smallest, and pins box-sizing in the source -- mutation-tested: removing border-box fails with its own message. |
||
|
|
6518fad1bb |
The language tree's layout becomes library's; its gospel classification does not
Driver brief §B, BUILD-LIBRARY-CHART-LANGUAGE-TREE: promote gospel-internet's language tree so a fork can build one from its OWN rows. gospel's scripts/build-language-tree.mjs does two jobs in one file. The generic half is the radial layout, the circle packing and the inverse Web Mercator projection. The other half reads gospel-internet.json, ebible-index.json, gospel-resource-links.json and FCBH verse scopes to classify each language g/y/r, and emits a second, purely gospel output. Moving the file whole would have put eBible and FCBH inside library/, and a fork building from its own roster has no eBible index -- so the literal move is the one shape that would NOT deliver the reuse the brief asks for. So the classification stays with its owner and arrives as `statusOf`, returning an OPAQUE string. Nothing here knows what 'g' means; a consumer colouring by speaker count substitutes one function instead of forking a data pipeline. The status is written on LEAVES ONLY, which is not an omission. Rolling a parent's colour up from its descendants is a RUNTIME decision the chart makes against the reader's current scope -- and the scope changes as the reader filters, so baking it would freeze one answer into the data. gospel confirmed the rollup is policy rather than geometry: nothing in the drawing depends on a parent being the worst of its children. The measured constants keep their reasons: the enclosing circle is the tree's real extent plus room for one label (sized by the deepest ring, packing wasted a third of the board); the poster layout drops dialects (2,653 dialect slots squeezed the 858 languages until ~40% of names could not be printed); and the share of the Mercator square is a parameter with a measured default, because it is what decides how far a reader can zoom out below the opening view. check-language-tree-layout asserts INVARIANTS rather than a golden file -- a snapshot of 4,000 coordinates fails on every legitimate change and says nothing about which property broke. Family circles do not overlap, every roster language is reachable, a missing one warns rather than vanishing, the drawing stays inside its extent budget, the poster layout excludes dialects, the status letter is carried through unexamined, and the same input builds the same tree twice. gospel's own script is untouched: they switch to this and drop their copy in the single owner-land already planned, so nothing of theirs breaks in between. |
||
|
|
a764a89e3b |
packet fixtures carry the description's two fields
The four packet fixtures predate primary_language_label and imb_gsec, which the
packet fetch has carried since L59. describeGroup degrades by DROPPING CLAUSES
rather than blanking — sr's deliberate and correct design — so on fixture data
it renders "The X live in Y." with no language and no reachedness sentence, and
does so without error.
That makes the OFFLINE render (PACKET_RECORDS=fixtures/…, which is how rega
verified NEEDS-EYES row 83) diverge from the live browser packet in exactly the
new column: the verification path would pass on something the product does not
print. Same Node-vs-browser divergence as the %evangelical page, pointing the
other way.
Regenerated from the live API (2,107 rows), matched by slug: 21/21, 20/20, 1/1,
43/43. Row counts and order are unchanged, so the 20/21/1/43 edge cases keep
their meaning and check-packet-render's `ceil(cards / PER_PAGE)` arithmetic is
untouched.
★ STORED POST-normalizeRecords, NOT AS THE API RETURNS IT. Both fields arrive
nested — primary_language is { value:'abf', label:'Abai Sungai' } and imb_gsec
is { value:'1', label:'Less than 2% Evangelical…' }. A fixture holding the raw
shape would have faithfully reproduced the bug that 3b644893 fixes, so the
fixture would look right and the packet still print half a description.
HONEST LIMIT: the DOXA set is 225 at GSEC 0, 1,852 at 1, 23 at 2, 4 at 4, 1 at
6, 2 null — so fixtures drawn from real data exercise levels 0 and 1 only, and
levels 3 and 5 do not occur at all. Per-level coverage belongs in the unit test
with synthetic rows, which is where it already is; the fixtures' job is to match
what the fetch delivers.
|
||
|
|
1061e9c5c4 |
The packet's generated profile sentence has its catalogue: ten keys in eleven locales
describeGroup returned an empty string against the shipping packet-strings.json because none of its keys existed, while its check passed on its own fixture strings. desc_where_* says where a group lives and what it speaks, dropping whichever clause has no data; desc_gsec_0..6 says how evangelical it is. Each locale's under-2% sentences reuse that locale's funnel_unreached wording, so a packet does not define the threshold two ways on two pages. English from the UPG-100 owner. |
||
|
|
11f0f4f5fc |
packet: country mode needs the country zone source, not just the wording
exp-shepherd measured on main: SERIES=country ONLY=country-yem made a POSTER, and the matching packet.mjs run died with 'no zone matches ONLY=country-yem'. The packet half of SERIES=country could not run for any country. Cause: allZones() built zones from clusters.json -- the UPG-100 zoning -- where a country id never appears. generate.mjs got its country zone source from countryMode in the previous commit; packet.mjs did not. The same 'one concept, two switches' defect I had just removed from one file survived in the other. MY OWN TEST MISSED IT BECAUSE IT WAS NOT END TO END. I ran the country packet against a UPG-100 zone id (ONLY=west-africa), which exercised the WORDING and never the zone source, then reported the parameter verified end to end. Running the actual pairing is what found it, and exp-shepherd did that, not me. allZones() now returns the country zones under countryMode, built to match generate.mjs's country branch exactly -- same ids, labels, adm0 and the PLURAL country_codes selector shape -- so a poster and its packet cannot describe different zones. Same two-path country-list lookup and the same error naming the command that writes the file. Verified: country-yem builds 8 groups / 6 pages, SERIES-ISOLATION PASS with 9 country markers and zero UPG-100 framing; the UPG-100 default is unchanged at 12 pages and 13 markers; all four packet gates PASS. |
||
|
|
11129455e4 |
posters: SERIES is the one switch; header wording joins it; country mode fixed
rega read the rendered page and found a fourth leak site my gate had passed: a
SERIES=country poster still printed 'UNENGAGED PEOPLE GROUPS', 'Unengaged of West
Africa', 'Series: List of 100 UPG's · map 11 of 28' and a side box calling it one
of a 30-map set. I had reported that PASS to three agents as proof the render was
clean. It was not proof: the gate's vocabulary was a HAND LIST of the phrases I
had already found, so it could not see framing I never enumerated.
ONE SWITCH (dx-shepherd's call). SERIES=country now implies the country zone
source, the series name and the header strings. COUNTRIES=1 is kept as an ALIAS
that resolves to SERIES=country, so runs that predate series.json keep working
and now get the matching packet too. Two parameters for one concept is exactly
what let rega's render take the new packet path with a UPG-100 header.
The five header strings move into series.json: kicker, title qualifier, series
name, side-box heading and body. One file owns the wording.
THE GATE NO LONGER DEPENDS ON WHAT I HAPPENED TO KNOW. Its vocabulary is DERIVED
from series.json -- every string a series declares becomes that series' own
fingerprint, so adding wording polices it from that moment. Strings shared
between series are dropped (they identify nothing) and a 12-character floor keeps
short phrases from matching innocent prose. The hand list is kept and merged for
phrases no series declares, like 'Project 7000'.
It also strips HTML and CSS comments before matching: generate.mjs ships design
notes into the SVG that QUOTE these strings, and a correct country poster failed
on five of them. A detector that does not know the comment syntax of what it
reads attacks the documentation of the thing it protects.
FOUND WHILE WIRING IT, a pre-existing break: COUNTRY MODE HAS NOT RUN AT ALL.
generate.mjs read plugin-designs/country-posters/data/countries.json, but
|
||
|
|
70cb1b3ed3 |
packet: refuse to build around a poster of a different series
The ordering rule 'regenerate the poster with SERIES=country BEFORE the packet' was a fact about the build living in a human's head. Documentation does not hold that kind of fact: the same bet already failed for 'regenerating the tree alone ships a stale .js', which is written down in the brief and still happened. The hazard is silent and I walked into it while building the series parameter. The three below-map blocks are DRAWN INTO the poster's SVG by generate.mjs, and the packet lifts that SVG whole, so the text reaches the packet through the IMAGE -- no string file mentions it and no grep of packet-strings.json can see it. A country packet built against a leftover UPG-100 poster prints 'Thirty disciples' on page 1 with no error, the right file size, and a generator reporting success. It looks exactly like the SERIES parameter does not work. So the build asserts it instead of remembering it. The packet reads the poster's series out of the artwork it is about to lift -- fingerprinted by the NETWORKS headings, which are the one marker that survives every stage of the pipeline into the artefact -- and exits 1 with the remedy command when it disagrees with its own SERIES. Suggested by regions-plugin, whose two near-misses today were the same class: assert against the ARTIFACT with a marker that survives the pipeline. Verified in all four directions: country packet vs upg100 poster FAILS, upg100 packet vs country poster FAILS, and both matching combinations build clean. An unrecognised poster is deliberately not an error -- that is the existing 'no <svg>' branch's failure, not this one's. |
||
|
|
12d7f218f3 |
posters: a SERIES parameter, so a country poster stops rendering the UPG-100 packet
The country posters shipped carrying UPG-100 framing: the UNENGAGED PEOPLE
GROUPS kicker, 'This packet is 1 of {n} UPG-100 lists', the Project
2000/7000/4700/700 narrowing funnel, and a below-map block telling the reader to
divide the set among THIRTY disciples -- coherent for ~30 UPG-100 zones and
incoherent for ~170 countries. Reported by the Driver.
Not a mislabel: there was NO series parameter anywhere, so 'the packet' and 'the
UPG-100 packet' were one thing and nothing could disagree.
series.json declares each series' funnel flag, string overrides and the three
below-map NETWORKS blocks. Both packet.mjs and generate.mjs read it, because the
leak was in TWO independent places -- the packet strings and the poster's own
furniture, which is not a packet string at all, so fixing either alone leaves the
other printing. SERIES absent or unknown resolves to upg100.
The funnel page drops for country: it narrows every people group on earth to THIS
list, which is not a description of a country. Reusing the existing
missing-vision-funnel path means one way for the page to be absent, not two.
Four packet keys are overridden, not seventeen: 12 of the 17 are funnel_* and go
with the page; the 12 prayer points are invariant, because they are about a
PEOPLE and a country poster still prays for the peoples within it. Overrides
apply to every locale on purpose -- an English country kicker is visibly awaiting
translation, whereas inheriting the translated UPG-100 kicker prints the wrong
series fluently.
The country English is a DRAFT from the Driver's dictated shape, flagged
_draft_awaiting_driver; no translations until he signs off.
check:series-isolation asserts the property, with a self-test over 6 known-bad
inputs. It caught a real leak this commit would otherwise have claimed fixed: the
packet's page 1 lifts the whole SVG from the pre-rendered poster, so a country
packet built against a stale UPG-100 poster still printed 'Thirty disciples'.
Verified at the rendered layer -- country 11 pages, upg100 12, the delta exactly
the funnel page; upg100 networks byte-identical to the text they replaced.
|
||
|
|
f7a7619695 |
Stop shipping an internal role noun in public bundles
check:public-hygiene flags "internal role noun (Driver) — process vocabulary" inside shipped bundles. Three of those reach regions-dashboard.js, and they are not from the regions components: they come from the shared poster and packet code, which every bundle using PosterWall carries. The worst is not a comment at all. packet-strings.json's note_on_scripture is a shipped STRING, so it is data a reader can read. The other two are a JS comment and a CSS comment that both live INSIDE template literals, which makes them string content rather than comments -- the minifier cannot strip what is not a comment, and the shadow-CSS stripper does not reach a print stylesheet. Rewritten to say what the decision was rather than who made it. Three lines, no behaviour, no reformatting. regions-dashboard.js goes from 3 occurrences to 0 after a rebuild. This is the white-label requirement, and it matters most for the artifact we intend to hand over: a recipient reading our bundles should not meet our internal process vocabulary. |
||
|
|
b22df1381e |
Mark 5 in five more languages, each a cited public-domain translation
rega measured the Arabic packet (row 83) and found page 8 still English. dx-shepherd's rule, which this follows exactly: NEVER machine-translate Scripture — use a published public-domain translation per locale, cited with its name (END-DXS-MK5GO). ar Arabic Van Dyck Bible ru Russian Synodal Bible es Reina Valera 1909 de German Luther Bible 1912 fr French Ostervald Bible Fetched from bible.helloao.org, which ships licenceUrl + website + sha256 per translation and points at ebible.org's own licence pages. Full provenance — translation id, name, licence URL, sha256 — is recorded in data/people-groups/scripture-sources.json, because a credit line on the page can carry a NAME but not a verifiable source, and the two are different claims. ★ VERSE NUMBERING VERIFIED AGAINST THE ENGLISH, not assumed. All five extract exactly verses 1-20 of 43 in the chapter; verse 1 matches the WEB's "they came to the other side of the sea, into the country of the Gadarenes" and verse 20 ends at "Decapolis ... everyone marveled". Lengths land within 1,689-2,399 chars against the English 2,141, which is the spread you would expect across languages rather than a truncation. The English passage carries no verse numbers, so these are joined as continuous prose the same way. ⚠ AND I DID NOT WRITE ANY OF IT FROM MEMORY. Scripture reproduced from recall is machine translation with extra confidence — the same failure the rule forbids, one step removed. Every character came from the API and was checked against the English before storing. hi/zh/pt/it/ro STAY ENGLISH and stay in DECLARED_ABSENT, which is now narrowed rather than deleted: PD candidates exist (Hindi 1851 Serampore-lineage, Chinese Union Version 1919, Almeida 1819, Diodati 1649, Cornilescu 1924) but their licence status varies by jurisdiction and I could not confirm it. The entry shrinks one locale at a time as each is cited. VERIFIED: check:packet-strings-parity PASS, and the ar and de packets both render 9 sheets PASS with the passage and its credit on page 8. |
||
|
|
524e5ebfae |
Translate the new packet pages into all eleven locales
The gate caught what I shipped: rendering the Tibet packet at PACKET_LANG=ar failed with "the funnel page carries Latin-script prose in a ar packet: 'This packet is', 'UPG-', 'lists … How each number is reached'". Exactly right — my new strings were English-only, and two of them PRINT on a page whose siblings (funnel_title, funnel_intro, funnel_people) are fully translated. English text on an otherwise Arabic page is not a graceful fallback, it is a half-translated page. So all 17 new keys are now genuinely translated across es fr pt de it ro ru ar hi zh — 150 values — and DECLARED_ABSENT is back to its original two entries (the Mark 5 passage and its credit). The declaration mechanism was the right call while they were untranslated and the wrong place to leave them. ⚠ 'UPG-' IS ALLOWLISTED, AND THE ORDER MATTERS. It is the Latin run left by "UPG-100", the product name every locale keeps verbatim — the same reason 'Project' is already in LATIN_OK. But it is allowed only AFTER the real translations landed: an allowlist entry added when the failure first appeared would have silenced the gate on fifteen genuinely untranslated strings. The allowlist is for names, not for work not yet done. VERIFIED at the rendered layer in BOTH locales: en and ar each PASS with 9 sheets, map painted, content intact. The Arabic packet carries its life-calendar title, christian-share title, both legend labels, the three prayer columns and the "1 of 28" tip in Arabic, with 2 grids and 32 shaded cells — the same numbers as the English one, which is the point: the arithmetic is language-independent and only the words change. check:packet-strings-parity PASS. |
||
|
|
de243597db |
The prayer packet the Driver asked for: life calendar, the same grid as a percentage, a triangle
Driver, 2026-09-27, in his own page order. Nine pages now, was seven.
1 map the zone's vertical one-pager (unchanged)
2 LIFE CALENDAR 100 years x 52 weeks = 5,200 boxes — a whole human life on one page
3 THE SAME GRID shaded by the evangelical share of these groups' POPULATION
4 funnel now an UPSIDE-DOWN TRIANGLE with an equation sidebar and a "1 of 28" tip
5..n groups 20 per page with photos AND three prayer tick columns
then the 12 prayer points and the prayer wheel (both unchanged)
★ THE 12 PRAYER POINTS WERE NOT LOST. The Driver believed they had gone; all 12 are intact in
packet-strings.json with his rhythms and sub-titles — praise/Two-Cross Gospel, wait/Holy Spirit &
deliverance, confess, read/the Father's love, ask/baptisms, intercede/laborers & shepherds,
pray_word/Persons & Houses of Peace, thank/rest, sing, meditate/communion, listen/accountability,
praise_end/great-greater-greatest — plus the Mark 5 passage and the Metacamp attribution.
★ PAGES 2 AND 3 ARE ONE GRID READ TWICE, which is the whole idea. 5,200 is the Driver's own arithmetic
(100 x 52), not a layout choice, so both pages derive from it and cannot drift into two grids that merely
look alike. A percentage drawn as a bar is an abstraction; drawn on the grid the reader has just
understood as their own life, 32 shaded boxes of 5,200 is the same fact made unavoidable.
★ AND PAGE 3's NUMBER IS POPULATION-WEIGHTED, because he asked for "% of Christian population vs lost".
That matters: by GROUP COUNT 69.1% of the 2,091 matched DOXA groups have no known evangelicals, but by
POPULATION the evangelical share is 0.61% of 209,079,159 people. The group-count version is a materially
milder picture of the same data. Computed in data/people-groups/christian-share.json, joined IMB
CSV ROP3Code+ISOAlpha3 to DOXA's snapshot — the same join the Vision Card uses — and READ here, never
re-derived, so one fact has one source.
⚠ THE BAND MIDPOINT IS AN ASSUMPTION AND THE PAGE SAYS SO. peoplegroups.org publishes EvangelicalLevel
as a BAND ("Less than 2%", "No Known Evangelicals"), never a number, so any percentage requires choosing
a value inside each band. The file records the method, the page prints it, and 0.61 + 99.39 = 100 exactly
— my first formatter rounded the large side to "99%", which loses the asymmetry that IS the point and
leaves a reader checking the arithmetic to find the page wrong.
⚠ GEOMETRY MEASURED, NOT EYEBALLED: square cells would need 26.5px x 100 rows = 2,654px of grid in
1,521px of printable height, overflowing the sheet 1.7x. 13px rows put 100 rows in 1,498px with 23px
spare. Caught by computing the height before rendering.
⚠ AND THE TIP SAID "1 of 27". listCount spread the three cluster keys and omitted `deaf`, whose two
deaf_lists entries buildLegendNodes collapses into one leaf — the same omission allZones() had before
|
||
|
|
6dd5278584 |
Svalbard carries its own data, not Norway's
iso3-to-iso2 mapped SJM to NO, so every research fetch keyed through it filed Norway's figures under Svalbard. With SJM -> SJ on main, the four fetched tables now hold what the pipeline produces for Svalbard itself, re-fetched for SJ through each script's own code: network-operators 60 Norwegian networks -> none (RIPEstat: 0 ASNs for SJ) platform-reachability 15 rows of Norway's OONI -> 15 not-measured (no SJ probes) social-platforms 8 rows of Norway, 2026-07 -> 4 rows of Svalbard, 2026-09 messaging-channels 3 rows from Norway's store -> none (no SJ storefront) Norway is not added: it is not in the DX country spine these tables are built over, so no build produces Norway rows here. Svalbard's own people group rows elsewhere are untouched, and every other country's rows are unchanged and in order. |
||
|
|
725467a9de |
Country names in the reader's language, for all 242
Our data speaks ISO alpha-3. Intl.DisplayNames('region') takes alpha-2 or
UN M49 and NOT alpha-3, so every country name was stuck in English for want
of the join.
Vendored CLDR cldr-core 48.2.2 (Unicode-3.0, 36KB, sha256 and source URL
recorded in library/vendor/cldr/README.md). No npm dependency: a package
would pull the whole supplemental tree and make a shipped data file depend
on an install step, where a vendored copy lets the generator reproduce the
map offline -- which is the point of vendoring rather than merely having a
copy.
generate-iso3-to-alpha2.mjs joins it against the 242 codes in
countries.json and GRADES ITS OWN OUTPUT, writing nothing on failure: 242
coverage, injectivity, two-letter well-formedness, and that each documented
exception is still needed. KOS -> XK is the only exception -- Kosovo has no
ISO 3166-1 assignment, so no refresh will ever supply it, and XK is the
user-assigned code CLDR itself uses in locale ids. It is recorded with its
reason instead of patched into the vendored bytes.
countryName(iso3, locale) falls back localized name -> canonical English
name -> the code. Intl.DisplayNames THROWS on a missing or malformed code
rather than returning null, so the guard is checked before asking; the
probe demonstrates that throw rather than asserting it in a comment. An
unassigned-but-well-formed region returns the input unchanged, so that case
falls back instead of rendering "DE" as a name.
THE RESEARCH SPINE MAP IS NOT TOUCHED. iso3-to-iso2.json is a different
file with a different job -- the 176-country spine that drives the data
fetches -- and expanding it is a Driver decision, not a side effect of
adding country names. The generator cross-checks it and REPORTS
disagreements without rewriting. There are none: CLDR independently agrees
with all 176 values, including the two corrected this session.
Also fixes a duplicate "check:religion-label-not-value" key in
package.json, left by a merge. JSON keeps the last, so one description was
silently dead and check:doc-scripts could not see it; bun's parser warned.
The surviving entry absorbs the only fact the dead one carried, the two
library sites by name.
|
||
|
|
8f79a3af0f |
Norway gets into the country table at all
NOR was absent from iso3-to-iso2, so Norway appeared in no research table: consumers filter on ISO3_2[iso3], which makes a missing code a silent omission rather than an error. Its absence is also why SJM -> NO read as injective -- "NO" had exactly one claimant, the wrong one. NOR -> NO, from ISO 3166-1, which is what CLDR's codeMappings encodes. The full CLDR vendor still comes with the 242-country table and will re-derive this alongside the other 66 missing codes; this is the one line exp-shepherd needs first to re-key the mislabelled Svalbard rows onto Norway. NOR joins the named spot-check, and the completeness floor rises 175 -> 176 with it. Left at 175 that check would have called a regression back to the pre-NOR map clean, which is the opposite of what it is for. Removing NOR now fires both guards; injectivity still reports "all 175 codes map to a DISTINCT ISO2" while it does, for the third time in this defect. |
||
|
|
1eae1d8c10 |
Svalbard gets its own country code
SJM mapped to NO, which is Norway. Every research fetch keyed to Svalbard and Jan Mayen therefore asked the APIs about Norway. SJM -> SJ. Injectivity could not see this, and that is the part worth keeping: the check passes as long as no two ISO3 codes claim the same ISO2, and NOR is absent from the map entirely, so "NO" had exactly one claimant and read as clean. A uniqueness test cannot tell a right code from a wrong one nobody else wanted. So SJM joins the named spot-check beside PSE and HKG, which were wrong the same way -- mapped to their administrator's code. Reverting the value now names it; before, the verifier still printed "all 175 codes map to a DISTINCT ISO2". The mapping is the half that stops a future fetch being wrong. The rows already fetched are still Norway's, keyed to Svalbard: 60 network operators (Telenor Norge AS, GlobalConnect AS, Marlink AS), 15 platform reachability rows and 8 social-platform rows, in the shipped tables under public/assets/tables/. Those belong to other plugins, so they are routed rather than rewritten here. |
||
|
|
1801bbc20f |
Messaging channels: a taxi and a food app are no longer a country's top messaging app
The classifier matched "line" as a bare word, so "LINE MAN: Food Delivery & more" (Thailand) and "Malé Taxi Line" (Maldives) each ranked as the country's top messaging app, ahead of WhatsApp. LINE now matches only as the whole name or before a colon. The classifier moves to app-kind.mjs so the fetcher and the table builder share it, and the builder re-derives each kind from the app name, so a cached fetch made under an older rule cannot carry its answer forward. The headline count is computed from the rows instead of typed: WhatsApp (Messenger or Business) is top in 73 countries. |
||
|
|
9a955f8f20 |
Web-sized boundary copies: every feature and island kept, a fifth of the bytes
rega measured ~3.6 MB of boundary GeoJSON costing 5-6 s of MapLibre worker parsing before the first territory draws, on every partner page. These copies are for interactive maps; the full files stay for posters, print and build-time point-in-polygon. world-adm0-web.geojson 1,866 KB -> 383 KB (gzip 596 -> 134) 162 features, 572/572 parts gb-IND-ADM1-full-web.geojson 1,796 KB -> 227 KB (gzip 451 -> 69) 36 features, 452/452 parts gb-IND-ADM1-web.geojson 1,628 KB -> 178 KB (gzip 412 -> 55) 34 features, 40/40 parts, listId kept (gb-CHN-ADM1 is 56 KB already: no copy) library/scripts/boundaries/build-web-boundaries.mjs writes them with mapshaper (pinned 0.6.121, via npx; not a repo dependency): -explode, then topology- preserving Visvalingam with keep-shapes, then -dissolve with every other property copied. The explode makes each island its own shape, so keep-shapes protects every one: without it, the world drops from 572 to 240 parts. The run FAILS on a changed feature count, a lost key, a changed property or a dropped part. Negative-tested (without -explode: 297/572 parts -> FAIL), and a second run writes identical bytes. Measured (_qa-cycle/03-deployment-cycle/REPORT-WEB-BOUNDARIES.md): median area error 0.57% (world), 0.04% (India). Of 14,486 real people-group points, 95.9% stay in the same country in the world file and 99.9% in the India file; the misses are archipelago coasts. No runtime code does point-in-polygon on these files, so this is visual only. Not wired: consumers still fetch the full files, and generate-tree does not stage the -web copies yet. Both are in the report's switch table. |
||
|
|
84062744ee |
Zone titles for the 10 zones that can have one, and a null for the 18 that cannot
Driver, 2026-09-27: zone titles must be GENERATED from already-translated parts, because UPG-100 is
becoming a pattern and a hand-translated title is a cost paid again for every project list (7000 / 4700
/ 3000 / 2000 / 700) forever. The Arabic map reads English today because every label is a hand-written
English string.
★ MEASURED BEFORE DESIGNING, and the measurement killed the more attractive half of the idea:
country_code_label from the records API all 175 codes, every locale (formal names)
Intl.DisplayNames({type:'region'}) all 175, every locale THROWS on ISO-3
Intl.DisplayNames({type:'language'}) 213 of 2242 = 9.5% ECHOES THE CODE
LANGUAGE IS OUT. `.of('kfa')` returns the STRING "kfa" rather than throwing, so a miss prints a raw code
onto a printed poster with nothing raising a hand — and the 90.5% miss is structural, not a gap to fill:
unreached peoples speak small languages by definition and CLDR carries the big ones. This module never
names a language.
TIERS, stated in the module because a generator that silently produces a bad title is worse than one
that declines:
TIER A (3) country_code -> one country name. GENERATED
TIER B (7) country_codes -> n names + a joiner. GENERATED
TIER C (18) everything else NULL, caller keeps its label
Tier C cannot be done from countries: the 12 India/China zones are pg_slugs whose every record is ONE
country (measured: asia-india-kerala 78/78 IND), so countries would render "India" twelve times and
could not tell "Malabar Coast" from "Northwest India" — those labels are SUB-NATIONAL, and Intl has no
subdivisions. The curated-table-vs-keep-English choice is the Driver's (repo's digest item D). Returning
null until then means nothing regresses.
⚠ AND I HAD THE SOURCE PRIORITY BACKWARDS, corrected by looking at what the two sources actually say.
I preferred the records' label because it is already localized and needs no code mapping — both true,
and it generated "Lao People's Democratic Republic" for the zone whose hand-written label was "Laos": a
generated title WORSE than the one it replaced. 28 of our 175 labels are formal forms like that
("Islamic Republic of Iran", "People's Republic of China"). Intl gives the short common form in every
locale, so Intl is now first and the records are the fallback. The ISO-3→ISO-2 mapper is INJECTED, not
imported, because regions-plugin owns that table (their card) and it has not landed: without it a
3-letter code falls through to the formal name, with it the titles read "Iran + Yemen + 11 more".
THE ONLY NEW TRANSLATED STRINGS ARE TWO: zone_join "{a} + {b}" and zone_join_more "{a} + {n} more", one
pair per locale block in packet-strings.json (22 keys across 11 blocks). That is the entire translation
cost for every project list that ever uses this — a new language stays a NEW BLOCK, never a code change.
VERIFIED against the real 28 zones in en AND ar, with live Arabic records from the API:
Laos -> لاوس | Middle East Core -> إيران + اليمن + 11 أخرى | Maghreb + Egypt -> الجزائر + ليبيا + 5 أخرى
9 generated, 18 declined, in both locales. Every decline path returns null rather than a code or a
partial title, including a country_codes zone where one of the names it would SHOW is missing — a title
naming two of three countries and dropping the third silently misinforms and cannot be told apart from a
correct one downstream.
NOT WIRED IN YET: this is the generator only. The caller change (legend + poster + packet header) and
the deaf zone — global by construction, which breaks the geography-first rule and which the Driver says
is unsolved — are the next steps on the pattern card.
|
||
|
|
3ef164c014 |
The deaf zone could not be generated at all, and five more resolved to nothing
check:packets-all reported "zones covered : 2 of 28", which reads as twenty-six nobody had got round to. Six of those twenty-six could not be generated by ANY command, for two causes, and the report could not tell the difference between a gap you close by working harder and a gap you cannot close at all. 1. `deaf` WAS UNREACHABLE BY CONSTRUCTION. allZones() looped three keys of clusters.json — lists (13) + asia_lists (12) + uncovered_lists (2) = 27 — and the file carries zones in a FOURTH, deaf_lists (2), which legend-tree.js collapses into ONE unified leaf (affinity bloc A017). legend-tree.js:106 states the real arithmetic: "13 + 12 + 2 + 1 deaf = 28". So `deaf` was a poster and a legend row with no obtainable packet: ONLY=deaf exited 1, "no zone matches". Fixed by enumerating through leafZones(buildLegendNodes(...)) — the SAME seam the poster generator and upg-100-list.vue use, so all three now agree on what a zone is. Leaves do not carry selectors, so they are re-attached by id exactly as generate.mjs:2801-2808 does it. 2. THE FIELD LIST WAS PART OF THE SELECTOR CONTRACT AND NOBODY HAD SAID SO. The records query asked for country_code and no other selector field, while selector-evaluator reads three more: rop1 (affinity_bloc -> deaf), wagf_block (west/east/central-africa) and wagf_region (europe, and the southern-africa-islands union). Those six zones evaluated to an EMPTY set for every record and were skipped as "no groups resolved" — which reads as missing DATA and was missing FIELDS. The tell: the records cache holds 123 groups with "Deaf" in the name and evaluate() still returned 0 slugs. Verified both directions against the evaluator — 0 slugs when a record lacks the field, the correct slug (and only that one) when it carries it, for all six selectors. The list is now DERIVED from presentation fields + the evaluator's own requirements, so a new selector type is a change in one place rather than a superset someone must remember to widen. 3. OFFLINE=1 WAS DOCUMENTED AT THE TOP OF THIS FILE AND COULD NEVER HAVE WORKED. loadRecords() has an OFFLINE branch that needs no API, but the fail-closed guard ran first and exited 1 without consulting it. The note above that guard records it being taught about PACKET_RECORDS for this exact reason; OFFLINE was never added. Worse than an undocumented flag: it sends a reader down a route the program refuses. Any env var that makes the API unnecessary is now named at the guard. MEASURED, not assumed: an ordinary zone still builds identically through the new enumeration (Tibet, 43 groups / 7 pages, check-packet-render PASS with --expect-funnel), all 28 zones now carry a selector, and OFFLINE=1 generated 22 of them from the cache. The other six need a records source carrying wagf_block/wagf_region/rop1; the cache predates the fixed field list, so their render is still unverified and the gate now says so by name rather than by count. |
||
|
|
0eae411edc |
The funnel stops claiming a coverage gap it cannot measure
I PUT A CONFIDENT WRONG NUMBER ON THE PAGE AND AM TAKING IT OFF. The footnote read "21 of the 2,107 are not yet in any list". That 21 is the total minus the sum of the DECLARED `count` fields in clusters.json — a claim about that file's bookkeeping, not about membership. I wrote it, asserted nothing about it, and shipped it twice. TRYING TO VERIFY IT PRODUCED A SECOND WRONG NUMBER, which is what settled the question. Resolving every selector against the packet records gives 418 in no geographic list, not 21. But that cache carries only TWELVE fields — no wagf_region, no wagf_block — so 13 of the 27 selectors cannot resolve against it at all, and deaf_predicate matches 0 of the 123 deaf records that ARE in it. Both figures describe the FIXTURE, not the product. Nothing on disk can answer this. AND THE QUESTION IS ILL-POSED BESIDES. clusters.json has FOUR buckets, and allLists() reads three: the two DEAF lists (61 + 62 groups, with their own poster) are excluded. Summing all 29 gives 2,209 against a source total of 2,106, so the deaf lists OVERLAP the geographic ones — a deaf group in West Africa is in both. They are a second, cross-cutting partition, organised by disability rather than geography, which is very likely WHY allLists() leaves them out: including them in a zone-to-list map would double-count. So "in no list" has no single answer until someone says which partition is meant. funnelPage already falls back to the provenance-only footnote when `unassigned` is absent, so removing it is the whole fix: the page now says "Counts from IMB / peoplegroups.org, captured 2026-07-16." and stops there. The share line is untouched — "1 of 27 lists · 2% of the 2,107" is provable. BOTH PATHS CHANGED TOGETHER. The Node generator computed the same figure; leaving it would have made the two packets disagree about page 2. CARDED FOR THE DRIVER, because two of these are product questions and not mine: whether a packet counts 27 geographic lists or 29 including the deaf ones, and whether "Central Asia + Pakistan" is a prayer list rather than a residual bucket. It has 55 groups, contiguous=true with contiguityExempt=false, a country-code selector, and the poster wall already files it under "Central Asia" — it is larger than four accepted lists (Europe 55, Oceania 53, SE Asia 50, Southern Africa 39) and reads exactly like them. Its neighbour uncovered-scatter is the genuine catch-all: 8 groups, contiguous=FALSE with contiguityExempt=TRUE, and that exemption flag is the tell. So "27 or 25" was a false binary and 26 is live. Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and the script check; the footnote no longer contains "not yet in any list"; the share line is unchanged. ⚠ NO BUNDLE REBUILD IN THIS COMMIT — dx-shepherd's shot window forbids public/ churn while rega reshoots the README screenshots. This is source only; whoever builds after the all-clear produces the matching artefact. |
||
|
|
6d6b656721 |
A packet says which list it holds, in the reader's language
TWO FINDINGS FROM rega'S ?list= RENDER, and the first one is a hazard I created in the same commit where I argued against it. I MADE PACKET_LIST FAIL CLOSED because "a packet that showed the wrong list is printed and handed to someone" — and then wrote the frontier packet to the SAME FILENAME as the default-list packet of that zone and language. rega measured the result: out/packets/ held frontier packets under the DEFAULT names, beside an `ar` file that was still the default list, and nothing in the name could tell them apart. Guarding the input while leaving the output ambiguous protects against asking for the wrong list, not against being unable to tell what you got. Fixed both ways. The list is in the FILENAME (asia-china-tibet-autonomous-region.frontier.hi.html) and STAMPED INTO THE DOCUMENT as data-packet-list, because a filename can be renamed, copied or emailed while the packet itself should be able to say what it is. Same reasoning as data-packet-build: out/ is gitignored, so a sweep grades whatever files happen to be there, and a packet that cannot describe its own provenance makes an accidental set indistinguishable from a current one. The gate prints it too. THE LIST'S LABEL WAS ENGLISH IN EVERY NON-ENGLISH PACKET. "Project 4700: frontier people groups" sat among Devanagari labels in a Latin serif face, because it came verbatim from library/lists/lists.json — which carries ONE label per list and has no locale dimension at all. The gate passed because it counts rows, not the row's language. It is translated in packet-strings.json rather than by restructuring the shared lists.json: that file is the packet's OWN catalogue, where a new language is a new block and never a code change, and it degrades per key like every other string. An unknown list still falls back to the data's English label. THE NUMERIC DESIGNATION STAYS — "Project 4700" / "مشروع 4700" / "4700 计划" — because it names the campaign, while "frontier people groups" is prose. That is the same line check-i18n-never-translated draws around "UUPGs": translate what a bilingual reader would not dispute, keep what is an identity. rega'S MEASUREMENTS, all confirming predictions I could only compute: 6 pages, 37 of 43 records, rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37, bars 1080/640/415/184/152/26 identical in en and hi, share "2.1% of the 1,745" with no "1 of 27 lists", provenance-only footnote, fail-closed rc=1 on an unknown list. And the number I most wanted: share-to-footnote clearance 314px en, 222px hi — so hi+frontier, the tightest combination that exists (128px rows, gap down to 147px), still clears by 222px and no label wraps. Verified: all 18 packets in out/ pass sheets, rows==bars and zero-population. The 11 laos packets FAILED the zero-population assertion until regenerated — they predated the fix, which is the gate distinguishing stale artefacts from current ones exactly as data-packet-build was added to do. Default path unchanged (43 groups, 7 sheets, 5 rows). frontier renders 6/6 in en, ar and hi with the label in each script. check:packet-strings-parity PASS with the 5 new keys. |
||
|
|
75e31e0126 |
The six-row funnel can finally be rendered
ITS ONLY WITNESS WAS MY OWN HARNESS. The browser path honours the universal
`list` prop — upg-100-list.vue calls applyPeopleGroupList() at every fetch site —
but library/scripts/posters/packet.mjs had NO list support at all. So the
non-default-list branch of funnelPage, including the six-row shape where a list's
own total is inserted before the reader's row, could not be produced by the only
pipeline rega can drive. I had unit-tested it through a script I wrote about it,
which is exactly the state I have been objecting to in other people's gates.
rega offered to shoot a ?list= packet and there was no command to give them.
PACKET_LIST=<id> closes that. Rendered for the first time, and it is right:
rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37
bars 1080 / 640 / 415 / 184 / 152 / 26 (six rows, six bars)
label "Project 4700: frontier people groups" between the network's list and
the reader's 37
share "This packet is 2.1% of the 1,745" (no false "1 of 27 lists")
foot provenance only, no coverage claim
row gap auto-adjusted 218 -> 147px for the extra row; last row stays 14px so
the share still reads as its conclusion.
IT READS THE MEMBERSHIP FILE FROM DISK, not through loadListMembership(), which
fetches from the asset pool over HTTP — there is no server in a Node build. Same
file, same slugs, same counts.doxa.
AND IT FAILS CLOSED: an unknown or empty list exits non-zero rather than quietly
producing a packet of every group. peopleGroupList.js makes the same choice for
the same reason ("a map that silently showed all 2,107 while an embed asked for
Project 700 would be showing the wrong list with no sign of it"), and a packet is
worse than a map — it is printed and handed to someone.
⚠ buildFunnel() IS NOW A FUNCTION, NOT A CONSTANT, because it depends on a list
resolved after the records load. It mirrors upg-100-list.vue's packetFunnel()
exactly: the default list is the only one the zone catalogue describes, so it is
the only one that may claim "1 of K lists" and an unassigned count.
── AND A CORRECTION TO MY OWN VERIFICATION ─────────────────────────────────
"Numerals untouched" is about DIGITS, not GROUPING, and I verified the digits and
assumed the rest. rega measured the rendered hi sheet: Indian grouping,
"8,28,85,63,969 लोग" (lakh/crore 2-2-3) rather than Western 3-3-3. That is
CORRECT hi-IN formatting, so changing it would be worse — the real risk was never
the grouping but INCONSISTENCY, the same number printed two ways in one document.
Measured and it is consistent: num() is the only formatter in the renderer, so the
funnel's populations and the card pages' populations group identically — confirmed
against the rendered hi packet (card populations 1,70,000 / 3,37,000 / 8,10,000,
Indian-grouped like the funnel's). Only values at or above 100,000 can distinguish
the systems at all; my first check classified "1,400" and read a mismatch that did
not exist.
Verified: default path byte-identical (43 groups, 7 pages, 5 rows, 5 bars); all
four fixtures PASS with rows==bars; PACKET_LIST=frontier gives 37 of 43 records
and 6 pages; check:packet-strings-parity PASS; vite build clean, bundle unchanged
(the change is Node-side).
|
||
|
|
127a2e0930 |
The funnel states the list it was actually drawn from
A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (
|
||
|
|
9d56943fae |
A complete India ADM1 (36 states) is staged beside UPG-100's 34-unit partition
The served gb-IND-ADM1.geojson is the 100-list's curated partition (Mizoram and Nagaland excluded, listId-tagged), so a map colouring every state had data for 36 and shapes for 34. data/boundaries/ gb-IND-ADM1-full.geojson is all 36 from geoBoundaries gbOpen (boundaryID IND-ADM1-1811400, DataMeet / Election Commission of India, CC BY 2.5 IN), with source and licence in gb-IND-ADM1-full.meta.json; generate-tree stages both, and the dev server serves data/ directly. Reproducible with library/scripts/boundaries/source-full-adm1.mjs (coordinates rounded to 3 decimals: 1.84 MB). The curated file is untouched. |
||
|
|
e3d25ff6db |
The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
|
||
|
|
65017fefa4 |
The prayer packet shows how its list was narrowed out of all the earth
Page 2 of every packet is now the funnel: 12,379 people groups on earth, 7,341
unreached, 4,754 frontier, 2,107 on this prayer network's list, and N of those
in the reader's hand. A partner holding one list can see where it sits in the
whole, which is the argument the packet is making.
★ IT RENDERS NUMBERS IT DID NOT COMPUTE. Every count is read from
data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates
from the IMB CSV and the live network list and which already drives the Vision
Card. Re-deriving them in the packet would give one fact two sources; they would
drift, and two internally-consistent surfaces disagreeing is worse than one
wrong number because a reader cannot tell which to trust.
K AND THE SHARE ARE MEASURED AT RENDER TIME, NEVER TYPED, and this mattered:
the brief said "~30 lists of 50-100" and the catalogue says K=27 with counts
from 8 to 118 (west-africa 118, asia-india-rajasthan 116, uncovered-scatter 8).
Only about half sit in that band, so no copy claims it. The page states what the
data can defend -- "1 of 27 lists" and "N of the 2,107" -- and a footnote states
plainly that 21 of the 2,107 are in no list, because a partner adding up rows
would find the gap anyway. Reported to the Driver; K=27 pending his ruling on
whether the two residual buckets count.
A SHARE MUST NEVER ROUND TO ZERO. One decimal gave the single-group fixture
"0.0%", which reads as "this packet covers none of them". Verified exhaustively
over all 2,108 possible list sizes against 2,107: no size prints 0 and none is
malformed. 1 group -> 0.05%, 20 -> 0.95%, 21 -> 1%, 43 -> 2%, 118 -> 5.6%.
NUMERALS STAY WESTERN, as required. num() is toLocaleString(lang), and CLDR's
default numbering system for the bare ar/hi tags is latn -- verified 12,379 and
2,107 in both. Reusing num() rather than writing a second formatter is also what
keeps the funnel's digits identical to every other page of the packet.
PAGE COUNT NOW COUNTS THE ARTIFACT. packet.mjs computed
`1 + ceil(n/PER_PAGE) + 2` under a comment claiming the number was "asked of the
renderer, not recomputed here". It WAS recomputed, and the funnel page silently
made it wrong by one -- exactly the drift the comment warned of. It now counts
the emitted sheets.
THE GATE IS TAUGHT, NOT LOOSENED. check-packet-render went red on every fixture
("43 cards should lay out as 6 sheets, but the packet has 7") -- correct
behaviour on a structure it had not been told about. The wrong fix is to accept
N or N+1: that assertion exists to catch silently dropped people groups, and a
±1 tolerance is the slack a lost list page hides in. So the funnel is DETECTED
via .funnel-title and added as a known term, and a funnel page that renders its
title but no share figure is now its own failure -- a number printed but not
asserted on is decoration.
The page is OPTIONAL: buildPacket omits it when no funnel data was supplied, so
every existing caller keeps working and a host without the data still gets a
correct packet. Better a 4-section packet than one asserting figures it could
not read. A multi-list batch selection keeps the steps but drops "1 of 27",
which is false for a custom selection, while "N of the 2,107" stays exactly true.
Verified: check:packet-strings-parity PASS (31 string keys x 11 locales, my 10
new ones present and translated in all 10 non-English blocks, 0 declared-absent
additions). A partial locale with 3 of 10 funnel keys translated renders those 3
translated and the other 7 in English, nothing blank. Arabic packet generated:
dir="rtl", funnel is sheet 2 of 7, all four steps carry Western digits. All 16
generated packets satisfy the corrected sheet arithmetic, including the 11 older
laos packets that have no funnel page. Mutation-tested: dropping a list sheet is
caught by the independent page-header witness (20 DROPPED), a funnel page with no
share figure is caught by the new assertion, and removing the funnel page passes.
BUNDLE=my-upg-100-list vite build 319 modules clean.
NOT verified in a browser: check:packet-render launches Chromium, which the
Playwright policy reserves to rega. The simulations above parse the same
attributes from the same generated HTML.
|
||
|
|
79bd6d2ac3 |
public/ is one folder: the output tree plus a README, with the Plugin Studio as its front page
Driver decisions (cards BUILD-OUTPUT-IS-ONE-FOLDER, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE).
- public/ holds only <tree>/ and a generated README.md. The build no longer writes public/index.html,
public/assets/ or public/__gallery/; `rm -rf public && bun run build` recreates exactly these two.
- The Plugin Studio is <tree>/index.html (tree-base "."), replacing the staging-forge chooser; nothing
is written above the tree. check:staging-lang, which tested that chooser, is retired with it.
- Assets are gathered in a temp directory and the fetched allowlist staged into <tree>/assets/ as
before. fonts/, images/, brand/ and legacy/ existed ONLY in public/assets: they are sources and move
to data/ (git mv). Poster previews keep their tracked sources in plugin-designs/*/assets/previews
(all 378 verified present).
- __gallery/ is archived out of public/ (a copy kept in the umbrella's _qa-cycle/archive) and out of
the build; generate-gallery renders only on request, into a directory you name, never public/.
- Native shells load the output folder: capacitor webDir public/<tree> (cap copy needs an index.html
there), Tauri frontendDist + window url relative to it. internal/sync-app-shells.mjs keeps both in
step with the tree's name on every build, so the coming rename stays one config line; the Capacitor
start page resolves its leaf tree-relative.
- dropin.include now lists only extras (README.md): the tree is always shipped and never named twice;
excludes are tree-relative ({tree}/...). clean.mjs removes the old root entries from existing
checkouts; the build lock and public/.gitignore's rules move out of public/.
- check:wp-embed read the BUILT manifest from gates:pre, before the build, so a from-scratch build
could never pass; it runs in gates:post and reads the tree name from config.
- Stale bundles refreshed by the clean build (key-cities, template-bundle, wall-of-people-groups);
the CLI doc's quoted scripts re-synced; capability exemplars regenerated; the '#' note leaves
check:stale-sweep-claim's command (it was already in //scripts).
Proof: rm -rf public && bun run build -> public/ = README.md + doxa-maps/; check:sri, check:drop,
check:parse, check:public-hygiene, check:quoted-scripts, check:capability-exemplars PASS; all 57 pages
(the front page + 56 leaves) mount under /doxa-maps/ on this tree. check:facet-cascade is red as on main.
|
||
|
|
412602cdef |
Five more gate scripts read the tree's name from the build config
The research verify-* scripts and research-map-plus-grid's two gates hardcoded 'doxa-maps' (11 literals); they now import TREE_NAME from internal/_tree-name.mjs like the rest of the tooling. |
||
|
|
7f700935c5 |
Simple Research + Big Picture: the host's style stops at the box, and the last English goes
TWO FIXES THAT BOTH SHOW UP ONLY WHEN THESE PLUGINS ARE SOMEBODY ELSE'S GUEST. 1. ATTRIBUTE FALLTHROUGH. These SFCs mount AS custom elements, so a style the host puts on the tag lands on the component's own root element. MEASURED with a host style of "display:flex; position:relative; height:100%": that exact string appeared verbatim on the inner root div of people-groups-grid, stack-demo, research-map-plus-grid and dashboard-plus-grid. The gallery sets one on every tab, and the same fallthrough collapsed the Prayer Walk Planner to 76px. inheritAttrs:false on each root — a host styling the TAG is styling the box, not our internals. AFTER: the inner root carries no inline style on any of the four, and each still renders full height (2997-4010px in the harness), so the guard did not become the collapse it prevents. Worth knowing for the next probe: shadowRoot.firstElementChild is a <style> node — Vue injects styles first — so a probe that trusts it measures nothing and reports "no fallthrough, nothing to fix". Take the first non-style child. 2. THE RESIDUAL CHROME the in-gallery re-measure found: the dashboard title, Align Dashboard and its tooltip, World, Vertical Legend, groups in scope, the per-row aria templates (Filter by / Remove filter / groups), and the map drawer's aria label. Catalogued in all 11 locales and read through tStatic, reusing existing wording where the catalogue already had the word. The aria TEMPLATES matter more than their size suggests: they repeat per row, so one untranslated template is dozens of English strings in a screen-reader's ear. |
||
|
|
33bd7ca387 |
All People Groups loads fast on a light map; the Vision Card becomes a true funnel from data
ALL PEOPLE GROUPS. The Driver: "not even loading, takes a very long
time", and its DOXA pins were dark on a dark map.
- It downloaded the 7.5 MB people-groups-slim.json (22 columns) to draw
ten fields. It now fetches people-groups-points.json: the same rows in
column order, repeated strings in dictionaries, coordinates at 3
decimals. 0.74 MB, generated deterministically by
library/scripts/people-groups/build-points.mjs and staged into the tree
by generate-tree. It falls back to the old file on an older tree.
- Pins no longer wait for the live prayer API. Every pin draws by GSEC
from the file, and the DOXA overlay recolours when the API answers. The
DOXA count reads "…" until then, not 0.
- Named style 'paper' (Mapbox light-v11 / MapLibre positron) instead of
'ink'. The four categories keep their meaning; yellow and green are a
step darker, and pins get a thin dark ring, so they read on near-white.
- Its words are in all 11 locales (allPeopleGroups.json), with a guarded
useI18n and values passed to vue-i18n so {n} is not blanked.
Measured on a served built tree, 3 fresh runs each, median time to first
rendered pins: 8.3 s before, 5.2 s after, on localhost. This box renders
GL in software, so most of the remaining time is map start-up; the 10x
smaller download matters more on a real connection.
DOXA VISION CARD. The Driver: "the data doesn't make sense … super false
information". The card rotated four typed-in figures with no source.
It is now a proportional funnel, every number computed from data by
library/scripts/people-groups/build-vision-funnel.mjs:
12,379 people groups (IMB rows: one group in one country)
7,341 unreached (IMB LostnessPriority 0-2, < 2% evangelical)
4,754 frontier (LostnessPriority 0, < 0.1% evangelical)
2,107 DOXA groups (the DOXA prayer list, snapshot saved in data/;
1,745 of them frontier)
The first three nest; DOXA groups do not all sit inside frontier, so the
card says how many do. The rules are on the card ("How these are
counted") and in HOW-THE-NUMBERS-ARE-COUNTED.md, which also explains why
the total is not the 16-17 thousand of Joshua Project's count. Material
light, all 11 locales, right-to-left in Arabic, sources and dates shown.
The old fake stats are removed from the instances file, the frontier
page, the manifest, the staging page and the gallery.
CHECKS.
- check:vision-card-numbers recomputes the funnel independently (own CSV
reader, the published rules) and compares with vision-funnel.json and,
with --url, the rendered bars. Negative-tested: a corrupted count exits
1. On the built tree: bars 12,379 / 7,341 / 4,754 / 2,107 and "1,745 of
them are frontier", all matching.
- check:map-embed-cells measures PRODUCTION-MATRIX cells B and C for the
seven gallery maps on a foreign origin; C proves the host's token is
used by handing the host a distinct token and checking every Mapbox
request. 14 of 14 pass.
|
||
|
|
51a3abe482 |
packets: render the MIXED photo case, and assert the fallback says something
Some checks are pending
build-gate / build (push) Waiting to run
Every packet ever produced was all-photo (laos: 95 photos, 0 typographic) or
all-typographic (every fixture: 0 photos, N typographic). THE MIXED CASE HAD
NEVER BEEN RENDERED — and it is the realistic one. fetchPhotos already degrades
per-photo on a failed fetch, so any zone where a few images 404 puts exactly
that layout in front of a reader.
PHOTOS_EVERY=N keeps only every Nth photo, deterministic by INDEX rather than
random so the same command always yields the same packet — a fixture that
differs run to run cannot be a gate. The build stamp gains `mixedphotos`,
because a deliberately mixed packet and a partly-failed photo fetch deserve
opposite verdicts and nothing could tell them apart.
★ IT IS ALSO AN I18N SURFACE NOBODY HAD SEEN. The fallback card renders
t('no_photo'), translated in all 11 catalogs, and the gate COUNTED .nophoto
elements without ever looking inside one. A blank grey box passed every
assertion: `photos + typographic === cards` held, the page count held, nothing
was broken. Counting proves the element exists; it says nothing about whether
the reader sees a label or a hole.
MEASURED on the first mixed packet (PHOTOS_EVERY=3 over laos, 31 photos and 64
typographic): all 64 read "no photo available", 0 blank, all 64 with a real box.
In Spanish "sin fotografía disponible", in Arabic "لا توجد صورة متاحة" — 63 and
64 cards, 0 blank in both.
MUTATION-TESTED: emptying the fallback to `<div class="nophoto"></div>` fails
the gate with "63 of 63 typographic card(s) render an EMPTY box", exit 1.
Restored, and check:packets-all stays green across all five shapes.
One trap worth recording: the language variable is PACKET_LANG, not LANG. The
file warns about it — process.env.LANG is the shell's POSIX locale
("en_US.UTF-8") — and I still lost a run to it, regenerating three full-photo
packets that proved nothing.
|
||
|
|
2f96f04b49 |
packets: fixtures at the PAGE BOUNDARY, which nothing had ever rendered
Some checks are pending
build-gate / build (push) Waiting to run
check:packets-all reported "every generated packet renders, in every locale present" and passed on 11 packets. All 11 were the SAME ZONE (laos) in 11 locales. It proved one zone's layout eleven times. The answer is not more zones -- 28 zones x 11 locales is a network fetch per zone and ~100MB of artefacts. It is more SHAPES, chosen where the layout can actually break, built from committed records with no network. ★ THE SHAPE THAT MATTERS IS THE PAGE BOUNDARY. packet-render.js:76 sets PER_PAGE = 20 and paginates `for (start = 0; start < groups.length; start += PER_PAGE)`. laos has 95 groups and tibet 43 -- both mid-range remainder cases, so the boundary ITSELF had never been rendered. Three fixtures, each a slice of the committed tibet records, so they are byte-deterministic and need no API: exactly 20 -- one full page; an off-by-one yields a second, empty one 21 -- two pages, the second holding a single card 1 -- the minimum a zone can have MUTATION-TESTED, and this is the argument for the whole change. Flipping the page loop to `start <= groups.length` -- an off-by-one that only shows when the count is a multiple of PER_PAGE: fixture-edge-exactly-20 FAIL <- the only one of 15 fixture-edge-21-groups pass fixture-edge-one-group pass fixture-tibet pass laos x 11 locales pass A real pagination bug would have shipped completely green under the old suite. The failure is specific too: "20 people cards should lay out as 4 sheets but the packet has 5" and "list pages disagree on the zone total: 20 vs 21". ⚠ EACH BUILD OVERWROTE THE LAST, which is why this is a script and not a line of shell. packet.mjs names its output for the ZONE, not the fixture, so four fixture builds against one zone leave ONE file -- and check:packets-all would go on seeing a single shape while reporting success. Each output is renamed to its fixture's own name so all of them coexist and every one is evaluated. packet:ci now builds all four. check:packets-all goes from 11 packets in 1 zone to 15 across 5 shapes; it still reports "5 of 28" and names what it does not cover. |
||
|
|
3fd1fbcaea |
Move the private field-access dataset out of the repo
data-private/ was a TRACKED folder, so field-access-visas.json would ship with the repo the moment it goes public. A tracked folder is not private. The table generator no longer writes inside the checkout: private tables go to DOXA_PRIVATE_DIR (a folder outside the repo) or are skipped. data-private/ is gitignored so it cannot be re-created by accident. The file is removed from HEAD only; it is still in history (5 commits). Publish from fresh history or scrub it before the repo goes public. |
||
|
|
3c17245c73 |
packets: give CI something to check, instead of a sweep that always SKIPs
Some checks are pending
build-gate / build (push) Waiting to run
check:packets-all is in gates:post. CI runs it on every build. It has SKIPped every single time, because library/scripts/posters/out/ is gitignored — both the records cache and every generated packet — so a clean checkout has none. Every packet assertion in this tree has therefore never once been evaluated there: the 12 prayer points, the wheel's 12 labels, page 1's geography paths, the print layer, the build stamp. Several ticks of work accumulating on a gate that had only ever graded files in my own working tree. A SKIP that is always taken is indistinguishable from an unwired gate — and the wave-membership census cannot tell them apart, because membership measures invocation, not evaluation. That census is how check:packets-all was created in the first place, which makes this the same class recurring one level up. I raised this at the end of the last tick and deferred it on the premise that closing it "adds a live-API call to every build". The premise was wrong. packet.mjs already had an OFFLINE mode, and a fixture needs no network at all. The fixture is the smallest zone — Tibet, 43 slugs, 20KB — with image_url stripped so CI can never fetch 43 photos from someone else's CDN. It is DERIVED, not hand-maintained: membership comes from the committed clusters.json, so check:packet-fixture regenerates and content-compares it. Re-adding an image_url fails; dropping a record fails naming the slug. A fixture packet stamps itself "fixture", and "fixture nomap" in CI, which has no poster sources either. `fixture` implies nophotos in the gate — without that it reported "every source record carries an image_url", a true sentence about real records and a false one about this packet. A real bug in my own change, caught only by running the script through its REAL invocation path: packet.mjs fails closed on a missing VITE_API_BASE_URL BEFORE loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a tree that had everything it needed. Every hand-test had passed because I was supplying a dummy host. The guard now exempts fixture builds and still fails closed otherwise. From a genuinely clean state — the whole out/ directory moved away — the sweep now reports "1 packet(s) checked, 1 passed". i18n verified through that same path: a partial block (1 string, 5/12 points, 3/12 rhythms) renders 12 points, 12/12 wheel labels, wheel 3 translated and 9 English, 0 blanks. check:packet-fixture joins gates:post (34 -> 35), README quote re-synced in the same commit. Wave 35/35, packets-all 11/11 here and 2/2 in the studio. |
||
|
|
64fed43449 |
packet: stamp how a packet was built, into the packet
Some checks are pending
build-gate / build (push) Waiting to run
out/packets/ is gitignored, so check:packets-all grades whatever .html files happen to be sitting there — an accidental set, not a current one. On this machine that was 11 freshly generated packets. In doxa-maps-plugin-studio, which shares main with this worktree, it was two NOPHOTOS=1 layout probes of mine from earlier ticks, and the sweep was RED there for anyone who ran the wave. The gate was honest about each file it checked; the set it checked was accidental. The underlying problem is that a fast layout-iteration build and a shippable one were byte-indistinguishable. The packet carried lang and dir and nothing about its own provenance, so the only way to tell them apart was for the person running the gate to remember and pass PACKET_EXPECT_PHOTOS=0. packet.mjs now stamps data-packet-build with the flags it generated under, and the gate reads it. The GENERATOR writes it rather than the gate inferring it: had the gate guessed "no photos present, so probably a probe", it would have excused genuine photo-fetch failures. Tested explicitly — a packet stamped "full" whose map svg is then stripped STILL FAILS. The stamp records intent and cannot excuse a defect the build did not declare. Deliberately not a timestamp. An mtime staleness rule was considered and rejected on evidence from this same session: packet-strings.json was 303s newer than the packets while byte-identical to HEAD, because a probe had restored it from backup, so an mtime rule would have failed a correct tree — the exact mistake fixed two commits ago in the library-index gate. Content comparison is not available here (regenerating hits a live API and takes minutes), so the build records its intent instead of the clock. The studio sweep goes from "2 checked, 0 passed" to "1 checked, 1 passed", with the packet self-declaring build="nophotos nomap". Env vars stay as an override for pre-stamp artefacts, and the declared-deliberate note now names whichever source actually declared it rather than always crediting the env var. All 11 locales stamp "full". i18n re-verified with a partial block (1 string, 3/12 points, 6/12 rhythms): wheel 6 translated and 6 English, 12 point rows with 6 translated, 0 blanks. Wave 34/34, packets-all 11/11 here and 1/1 in the studio. |
||
|
|
e130cb672a |
packet generator: say when page 1 has no map, instead of printing a tick
Some checks are pending
build-gate / build (push) Waiting to run
`✓ laos 95 groups 8 pages 0 photos` — for a packet whose first page is blank. The page count stayed 8 because a blank sheet is still a sheet. The map one-pager is lifted from out/posters/<zone>.html, which a separate manual step writes. When it is absent packet.mjs deliberately still builds, and that fallback is right — but it was silent. The empty-groups branch three lines above already warns and skips; the map branch had the same shape and no warning. A gate catching this is not the same as the tool reporting it. The gate added last tick runs later, optionally, and only when someone invokes it. The generator prints its tick at the moment of production, which is the one output an operator is guaranteed to see. Now it prints ⚠ with the reason inline and repeats it in an end-of-run summary naming every affected zone and the fix — a per-line mark scrolls off in a 27-zone run, and this is the one fault that cannot be seen by counting pages. Both silent branches are covered, distinctly: a missing poster reports "no out/posters/laos.html"; a poster that exists but carries no <svg> — a truncated generate run — reports "out/posters/laos.html has no <svg>". An operator can tell "never generated" from "generation broke". Also adds the `packets` script. Its absence is why the posters -> packets dependency was never expressible anywhere: packet generation was only ever a hand-typed node invocation, so nothing could state what it reads. Measured in both repos. doxa-maps-plugin-studio has 0 generated posters against wt-packet's 28 — out/ is gitignored in both, so posters are build artifacts and this is a missing build step, not a repo defect. A fresh studio run now warns; wt-packet prints a clean tick. The size difference corroborates: 0.4 MB with the map, 0.1 MB without. i18n re-verified end to end since this touches the generator: a partial locale block (2 strings, 3/12 points, 4/12 rhythms) renders the wheel 4 translated and 8 English, 12 point rows with 4 translated, 0 blanks, clean tick. All 11 locales regenerated with photos — packets-all 11/11, each 12/12 wheel labels, 12/12 points, 13 geography paths. Wave 34/34. |
||
|
|
e8ad1d56e6 |
packet: a partial translation renders 12 prayer points, not 3
Some checks are pending
build-gate / build (push) Waiting to run
packet-render.js resolved the prayer points with `L.points || EN.points`. That is a whole-block fallback when the key is ABSENT, and TRUNCATING when a translator has done some of the 12 — a 3-element array is truthy. Measured at the rendered layer with a deliberately partial locale block: a page headed "12 Prayer Points" containing THREE, points 4..12 silently gone, including the Mark 5:1-20 passage point 4 prints in full. Nothing blank, no error raised. Invisible in all 11 shipping locales because every one of them is complete. It would have appeared on the next language added — and "a new language is a NEW BLOCK, never a code change" only holds if a PARTIAL block degrades per key, since half-finished is the normal state of a language in progress. Now merges per point and per field against English: English fixes the LENGTH and supplies every missing field, so a point translated for `text` but not `sub` keeps the English `sub` — and keeps its NUMBER, which the merge supplies. The sheet-sum invariant could not see this: a 3-point page is still exactly one sheet, so every number check-packet-render printed stayed correct while nine points were missing. It now measures and asserts the count. Mutation-tested — reverting the fix prints "prayer points : 2 / 12" and fails. All 11 locales regenerated with photos and swept: check:packets-all 11/11, each 12/12 points, including RTL ar and Devanagari hi. Renderer, generator and gate synced to doxa-maps-plugin-studio, which also lacked the .points-title and .wheel-title test hooks the new assertion needs. Also wires check:capability-exemplars into gates:post (33 -> 34 steps). It reads public/doxa-maps/manifest.json, a build artifact, so ordering mattered: `tree` writes the manifest and is &&-chained immediately before gates:post, and the gate SKIPs rather than fails when the manifest is absent. Full wave: 34/34. The quoted gates:post line in the README is re-synced in the same commit, because check:quoted-scripts goes red on the drift this commit creates. |