62 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
af22670dfd |
Offline packets take page 1's map from the tracked posters on a fresh checkout
packet.mjs looked for page 1's map sheet only in library/scripts/posters/out/posters/, which exists only after "bun run posters". On a fresh checkout every offline packet printed the missing-map notice, and check:packets-all failed each fixture packet with map=NO. The same 28 zone poster sources are tracked in data/posters/ and ship with the tree. The generator now looks in out/posters first (a local run's fresh sheets win), then in data/posters. With out/posters absent, all four fixture packets now carry their map. Messages that named the sheet give its real path. |
||
|
|
88991641cf |
The offline packet generator runs again, and a gate now runs it
|
||
|
|
87c59e3a1f |
Name the map's countries on page 1 in the packet's language
The map sheets are generated once, in English, so in an Arabic or Chinese packet page 1's map labelled every country in Latin capitals, and its privacy line was English. The labels are now replaced as the sheet is lifted into the packet. They are recognised by the generator's own rule rather than a list: generate.mjs draws each label as esc(ISO3_NAME[iso3].toUpperCase()) from the zone data's ISO3_NAME table, so inverting that table under that transform names the country behind every label it can draw. The inverse is unambiguous (162 labels, no two countries sharing one), so a label is known exactly or left as drawn. The map's privacy line is word for word the packet's privacy_note, so it takes this language's. A width guard: the generator placed every label and then dropped any that would collide, measuring the English name, so a translation is fitted into that same box - estimated wider, it shrinks in proportion and cannot reach a neighbour the English name did not. On the Sudan poster, six Arabic names shrink (the longest, Saudi Arabia, from 15px to 10px); none of the narrower Chinese ones do. Capitals' tracking is dropped for scripts without capitals, where it breaks Arabic joining. English is untouched: its relabeller is null, and the lifted sheet is byte-identical with and without it. No sheet or poster is regenerated - the change is in the lift, so the tracked 3x4 posters page 1 now uses are relabelled as they are. Both print paths supply the relabeller: the UPG 100 profile from its own ISO3_NAME and the library's country-name resolver, the offline generator from the same table and the injected ISO-3 to ISO-2 table. check-map-labels (gates:pre), on the tracked Sudan poster: English byte-identical; in ar and zh no known English name left, halo matching fill, privacy localized; the guard fires for Arabic, not Chinese, and grows nothing; Arabic tracking off, Spanish kept; an unknown label left alone. Mutation-tested five ways, each caught. |
||
|
|
6af71bce45 |
Name the Deaf packet in its reader's language, not by its internal label
The Deaf zone printed "Deaf (affinity bloc A017)" as its title - on page 1, at the head of every profile page and in the document's own title. That is the legend's internal label: the unified Deaf row is an affinity bloc spanning every region, so there is no country to name it and zone-title.js declined it, leaving the label to print. An affinity-bloc zone has a name of its own even though it has no country. zone-title.js now reads it from the packet strings, keyed by bloc (zone_affinity_A017: "Deaf people groups", in all eleven languages), so another affinity bloc a list adds is named with one string and no code. With no strings it still declines and the label stays - it never invents a name. Both print paths now pass the language's strings, English per key, the same fallback the renderer applies. check-zone-title-tier-a covers the unified Deaf zone, which it never saw (it enumerates clusters.json entries, where Deaf is two older lists): the translated name in en/es/ar, never internal text, and a decline without strings. It now tests with the real packet strings, which carry the multi-country joiner, so a broken one-country guard would generate a title and be caught. |
||
|
|
bc422bb678 |
Each packet's % evangelical page states its own list's share
The page says each box is a share of the people in this list. It printed one DOXA-wide figure - 0.6133% of 209,079,159 people - in every packet, true of no single list. That figure had no builder in the repo. build-evangelical-share.mjs makes its method reproducible, per group: the IMB CSV joined to the DOXA snapshot by ROP3 + country, each group's EvangelicalLevel band at its midpoint (0 for No Known Evangelicals, the midpoint read from the band's own wording), weighted by IMB population. It writes evangelical-by-slug.json and refuses to write unless the table, summed over every DOXA group, reproduces christian-share.json exactly: 2,091 matched, 209,079,159 people, 1,282,178 evangelical, 0.6133%. Change a single band's midpoint rule and it refuses. Not the API's imb_gsec: GSEC puts 225 groups at "no evangelicals" where EvangelicalLevel puts 1,444, because its "less than 2%" band also carries church-planting status. A share from it sits on a different scale from the method the page's own footnote states. evangelicalShare.js sums a packet's own groups - pure, zero imports, so the browser print and the offline generator run the same sum. Across the 28 zones the page now carries 28 different figures, from 0.182% (Deaf) to 0.922%. A host that supplies its own figure still wins. A list with no published levels prints a dash and no number. The old guard, Number.isFinite(Number(x)), passes null as 0 and would have printed "0% evangelical" for no data. Where some of a list's groups lack a level, the footnote says what the figure is computed over; it says nothing when coverage is whole. That clause is in all eleven languages. check-evangelical-share (gates:pre): the table reproduces the committed figure; different lists differ; no data is a dash; coverage appears only when partial. Mutation-tested four ways, each caught - the fourth only once it corrupted an existing group rather than adding one no sum reads. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
0952006343 |
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. |
||
|
|
5c2549701b |
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. |
||
|
|
6fc6f2017b |
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).
|
||
|
|
373fdd2595 |
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 (
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
3a468c2bc3 |
The poster print preview rasterises too, and a deployment can ship sources without rasters
Some checks are pending
build-gate / build (push) Waiting to run
posters/*.webp is not shipped by default (42MB; bundling pushed the tree to 80MB,
git-packed to 57MiB, and the catalog rejected the push). The DOWNLOAD path has
handled that since
|
||
|
|
61dcd9b28e |
Merge main into bundler-1/work (regenerate the built tree, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run
The nine conflicts were all in generated artifacts under public/. Built files are not hand-merged: every bundle whose source either side touched was rebuilt, the tree regenerated against main's new generate-tree, and the hashes re-baked. Source merged clean — main never touched PosterWall.vue or en/regions.json, they only appeared in a two-dot diff because this branch changed them. |
||
|
|
ddde518a85 |
Regions: expand a region to its country posters, reusing the shared wall
Driver request: "a toggle or carrot added to the bottom of the region section that says view country posters, which will let you view country posters for each of the countries in the regions" — with View-on-map and Print, "the same reusable code from upg 100 list". This is a third sanctioned addition alongside the tab strip and the no-selection chooser. Almost nothing was built. library/components/poster/PosterWall.vue was already the shared, fully parameterised grid, and it already owned the card layout, the "Show on map" header button, the PRINT OPTIONS footer and the client-side raster. regions-list.vue supplies only data and a toggle — the same division country-poster-wall.vue states in its own header. - A "View country posters" toggle at the bottom of every region section, carrying aria-expanded and aria-controls, using the same caret SVG the header and footer links already use, rotated when open. - Opening a region renders that region's countries. Measured: 41 cards for Africa and all 41 previews LOAD (naturalWidth>0 — a 404 still yields an <img>, so the load is the real check). No assets were copied: public/assets/previews already holds 174 country-*.webp and generate-tree already stages them. - "Show Chad on the map" reaches the Region Dashboards tab with title "Chad" and hash "#chad", by mapping PosterWall's CustomEvent listId back through the country-selected the tabs already exchange. - India prints its own reason with the exact number from the data; China renders a real poster. Only India is unsupported — 855 groups and no sheet, against China's 154 on a 3x4. Two defects the screenshot caught and no assertion did: - PosterWall's root is position:absolute;inset:0, because in its own bundles it IS a pane. Embedded in a region card it painted 1440x1100 over the region's country list. Fixed by parameterising the shared component rather than overriding its CSS locally: new `embedded` and `sectionHeads` props, both defaulting to today's behaviour, so country-posters and key-cities are unchanged by construction. Both re-checked. Re-measured: root static, 1200px wide, panel below the grid, no overlap. - With one unnamed group the wall printed a section head reading the literal "Other" plus a count duplicating the region card's own summary. Suppressed by sectionHeads=false. The dataset moved to library/data/country-posters.json now that it has a second consumer, one import line changed in country-posters, and emit-countries.mjs was retargeted so the next generator run cannot recreate the duplicate. The three new strings do not exist on DX-life, so there was nothing to copy and nothing was invented in another language: en only, with the other seven locales falling back to the key. Regression: 15 suites green, including region-vs-live 73/73, country-vs-live 28/28, mobile 44/44 and 26/26, and a new _probe-region-posters.mjs. check:library-index drifted for real this time (3 new library files) and is regenerated here. |
||
|
|
ec974b4c63 |
fix(posters): ask the legend which zones are reachable, not clusters.json
Some checks are pending
build-gate / build (push) Waiting to run
publish-html.mjs enumerated the raw cluster keys under a comment that said "the zone ids the app can actually reach" — which was exactly what it did not do. It then warned that deaf-old-world and deaf-new-world-asia-pacific were MISSING on every run, when nothing was missing. Those two are SUPERSEDED by the unified `deaf` row (affinity bloc A017), not absent. Verified against live records instead of trusting the comment that says so: 63 + 60 = 123 = A017 exactly, zero groups on either side of the difference. They stay in clusters.json deliberately — three isolation-proof scripts assert deaf_lists.length === 2 — but the legend does not expose them, so no user can select them and a poster for them is unreachable by construction. It now derives zone ids from leafZones(buildLegendNodes(...)), the same source the app itself uses, so the two can never disagree again. Result: 28 posters published, zero warnings, matching the legend's 28 leaves. records:null, not records:[] — buildLegendNodes treats an empty array as truthy and then demands population fields that are not available here. A recurring warning that is always wrong is worse than no warning: it trains everyone to ignore the one run where something really is missing. |
||
|
|
467b7f01a2 |
feat(packet): all 11 locales — Hindi unblocked with a vendored Devanagari face
Some checks are pending
build-gate / build (push) Waiting to run
The packet now ships in every locale the app ships: en es fr pt de it ro ru ar hi zh. Each carries all 21 translatable keys, the twelve prayer points, the eleven rhythms, both attributions and the privacy note. point4_passage stays omitted everywhere and falls back to the licensed English WEB text, for the reason already recorded — translating scripture here would put unlicensed, unverified Bible text into a printed prayer packet. HINDI WAS BLOCKED ON A FONT, not on a translation. Devanagari renders as tofu boxes in the packet's Georgia/Palatino/serif stack because this machine has no Devanagari font at all. Noto Serif Devanagari (SIL OFL) is now vendored — devanagari subset only, 124 KB, licence and provenance beside it, matching how poppins/ ships latin and latin-ext separately. It is base64-INLINED into the packet, not linked. A packet is a standalone file meant to print offline — the Node generator writes HTML with no server, and the photos are already inlined for exactly this reason — so a URL would break precisely where the packet is most used. Cost: ~165 KB on a 3.5 MB packet, and only for that language. One rule prepends the face to the stack rather than editing 22 hardcoded Georgia declarations, and the browser falls back PER CHARACTER, so Latin scripture references in a Hindi packet still set in Georgia. mr and ne map to the same face; a missing font warns loudly rather than silently producing rectangles. Chinese needed no font work — CJK already shapes in the system stack — only the translation. ★ TWO MEASUREMENTS LIED ABOUT SCRIPT COVERAGE, recorded so nobody automates this cheaply. Comparing shaped width against a missing font reported ar and zh BROKEN and hi FINE — exactly backwards, because Devanagari tofu boxes happen to match real glyph advance. An ink-column-profile heuristic then scored CORRECTLY RENDERED Devanagari the same as tofu (3 vs 2 distinct heights), because its baseline bar gives a uniform profile. Both were abandoned: a detector that blocks working packets is worse than none. Verified: all 11 languages render 8 sheets -> 8 letter portrait pages, map painted, zero overflow. The Hindi prayer-points page was READ AS A PICTURE — conjuncts and matras shaped correctly, no boxes, Latin references still Georgia. |
||
|
|
dd14b5fe76 |
feat(packet): five more languages, and right-to-left support for Arabic
Some checks are pending
build-gate / build (push) Waiting to run
The packet now ships in 9 of the app's 11 locales: en es fr pt de it ro ru ar. Each carries all 21 translatable keys — page furniture, the twelve prayer points with sub-headings, the eleven rhythms, both Metacamp attributions, the privacy note. point4_passage (Mark 5:1-20 in full) stays omitted in every non-en block and falls back to the licensed English WEB text with its credit, for the reason already recorded: translating scripture here would put unlicensed, unverified Bible text into a printed prayer packet. RTL IS NOW REAL, not assumed. The renderer emits dir on <html> from an RTL locale set. Without it an Arabic packet renders readable glyphs LEFT-aligned — which looks like a styling quirk rather than a bug, and no assertion noticed. ★ TWO BIDI FINDINGS, both from reading the render rather than the code: 1. Scripture references are LTR runs inside RTL paragraphs, and the bidi algorithm REORDERS a run that starts with a digit: "1 Peter 5:6-11" rendered as "Peter 5:6-11 1". Each reference is now dir="ltr" + unicode-bidi:isolate. 2. With isolation, the reference LIST reads right-to-left on an RTL page (first item rightmost). That is correct for an RTL reader, not a defect — recorded in _meta so nobody "fixes" it. English source order is unchanged, asserted. ★ HINDI IS DELIBERATELY NOT ADDED. Rendering each script at 48px and READING THE PIXELS: Latin, Cyrillic, Arabic and CJK all shape correctly in the packet's Georgia/serif stack; DEVANAGARI RENDERS AS TOFU BOXES, because this box has zero Devanagari fonts (fc-list). A hi block would ship a page of empty rectangles. It is blocked on vendoring a Devanagari font into public/assets/fonts, which is its own piece of work. ⚠ THE MEASUREMENT THAT LIED. My first test compared rendered text advance against a missing-font baseline and reported ar and zh BROKEN and hi FINE — the exact opposite of the truth, because Devanagari tofu boxes happen to match real glyph advance. One screenshot settled in a glance what two rounds of clever arithmetic got backwards. Recorded in _meta. Verified at the rendered layer in all nine languages: 8 sheets -> 8 letter portrait pages, 95 people cards, 12 wheel segments, map painted, zero overflowing sheets, 19 strings differing from English per locale. The Arabic prayer-points page was read as a picture, not just asserted on. |
||
|
|
41847076a2 |
feat(packet): ship the prayer packet in Spanish, French and Portuguese
Some checks are pending
build-gate / build (push) Waiting to run
The app ships 11 locales; the packet shipped 1. A Spanish-speaking reader got a
Spanish interface and then an entirely English prayer packet. The per-key
fallback machinery was built and verified several ticks ago — the catalog was
simply never populated, so i18n was functionally unshipped.
es, fr and pt now carry all 21 translatable keys: the page furniture, the twelve
prayer points with their sub-headings, the eleven prayer rhythms, both Metacamp
attributions and the privacy note. Terminology follows the app's OWN existing
locales rather than inventing a parallel vocabulary — "grupo étnico" for people
group and "no evangelizado" for unengaged come straight from
library/i18n/locales/es.
★ point4_passage IS DELIBERATELY OMITTED FROM EVERY LANGUAGE BLOCK, and that is
the point, not an oversight. It is Mark 5:1-20 printed in full, and the file's
own guidance already required "a public-domain or properly-licensed version for
that language". Translating scripture myself would put unlicensed and
unverified Bible text into a printed prayer packet. Omitting the key makes it
fall back to the licensed English WEB text with its credit line intact —
honest, and visibly English, which invites a real translation rather than
hiding the gap. The note in _meta now says this outright so the next translator
does not "complete" the block.
Also recorded, because the render made it visible: scripture REFERENCES stay in
English in every language ("Matthew 27:27" on a French page), so a reader can
find the passage in their own Bible. That is a deliberate tradeoff and it does
look foreign next to fully translated prose — _meta now names it and says how to
add localised book names properly if a language wants them.
Verified at the rendered layer in all four languages: 8 sheets -> 8 letter
portrait pages, 95 people cards, 12 wheel segments, map painted, zero
overflowing sheets, and Mark 5 correctly falling back to English with its
credit. The French prayer-points page was read as a picture, not just asserted
on: all twelve points translated, Matt 27:27 intact at point 1, "un lieu sûr" at
point 8.
|
||
|
|
cfea0c8e70 |
fix(posters): measure the full-version header instead of guessing 460
Some checks are pending
build-gate / build (push) Waiting to run
Four zones had NO POSTER AT ALL. measureHeader() opened with
`if (!SPARSE) return HEADER_BASE` — a flat 460 — so the per-zone measurement its
own comments describe ("HEADER IS PER-ZONE, NOT A SHARED CONSTANT...
measureHeader() predicts the height from the zone's OWN content") only ever ran
in SPARSE mode. Outside it, any zone whose header was taller than 460 tripped
the fail-loud guard and rendered nothing:
oceania-indonesia 504 andean-caribbean 504
asia-china-tibet-autonomous-region 481 deaf 619
Raising the constant was not the fix. HEADER is subtracted from the map height,
so 460 -> 620 would have stolen 160px of map from the 25 healthy sheets to
rescue 4 — the exact tradeoff the per-zone measurement exists to avoid, and the
one its own comment records (tuned to North India, it overflowed west-africa;
tuned to west-africa, it stole from everything else).
MEASURED the real header on six zones instead:
HEAD_BOTTOM = (headY + 34) + 118 exact on all six
headY = 191 + 23 * tableLines exact on all four probed
The 118 is the two vision columns — a constant 104, because that copy is fixed
and wraps identically on every sheet — plus the 14px trailing gap. ALL per-zone
variation is the facts table, which advances 23px per wrapped line. So this is
arithmetic on the same inputs the renderer wraps, not an estimate, and it needs
no calibration history like the SPARSE model's +24/+52/+66/-8/+6 above it.
★ THE ERROR WORTH RECORDING: my first attempt derived the constant by READING
the code — tracing headY's assignments to 150 + 23n - 12 — and was 53px short on
every zone, because the scripture block between the table and the vision columns
also advances the cursor and I had not traced it. Every zone then failed,
including ones that worked before. Deriving it from four measured renders got it
exact. Reading selectively is not measuring.
Result: 28 posters render, zero failures. And the map GREW on the healthy
sheets, because they no longer pay for the worst case — laos' map frame starts
at y=443 against the old flat 460, while deaf takes the 627 it genuinely needs.
Still missing after this: deaf-old-world and deaf-new-world-asia-pacific, which
never reach the render at all (silent skip, exit 0 — they are `deaf_predicate`
selectors that leafZones() does not return). Different cause, carded separately.
|