132 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. |
||
|
|
af8da2259c |
Any group set can generate packets from one folder
The generator could already serve a second group set -- series.json's country series proves it -- but only one wired into series.json, reading upg100's bucketed clusters, upg100's legend tree for enumeration, and the shared records cache. A fork had no way in. The contract is now one folder. DATA_DIR points at it; clusters.json with a flat lists[] is the group set; records.json in the same folder means no API and no cache; selector-evaluator.js resolves from there, as it always did. normalize.js and legend-tree.js become optional -- absent, records are used as-is and the flat file IS the enumeration -- and the absence is logged rather than silent, because a silent fallback would let a typo'd normalize.js skip normalization on the real series. PACKET_OUT keeps a fork's output away from the real artifact directory, where toy packets would be counted as coverage. unlisted: true carries upg100's reachability idea across as data: the list is defined and selectable with ONLY=, and not generated by default. The bucketed shape keeps its legend-tree enumeration untouched, so the deaf collapse and the 28-zone arithmetic are byte-for-byte unchanged. The smoke test was written first and failed first, on all four counts. Its fixture is synthetic -- every slug and number invented -- and it asserts the unlisted list does NOT generate, which is as load-bearing as the two that do: an unlisted entry that generates anyway is the reachability bug inverted. One bug found in my own work while proving it: allZones became async, and the funnel's listCount read it from inside a try/catch that returns null on error -- so a use-before-init did not crash, it silently deleted the triangle from all 28 packets. Caught because a probe for the tip's "1 of 28" found nothing, not because anything failed. Resolved once, before the funnel is built; regenerated and verified -- funnel present, tip reads 1 of 28, 95 photos, 11 sheets. |
||
|
|
4b45285b7d |
Hold the packet's ring names to the screen's, and adopt its exact words
The agreed ring vocabulary now lives in two catalogues in eleven locales each -- gospelInternet.json for the screen legend, packet-strings.json for the printed sheet -- and nothing held them together. They legitimately want different SHAPES (the screen renders the axes as standalone labels, the packet composes them into one string per ring), so neither can import the other; but the WORDS must match. The failure that makes this worth a gate is quiet: one cell is provisional on the Driver's row 89, and it appears in rings 1, 4 and 7. Answer it in one file and not the other and the sheet and the screen state different things about the same ring, each correct in isolation, with no error anywhere. Only a reader holding both would notice. Measuring first showed 36 of 77 cells already differed, in two classes. Most were grammar -- the packet's culture terms follow a comma mid-sentence, so they are lowercase -- but some were genuinely different words: "Same family" against "Same language family", German "verwandte" against "Nahe", Hindi "वही जन" against "वही जनसमूह". Those are drift, and the screen's wording wins because it is what readers already see. The packet now adopts every term verbatim, lowercasing only the first character of the culture half, which is a no-op in zh/ar/hi and leaves German common nouns capitalised. That adoption caught a bug in my own assertion: I had hardcoded ring 7-9 as "Same family" from CenteredSetTarget.vue's header, while the shipping string is "Same language family". A summary in a comment is a description of the data, never a second source of it. The gate also refuses to pass when it compared nothing -- rename a key on either side and every lookup returns undefined, every pair is skipped, and a silent check reports success. Mutation-tested both ways: answering row 89 on the screen only, and renaming a packet key. |
||
|
|
fccf6ee2fe |
Name the rings on the packet's centered set
The figure printed rings without saying what they mean -- honest but thin.
It now names each one with the vocabulary gospel and exp settled, the same
words the screen legend renders, so a printed sheet cannot disagree with
the screen.
COMPOSED FROM band x culture, NOT A FLAT 1..10 TABLE, and that is the
whole design decision. "Same people" in column 1 is provisional on the
Driver's row 89 -- and it appears in rings 1, 4 AND 7. A ten-string table
would need three edits kept in step, which is exactly how two of them end
up saying different things. Composed, it is one string
(ring_culture_same), and if he picks "culture" the ring numbering, the
columns and the colours are all untouched.
Ring 10 stands alone ("Nearby only") because it is not a band-and-culture
reading at all -- it is what is left when no linguistic relation was
found. The gate asserts that separately, and a mutant that composes it
like the other nine fails.
ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure
is a glossary, not a legend, and a named ring with no dot on it invites
the reader to hunt for something that is not there. ringTally's per-ring
counts drive the list, so it states what this zone actually contains.
Mutation-tested: a wrong band order, listing all ten rings regardless of
count, and composing ring 10 like the others each fail with their own
message. Eight new strings x eleven locales; packet-strings-parity green.
|
||
|
|
3c1d513cc3 |
The packet gets its bounded and centered sets page
Brief A1 back matter: after the profiles, before the wheel, full detail only. The lightweight packet is map, triangle, profiles, wheel, points -- these two figures are the part a reader sits with rather than carries. The layouts are regions' library/components/charts modules, imported rather than injected: unlike describeGroup's country resolver they have zero imports and run under plain node, and packet-render already imports scatterCells from that directory. Verified by importing and CALLING them, not by reading them. They return data, so the SVG is ours. Gospel's non-negotiable is carried through and checked rather than trusted: the bounded set keeps a deliberate gap either side of the boundary because a dot ON the line is the one thing a bounded set cannot express, and print has no hover to disambiguate it. Measured on the real module with 40 groups -- inside max 85.3 of 92 allowed, outside min 119.8 of 112 required. The figure scales through the viewBox, so the gap lives in coordinates the scaling cannot touch; shrink the figure, never the gap. UNKNOWN POINTS ARE STATED, NOT PLOTTED. layoutBoundedSet gives them no x/y, which is the module being right: `reached` undefined means we do not know which side they belong on, so there is no honest place to draw them. My first version mapped them through the same dot() helper and crashed on p.x.toFixed -- a better outcome than silently drawing them at (0,0), which would have put "we don't know" inside the circle. They go in the tally, because on a printed page a category the reader cannot see reads as zero. check-packet-variants' sheet delta goes 2 -> 3, updated in this commit rather than relaxed into a range. It also now SUPPLIES the sets fixture: setsPage returns '' without data, so a build() that omitted `sets` exercised the filter for every page except the newest one, and the gate sat green through the whole rewrite while asserting a delta that had become wrong. A fixture missing an input silently narrows what the whole file covers. Seven strings x eleven locales; packet-strings-parity green. Gospel's own ring names live in gospelInternet.json, a plugin catalogue, so there is no collision with these. |
||
|
|
d3ee0bb6ec |
The whiteboard is a layer of tiered points; only the edges were ever a tree
Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom, label priority and tiling from the map engine instead of re-implementing them in SVG. READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The parent->child links are sourced from linkFC; the points, the selection and hover rings, the dish behind a cluster and EVERY label layer come from one point collection and know nothing about trees. gospel re-read that independently and found the same split, which is the only reason I trust it -- "the seam is generic" is the claim that should never travel on one reader. So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose features carry id, label, tier and priority, and gives each TIER its own symbol layer with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group names in": the engine is told which labels may exist at which zoom and resolves the collisions itself. Paint expressions pass through unread -- the layer has no opinion about what a colour means, which is what keeps it reusable. Selection and hover are FILTERED layers, so hover costs one setFilter and structurally cannot move a camera: there is nothing in here that could. AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong. gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the labels a reader compares are. library/maps/whiteboard-projection.js is the inverse conversion, EXTRACTED from build-language-tree.mjs rather than written, because the second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996 of the width at the centre to 0.6794 at the edge; that shrink IS the correction the renderer then cancels. TWO TESTS, EACH GUARDING A DIFFERENT RISK. check:whiteboard-projection reproduces the old private implementation VERBATIM as a reference and compares over 1,734 grid points EXACTLY -- the language tree is a build-time artifact, so a rounding shift would move 4,000 coordinates and look like nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND that a linear scale FAILS that same assertion, because an assertion the defect can pass is measuring nothing. check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers, priority by population, and NO EDGES. The absent links layer is the evidence -- if the whiteboard draws this chart with no line layer anywhere, edges are separable in fact rather than in my reading. The map is a recording fake, so the assertions are about what the module DOES; a regex over the source would pass on a module that never called addLayer. Negative-tested: making the projection linear, changing its rounding, dropping the label priority, or giving every tier the same minzoom each turn one of them red. gospel's UI is NOT wired -- their component is theirs to write, later, against a module that now exists. Their boardEngine.js stays where it is until the chart lands. |
||
|
|
aedc517cec |
The table row is a border-box, and the offline packet gets its description
Two fixes from measuring what I had only modelled. 1. THE ROW RENDERED 81px AGAINST A 73.25px MODEL. rega measured it: no box-sizing, so the declared height was the CONTENT box and the 6px padding plus 1px border were added outside it. Because `top` still advanced by the modelled height, every row overlapped its successor by the difference. The stack still fitted the sheet -- it ended 8px inside the 60px bottom margin instead of on it -- so nothing clipped, nothing ran off the page, and no headless check could see it. box-sizing:border-box makes the declared height the rendered height. The ladder still fits: the content box is 66.25px and a name plus two lines at the default 17px needs 61.8px. 2. THE OFFLINE GENERATOR WOULD HAVE PRINTED A ONE-CLAUSE DESCRIPTION. Its fetch never asked for primary_language or imb_gsec -- measured on the cached records, 0 of 2107 carried either, while country_code_label was present on all 2107. So the browser packet printed two full sentences and the offline packet printed "The Amahuaca live in Peru." from the same data. Same defect class as the comment already above that field list: it reads as missing DATA and is missing FIELDS. The generator now injects the describer too, deliberately WITHOUT a country resolver: library/utils/countryName.js cannot be imported under node at all (it imports JSON without an import attribute -- measured), which is the constraint that made this seam an injection. It is not needed, because describeGroup falls back to country_code_label and every cached record has one. Verified end to end: 28 packets, 63 sentences on the first page, zero fallbacks, country localized correctly. The gate now budgets against the CONTENT box rather than the declared height, asserts the DEFAULT 17px step fits and not merely the smallest, and pins box-sizing in the source -- mutation-tested: removing border-box fails with its own message. |
||
|
|
6518fad1bb |
The language tree's layout becomes library's; its gospel classification does not
Driver brief §B, BUILD-LIBRARY-CHART-LANGUAGE-TREE: promote gospel-internet's language tree so a fork can build one from its OWN rows. gospel's scripts/build-language-tree.mjs does two jobs in one file. The generic half is the radial layout, the circle packing and the inverse Web Mercator projection. The other half reads gospel-internet.json, ebible-index.json, gospel-resource-links.json and FCBH verse scopes to classify each language g/y/r, and emits a second, purely gospel output. Moving the file whole would have put eBible and FCBH inside library/, and a fork building from its own roster has no eBible index -- so the literal move is the one shape that would NOT deliver the reuse the brief asks for. So the classification stays with its owner and arrives as `statusOf`, returning an OPAQUE string. Nothing here knows what 'g' means; a consumer colouring by speaker count substitutes one function instead of forking a data pipeline. The status is written on LEAVES ONLY, which is not an omission. Rolling a parent's colour up from its descendants is a RUNTIME decision the chart makes against the reader's current scope -- and the scope changes as the reader filters, so baking it would freeze one answer into the data. gospel confirmed the rollup is policy rather than geometry: nothing in the drawing depends on a parent being the worst of its children. The measured constants keep their reasons: the enclosing circle is the tree's real extent plus room for one label (sized by the deepest ring, packing wasted a third of the board); the poster layout drops dialects (2,653 dialect slots squeezed the 858 languages until ~40% of names could not be printed); and the share of the Mercator square is a parameter with a measured default, because it is what decides how far a reader can zoom out below the opening view. check-language-tree-layout asserts INVARIANTS rather than a golden file -- a snapshot of 4,000 coordinates fails on every legitimate change and says nothing about which property broke. Family circles do not overlap, every roster language is reachable, a missing one warns rather than vanishing, the drawing stays inside its extent budget, the poster layout excludes dialects, the status letter is carried through unexamined, and the same input builds the same tree twice. gospel's own script is untouched: they switch to this and drop their copy in the single owner-land already planned, so nothing of theirs breaks in between. |
||
|
|
a764a89e3b |
packet fixtures carry the description's two fields
The four packet fixtures predate primary_language_label and imb_gsec, which the
packet fetch has carried since L59. describeGroup degrades by DROPPING CLAUSES
rather than blanking — sr's deliberate and correct design — so on fixture data
it renders "The X live in Y." with no language and no reachedness sentence, and
does so without error.
That makes the OFFLINE render (PACKET_RECORDS=fixtures/…, which is how rega
verified NEEDS-EYES row 83) diverge from the live browser packet in exactly the
new column: the verification path would pass on something the product does not
print. Same Node-vs-browser divergence as the %evangelical page, pointing the
other way.
Regenerated from the live API (2,107 rows), matched by slug: 21/21, 20/20, 1/1,
43/43. Row counts and order are unchanged, so the 20/21/1/43 edge cases keep
their meaning and check-packet-render's `ceil(cards / PER_PAGE)` arithmetic is
untouched.
★ STORED POST-normalizeRecords, NOT AS THE API RETURNS IT. Both fields arrive
nested — primary_language is { value:'abf', label:'Abai Sungai' } and imb_gsec
is { value:'1', label:'Less than 2% Evangelical…' }. A fixture holding the raw
shape would have faithfully reproduced the bug that 3b644893 fixes, so the
fixture would look right and the packet still print half a description.
HONEST LIMIT: the DOXA set is 225 at GSEC 0, 1,852 at 1, 23 at 2, 4 at 4, 1 at
6, 2 null — so fixtures drawn from real data exercise levels 0 and 1 only, and
levels 3 and 5 do not occur at all. Per-level coverage belongs in the unit test
with synthetic rows, which is where it already is; the fixtures' job is to match
what the fetch delivers.
|
||
|
|
1061e9c5c4 |
The packet's generated profile sentence has its catalogue: ten keys in eleven locales
describeGroup returned an empty string against the shipping packet-strings.json because none of its keys existed, while its check passed on its own fixture strings. desc_where_* says where a group lives and what it speaks, dropping whichever clause has no data; desc_gsec_0..6 says how evangelical it is. Each locale's under-2% sentences reuse that locale's funnel_unreached wording, so a packet does not define the threshold two ways on two pages. English from the UPG-100 owner. |
||
|
|
11f0f4f5fc |
packet: country mode needs the country zone source, not just the wording
exp-shepherd measured on main: SERIES=country ONLY=country-yem made a POSTER, and the matching packet.mjs run died with 'no zone matches ONLY=country-yem'. The packet half of SERIES=country could not run for any country. Cause: allZones() built zones from clusters.json -- the UPG-100 zoning -- where a country id never appears. generate.mjs got its country zone source from countryMode in the previous commit; packet.mjs did not. The same 'one concept, two switches' defect I had just removed from one file survived in the other. MY OWN TEST MISSED IT BECAUSE IT WAS NOT END TO END. I ran the country packet against a UPG-100 zone id (ONLY=west-africa), which exercised the WORDING and never the zone source, then reported the parameter verified end to end. Running the actual pairing is what found it, and exp-shepherd did that, not me. allZones() now returns the country zones under countryMode, built to match generate.mjs's country branch exactly -- same ids, labels, adm0 and the PLURAL country_codes selector shape -- so a poster and its packet cannot describe different zones. Same two-path country-list lookup and the same error naming the command that writes the file. Verified: country-yem builds 8 groups / 6 pages, SERIES-ISOLATION PASS with 9 country markers and zero UPG-100 framing; the UPG-100 default is unchanged at 12 pages and 13 markers; all four packet gates PASS. |
||
|
|
11129455e4 |
posters: SERIES is the one switch; header wording joins it; country mode fixed
rega read the rendered page and found a fourth leak site my gate had passed: a
SERIES=country poster still printed 'UNENGAGED PEOPLE GROUPS', 'Unengaged of West
Africa', 'Series: List of 100 UPG's · map 11 of 28' and a side box calling it one
of a 30-map set. I had reported that PASS to three agents as proof the render was
clean. It was not proof: the gate's vocabulary was a HAND LIST of the phrases I
had already found, so it could not see framing I never enumerated.
ONE SWITCH (dx-shepherd's call). SERIES=country now implies the country zone
source, the series name and the header strings. COUNTRIES=1 is kept as an ALIAS
that resolves to SERIES=country, so runs that predate series.json keep working
and now get the matching packet too. Two parameters for one concept is exactly
what let rega's render take the new packet path with a UPG-100 header.
The five header strings move into series.json: kicker, title qualifier, series
name, side-box heading and body. One file owns the wording.
THE GATE NO LONGER DEPENDS ON WHAT I HAPPENED TO KNOW. Its vocabulary is DERIVED
from series.json -- every string a series declares becomes that series' own
fingerprint, so adding wording polices it from that moment. Strings shared
between series are dropped (they identify nothing) and a 12-character floor keeps
short phrases from matching innocent prose. The hand list is kept and merged for
phrases no series declares, like 'Project 7000'.
It also strips HTML and CSS comments before matching: generate.mjs ships design
notes into the SVG that QUOTE these strings, and a correct country poster failed
on five of them. A detector that does not know the comment syntax of what it
reads attacks the documentation of the thing it protects.
FOUND WHILE WIRING IT, a pre-existing break: COUNTRY MODE HAS NOT RUN AT ALL.
generate.mjs read plugin-designs/country-posters/data/countries.json, but
|
||
|
|
70cb1b3ed3 |
packet: refuse to build around a poster of a different series
The ordering rule 'regenerate the poster with SERIES=country BEFORE the packet' was a fact about the build living in a human's head. Documentation does not hold that kind of fact: the same bet already failed for 'regenerating the tree alone ships a stale .js', which is written down in the brief and still happened. The hazard is silent and I walked into it while building the series parameter. The three below-map blocks are DRAWN INTO the poster's SVG by generate.mjs, and the packet lifts that SVG whole, so the text reaches the packet through the IMAGE -- no string file mentions it and no grep of packet-strings.json can see it. A country packet built against a leftover UPG-100 poster prints 'Thirty disciples' on page 1 with no error, the right file size, and a generator reporting success. It looks exactly like the SERIES parameter does not work. So the build asserts it instead of remembering it. The packet reads the poster's series out of the artwork it is about to lift -- fingerprinted by the NETWORKS headings, which are the one marker that survives every stage of the pipeline into the artefact -- and exits 1 with the remedy command when it disagrees with its own SERIES. Suggested by regions-plugin, whose two near-misses today were the same class: assert against the ARTIFACT with a marker that survives the pipeline. Verified in all four directions: country packet vs upg100 poster FAILS, upg100 packet vs country poster FAILS, and both matching combinations build clean. An unrecognised poster is deliberately not an error -- that is the existing 'no <svg>' branch's failure, not this one's. |
||
|
|
12d7f218f3 |
posters: a SERIES parameter, so a country poster stops rendering the UPG-100 packet
The country posters shipped carrying UPG-100 framing: the UNENGAGED PEOPLE
GROUPS kicker, 'This packet is 1 of {n} UPG-100 lists', the Project
2000/7000/4700/700 narrowing funnel, and a below-map block telling the reader to
divide the set among THIRTY disciples -- coherent for ~30 UPG-100 zones and
incoherent for ~170 countries. Reported by the Driver.
Not a mislabel: there was NO series parameter anywhere, so 'the packet' and 'the
UPG-100 packet' were one thing and nothing could disagree.
series.json declares each series' funnel flag, string overrides and the three
below-map NETWORKS blocks. Both packet.mjs and generate.mjs read it, because the
leak was in TWO independent places -- the packet strings and the poster's own
furniture, which is not a packet string at all, so fixing either alone leaves the
other printing. SERIES absent or unknown resolves to upg100.
The funnel page drops for country: it narrows every people group on earth to THIS
list, which is not a description of a country. Reusing the existing
missing-vision-funnel path means one way for the page to be absent, not two.
Four packet keys are overridden, not seventeen: 12 of the 17 are funnel_* and go
with the page; the 12 prayer points are invariant, because they are about a
PEOPLE and a country poster still prays for the peoples within it. Overrides
apply to every locale on purpose -- an English country kicker is visibly awaiting
translation, whereas inheriting the translated UPG-100 kicker prints the wrong
series fluently.
The country English is a DRAFT from the Driver's dictated shape, flagged
_draft_awaiting_driver; no translations until he signs off.
check:series-isolation asserts the property, with a self-test over 6 known-bad
inputs. It caught a real leak this commit would otherwise have claimed fixed: the
packet's page 1 lifts the whole SVG from the pre-rendered poster, so a country
packet built against a stale UPG-100 poster still printed 'Thirty disciples'.
Verified at the rendered layer -- country 11 pages, upg100 12, the delta exactly
the funnel page; upg100 networks byte-identical to the text they replaced.
|
||
|
|
f7a7619695 |
Stop shipping an internal role noun in public bundles
check:public-hygiene flags "internal role noun (Driver) — process vocabulary" inside shipped bundles. Three of those reach regions-dashboard.js, and they are not from the regions components: they come from the shared poster and packet code, which every bundle using PosterWall carries. The worst is not a comment at all. packet-strings.json's note_on_scripture is a shipped STRING, so it is data a reader can read. The other two are a JS comment and a CSS comment that both live INSIDE template literals, which makes them string content rather than comments -- the minifier cannot strip what is not a comment, and the shadow-CSS stripper does not reach a print stylesheet. Rewritten to say what the decision was rather than who made it. Three lines, no behaviour, no reformatting. regions-dashboard.js goes from 3 occurrences to 0 after a rebuild. This is the white-label requirement, and it matters most for the artifact we intend to hand over: a recipient reading our bundles should not meet our internal process vocabulary. |
||
|
|
b22df1381e |
Mark 5 in five more languages, each a cited public-domain translation
rega measured the Arabic packet (row 83) and found page 8 still English. dx-shepherd's rule, which this follows exactly: NEVER machine-translate Scripture — use a published public-domain translation per locale, cited with its name (END-DXS-MK5GO). ar Arabic Van Dyck Bible ru Russian Synodal Bible es Reina Valera 1909 de German Luther Bible 1912 fr French Ostervald Bible Fetched from bible.helloao.org, which ships licenceUrl + website + sha256 per translation and points at ebible.org's own licence pages. Full provenance — translation id, name, licence URL, sha256 — is recorded in data/people-groups/scripture-sources.json, because a credit line on the page can carry a NAME but not a verifiable source, and the two are different claims. ★ VERSE NUMBERING VERIFIED AGAINST THE ENGLISH, not assumed. All five extract exactly verses 1-20 of 43 in the chapter; verse 1 matches the WEB's "they came to the other side of the sea, into the country of the Gadarenes" and verse 20 ends at "Decapolis ... everyone marveled". Lengths land within 1,689-2,399 chars against the English 2,141, which is the spread you would expect across languages rather than a truncation. The English passage carries no verse numbers, so these are joined as continuous prose the same way. ⚠ AND I DID NOT WRITE ANY OF IT FROM MEMORY. Scripture reproduced from recall is machine translation with extra confidence — the same failure the rule forbids, one step removed. Every character came from the API and was checked against the English before storing. hi/zh/pt/it/ro STAY ENGLISH and stay in DECLARED_ABSENT, which is now narrowed rather than deleted: PD candidates exist (Hindi 1851 Serampore-lineage, Chinese Union Version 1919, Almeida 1819, Diodati 1649, Cornilescu 1924) but their licence status varies by jurisdiction and I could not confirm it. The entry shrinks one locale at a time as each is cited. VERIFIED: check:packet-strings-parity PASS, and the ar and de packets both render 9 sheets PASS with the passage and its credit on page 8. |
||
|
|
524e5ebfae |
Translate the new packet pages into all eleven locales
The gate caught what I shipped: rendering the Tibet packet at PACKET_LANG=ar failed with "the funnel page carries Latin-script prose in a ar packet: 'This packet is', 'UPG-', 'lists … How each number is reached'". Exactly right — my new strings were English-only, and two of them PRINT on a page whose siblings (funnel_title, funnel_intro, funnel_people) are fully translated. English text on an otherwise Arabic page is not a graceful fallback, it is a half-translated page. So all 17 new keys are now genuinely translated across es fr pt de it ro ru ar hi zh — 150 values — and DECLARED_ABSENT is back to its original two entries (the Mark 5 passage and its credit). The declaration mechanism was the right call while they were untranslated and the wrong place to leave them. ⚠ 'UPG-' IS ALLOWLISTED, AND THE ORDER MATTERS. It is the Latin run left by "UPG-100", the product name every locale keeps verbatim — the same reason 'Project' is already in LATIN_OK. But it is allowed only AFTER the real translations landed: an allowlist entry added when the failure first appeared would have silenced the gate on fifteen genuinely untranslated strings. The allowlist is for names, not for work not yet done. VERIFIED at the rendered layer in BOTH locales: en and ar each PASS with 9 sheets, map painted, content intact. The Arabic packet carries its life-calendar title, christian-share title, both legend labels, the three prayer columns and the "1 of 28" tip in Arabic, with 2 grids and 32 shaded cells — the same numbers as the English one, which is the point: the arithmetic is language-independent and only the words change. check:packet-strings-parity PASS. |
||
|
|
de243597db |
The prayer packet the Driver asked for: life calendar, the same grid as a percentage, a triangle
Driver, 2026-09-27, in his own page order. Nine pages now, was seven.
1 map the zone's vertical one-pager (unchanged)
2 LIFE CALENDAR 100 years x 52 weeks = 5,200 boxes — a whole human life on one page
3 THE SAME GRID shaded by the evangelical share of these groups' POPULATION
4 funnel now an UPSIDE-DOWN TRIANGLE with an equation sidebar and a "1 of 28" tip
5..n groups 20 per page with photos AND three prayer tick columns
then the 12 prayer points and the prayer wheel (both unchanged)
★ THE 12 PRAYER POINTS WERE NOT LOST. The Driver believed they had gone; all 12 are intact in
packet-strings.json with his rhythms and sub-titles — praise/Two-Cross Gospel, wait/Holy Spirit &
deliverance, confess, read/the Father's love, ask/baptisms, intercede/laborers & shepherds,
pray_word/Persons & Houses of Peace, thank/rest, sing, meditate/communion, listen/accountability,
praise_end/great-greater-greatest — plus the Mark 5 passage and the Metacamp attribution.
★ PAGES 2 AND 3 ARE ONE GRID READ TWICE, which is the whole idea. 5,200 is the Driver's own arithmetic
(100 x 52), not a layout choice, so both pages derive from it and cannot drift into two grids that merely
look alike. A percentage drawn as a bar is an abstraction; drawn on the grid the reader has just
understood as their own life, 32 shaded boxes of 5,200 is the same fact made unavoidable.
★ AND PAGE 3's NUMBER IS POPULATION-WEIGHTED, because he asked for "% of Christian population vs lost".
That matters: by GROUP COUNT 69.1% of the 2,091 matched DOXA groups have no known evangelicals, but by
POPULATION the evangelical share is 0.61% of 209,079,159 people. The group-count version is a materially
milder picture of the same data. Computed in data/people-groups/christian-share.json, joined IMB
CSV ROP3Code+ISOAlpha3 to DOXA's snapshot — the same join the Vision Card uses — and READ here, never
re-derived, so one fact has one source.
⚠ THE BAND MIDPOINT IS AN ASSUMPTION AND THE PAGE SAYS SO. peoplegroups.org publishes EvangelicalLevel
as a BAND ("Less than 2%", "No Known Evangelicals"), never a number, so any percentage requires choosing
a value inside each band. The file records the method, the page prints it, and 0.61 + 99.39 = 100 exactly
— my first formatter rounded the large side to "99%", which loses the asymmetry that IS the point and
leaves a reader checking the arithmetic to find the page wrong.
⚠ GEOMETRY MEASURED, NOT EYEBALLED: square cells would need 26.5px x 100 rows = 2,654px of grid in
1,521px of printable height, overflowing the sheet 1.7x. 13px rows put 100 rows in 1,498px with 23px
spare. Caught by computing the height before rendering.
⚠ AND THE TIP SAID "1 of 27". listCount spread the three cluster keys and omitted `deaf`, whose two
deaf_lists entries buildLegendNodes collapses into one leaf — the same omission allZones() had before
|
||
|
|
6dd5278584 |
Svalbard carries its own data, not Norway's
iso3-to-iso2 mapped SJM to NO, so every research fetch keyed through it filed Norway's figures under Svalbard. With SJM -> SJ on main, the four fetched tables now hold what the pipeline produces for Svalbard itself, re-fetched for SJ through each script's own code: network-operators 60 Norwegian networks -> none (RIPEstat: 0 ASNs for SJ) platform-reachability 15 rows of Norway's OONI -> 15 not-measured (no SJ probes) social-platforms 8 rows of Norway, 2026-07 -> 4 rows of Svalbard, 2026-09 messaging-channels 3 rows from Norway's store -> none (no SJ storefront) Norway is not added: it is not in the DX country spine these tables are built over, so no build produces Norway rows here. Svalbard's own people group rows elsewhere are untouched, and every other country's rows are unchanged and in order. |
||
|
|
725467a9de |
Country names in the reader's language, for all 242
Our data speaks ISO alpha-3. Intl.DisplayNames('region') takes alpha-2 or
UN M49 and NOT alpha-3, so every country name was stuck in English for want
of the join.
Vendored CLDR cldr-core 48.2.2 (Unicode-3.0, 36KB, sha256 and source URL
recorded in library/vendor/cldr/README.md). No npm dependency: a package
would pull the whole supplemental tree and make a shipped data file depend
on an install step, where a vendored copy lets the generator reproduce the
map offline -- which is the point of vendoring rather than merely having a
copy.
generate-iso3-to-alpha2.mjs joins it against the 242 codes in
countries.json and GRADES ITS OWN OUTPUT, writing nothing on failure: 242
coverage, injectivity, two-letter well-formedness, and that each documented
exception is still needed. KOS -> XK is the only exception -- Kosovo has no
ISO 3166-1 assignment, so no refresh will ever supply it, and XK is the
user-assigned code CLDR itself uses in locale ids. It is recorded with its
reason instead of patched into the vendored bytes.
countryName(iso3, locale) falls back localized name -> canonical English
name -> the code. Intl.DisplayNames THROWS on a missing or malformed code
rather than returning null, so the guard is checked before asking; the
probe demonstrates that throw rather than asserting it in a comment. An
unassigned-but-well-formed region returns the input unchanged, so that case
falls back instead of rendering "DE" as a name.
THE RESEARCH SPINE MAP IS NOT TOUCHED. iso3-to-iso2.json is a different
file with a different job -- the 176-country spine that drives the data
fetches -- and expanding it is a Driver decision, not a side effect of
adding country names. The generator cross-checks it and REPORTS
disagreements without rewriting. There are none: CLDR independently agrees
with all 176 values, including the two corrected this session.
Also fixes a duplicate "check:religion-label-not-value" key in
package.json, left by a merge. JSON keeps the last, so one description was
silently dead and check:doc-scripts could not see it; bun's parser warned.
The surviving entry absorbs the only fact the dead one carried, the two
library sites by name.
|
||
|
|
8f79a3af0f |
Norway gets into the country table at all
NOR was absent from iso3-to-iso2, so Norway appeared in no research table: consumers filter on ISO3_2[iso3], which makes a missing code a silent omission rather than an error. Its absence is also why SJM -> NO read as injective -- "NO" had exactly one claimant, the wrong one. NOR -> NO, from ISO 3166-1, which is what CLDR's codeMappings encodes. The full CLDR vendor still comes with the 242-country table and will re-derive this alongside the other 66 missing codes; this is the one line exp-shepherd needs first to re-key the mislabelled Svalbard rows onto Norway. NOR joins the named spot-check, and the completeness floor rises 175 -> 176 with it. Left at 175 that check would have called a regression back to the pre-NOR map clean, which is the opposite of what it is for. Removing NOR now fires both guards; injectivity still reports "all 175 codes map to a DISTINCT ISO2" while it does, for the third time in this defect. |
||
|
|
1eae1d8c10 |
Svalbard gets its own country code
SJM mapped to NO, which is Norway. Every research fetch keyed to Svalbard and Jan Mayen therefore asked the APIs about Norway. SJM -> SJ. Injectivity could not see this, and that is the part worth keeping: the check passes as long as no two ISO3 codes claim the same ISO2, and NOR is absent from the map entirely, so "NO" had exactly one claimant and read as clean. A uniqueness test cannot tell a right code from a wrong one nobody else wanted. So SJM joins the named spot-check beside PSE and HKG, which were wrong the same way -- mapped to their administrator's code. Reverting the value now names it; before, the verifier still printed "all 175 codes map to a DISTINCT ISO2". The mapping is the half that stops a future fetch being wrong. The rows already fetched are still Norway's, keyed to Svalbard: 60 network operators (Telenor Norge AS, GlobalConnect AS, Marlink AS), 15 platform reachability rows and 8 social-platform rows, in the shipped tables under public/assets/tables/. Those belong to other plugins, so they are routed rather than rewritten here. |
||
|
|
1801bbc20f |
Messaging channels: a taxi and a food app are no longer a country's top messaging app
The classifier matched "line" as a bare word, so "LINE MAN: Food Delivery & more" (Thailand) and "Malé Taxi Line" (Maldives) each ranked as the country's top messaging app, ahead of WhatsApp. LINE now matches only as the whole name or before a colon. The classifier moves to app-kind.mjs so the fetcher and the table builder share it, and the builder re-derives each kind from the app name, so a cached fetch made under an older rule cannot carry its answer forward. The headline count is computed from the rows instead of typed: WhatsApp (Messenger or Business) is top in 73 countries. |
||
|
|
9a955f8f20 |
Web-sized boundary copies: every feature and island kept, a fifth of the bytes
rega measured ~3.6 MB of boundary GeoJSON costing 5-6 s of MapLibre worker parsing before the first territory draws, on every partner page. These copies are for interactive maps; the full files stay for posters, print and build-time point-in-polygon. world-adm0-web.geojson 1,866 KB -> 383 KB (gzip 596 -> 134) 162 features, 572/572 parts gb-IND-ADM1-full-web.geojson 1,796 KB -> 227 KB (gzip 451 -> 69) 36 features, 452/452 parts gb-IND-ADM1-web.geojson 1,628 KB -> 178 KB (gzip 412 -> 55) 34 features, 40/40 parts, listId kept (gb-CHN-ADM1 is 56 KB already: no copy) library/scripts/boundaries/build-web-boundaries.mjs writes them with mapshaper (pinned 0.6.121, via npx; not a repo dependency): -explode, then topology- preserving Visvalingam with keep-shapes, then -dissolve with every other property copied. The explode makes each island its own shape, so keep-shapes protects every one: without it, the world drops from 572 to 240 parts. The run FAILS on a changed feature count, a lost key, a changed property or a dropped part. Negative-tested (without -explode: 297/572 parts -> FAIL), and a second run writes identical bytes. Measured (_qa-cycle/03-deployment-cycle/REPORT-WEB-BOUNDARIES.md): median area error 0.57% (world), 0.04% (India). Of 14,486 real people-group points, 95.9% stay in the same country in the world file and 99.9% in the India file; the misses are archipelago coasts. No runtime code does point-in-polygon on these files, so this is visual only. Not wired: consumers still fetch the full files, and generate-tree does not stage the -web copies yet. Both are in the report's switch table. |
||
|
|
84062744ee |
Zone titles for the 10 zones that can have one, and a null for the 18 that cannot
Driver, 2026-09-27: zone titles must be GENERATED from already-translated parts, because UPG-100 is
becoming a pattern and a hand-translated title is a cost paid again for every project list (7000 / 4700
/ 3000 / 2000 / 700) forever. The Arabic map reads English today because every label is a hand-written
English string.
★ MEASURED BEFORE DESIGNING, and the measurement killed the more attractive half of the idea:
country_code_label from the records API all 175 codes, every locale (formal names)
Intl.DisplayNames({type:'region'}) all 175, every locale THROWS on ISO-3
Intl.DisplayNames({type:'language'}) 213 of 2242 = 9.5% ECHOES THE CODE
LANGUAGE IS OUT. `.of('kfa')` returns the STRING "kfa" rather than throwing, so a miss prints a raw code
onto a printed poster with nothing raising a hand — and the 90.5% miss is structural, not a gap to fill:
unreached peoples speak small languages by definition and CLDR carries the big ones. This module never
names a language.
TIERS, stated in the module because a generator that silently produces a bad title is worse than one
that declines:
TIER A (3) country_code -> one country name. GENERATED
TIER B (7) country_codes -> n names + a joiner. GENERATED
TIER C (18) everything else NULL, caller keeps its label
Tier C cannot be done from countries: the 12 India/China zones are pg_slugs whose every record is ONE
country (measured: asia-india-kerala 78/78 IND), so countries would render "India" twelve times and
could not tell "Malabar Coast" from "Northwest India" — those labels are SUB-NATIONAL, and Intl has no
subdivisions. The curated-table-vs-keep-English choice is the Driver's (repo's digest item D). Returning
null until then means nothing regresses.
⚠ AND I HAD THE SOURCE PRIORITY BACKWARDS, corrected by looking at what the two sources actually say.
I preferred the records' label because it is already localized and needs no code mapping — both true,
and it generated "Lao People's Democratic Republic" for the zone whose hand-written label was "Laos": a
generated title WORSE than the one it replaced. 28 of our 175 labels are formal forms like that
("Islamic Republic of Iran", "People's Republic of China"). Intl gives the short common form in every
locale, so Intl is now first and the records are the fallback. The ISO-3→ISO-2 mapper is INJECTED, not
imported, because regions-plugin owns that table (their card) and it has not landed: without it a
3-letter code falls through to the formal name, with it the titles read "Iran + Yemen + 11 more".
THE ONLY NEW TRANSLATED STRINGS ARE TWO: zone_join "{a} + {b}" and zone_join_more "{a} + {n} more", one
pair per locale block in packet-strings.json (22 keys across 11 blocks). That is the entire translation
cost for every project list that ever uses this — a new language stays a NEW BLOCK, never a code change.
VERIFIED against the real 28 zones in en AND ar, with live Arabic records from the API:
Laos -> لاوس | Middle East Core -> إيران + اليمن + 11 أخرى | Maghreb + Egypt -> الجزائر + ليبيا + 5 أخرى
9 generated, 18 declined, in both locales. Every decline path returns null rather than a code or a
partial title, including a country_codes zone where one of the names it would SHOW is missing — a title
naming two of three countries and dropping the third silently misinforms and cannot be told apart from a
correct one downstream.
NOT WIRED IN YET: this is the generator only. The caller change (legend + poster + packet header) and
the deaf zone — global by construction, which breaks the geography-first rule and which the Driver says
is unsolved — are the next steps on the pattern card.
|
||
|
|
3ef164c014 |
The deaf zone could not be generated at all, and five more resolved to nothing
check:packets-all reported "zones covered : 2 of 28", which reads as twenty-six nobody had got round to. Six of those twenty-six could not be generated by ANY command, for two causes, and the report could not tell the difference between a gap you close by working harder and a gap you cannot close at all. 1. `deaf` WAS UNREACHABLE BY CONSTRUCTION. allZones() looped three keys of clusters.json — lists (13) + asia_lists (12) + uncovered_lists (2) = 27 — and the file carries zones in a FOURTH, deaf_lists (2), which legend-tree.js collapses into ONE unified leaf (affinity bloc A017). legend-tree.js:106 states the real arithmetic: "13 + 12 + 2 + 1 deaf = 28". So `deaf` was a poster and a legend row with no obtainable packet: ONLY=deaf exited 1, "no zone matches". Fixed by enumerating through leafZones(buildLegendNodes(...)) — the SAME seam the poster generator and upg-100-list.vue use, so all three now agree on what a zone is. Leaves do not carry selectors, so they are re-attached by id exactly as generate.mjs:2801-2808 does it. 2. THE FIELD LIST WAS PART OF THE SELECTOR CONTRACT AND NOBODY HAD SAID SO. The records query asked for country_code and no other selector field, while selector-evaluator reads three more: rop1 (affinity_bloc -> deaf), wagf_block (west/east/central-africa) and wagf_region (europe, and the southern-africa-islands union). Those six zones evaluated to an EMPTY set for every record and were skipped as "no groups resolved" — which reads as missing DATA and was missing FIELDS. The tell: the records cache holds 123 groups with "Deaf" in the name and evaluate() still returned 0 slugs. Verified both directions against the evaluator — 0 slugs when a record lacks the field, the correct slug (and only that one) when it carries it, for all six selectors. The list is now DERIVED from presentation fields + the evaluator's own requirements, so a new selector type is a change in one place rather than a superset someone must remember to widen. 3. OFFLINE=1 WAS DOCUMENTED AT THE TOP OF THIS FILE AND COULD NEVER HAVE WORKED. loadRecords() has an OFFLINE branch that needs no API, but the fail-closed guard ran first and exited 1 without consulting it. The note above that guard records it being taught about PACKET_RECORDS for this exact reason; OFFLINE was never added. Worse than an undocumented flag: it sends a reader down a route the program refuses. Any env var that makes the API unnecessary is now named at the guard. MEASURED, not assumed: an ordinary zone still builds identically through the new enumeration (Tibet, 43 groups / 7 pages, check-packet-render PASS with --expect-funnel), all 28 zones now carry a selector, and OFFLINE=1 generated 22 of them from the cache. The other six need a records source carrying wagf_block/wagf_region/rop1; the cache predates the fixed field list, so their render is still unverified and the gate now says so by name rather than by count. |
||
|
|
0eae411edc |
The funnel stops claiming a coverage gap it cannot measure
I PUT A CONFIDENT WRONG NUMBER ON THE PAGE AND AM TAKING IT OFF. The footnote read "21 of the 2,107 are not yet in any list". That 21 is the total minus the sum of the DECLARED `count` fields in clusters.json — a claim about that file's bookkeeping, not about membership. I wrote it, asserted nothing about it, and shipped it twice. TRYING TO VERIFY IT PRODUCED A SECOND WRONG NUMBER, which is what settled the question. Resolving every selector against the packet records gives 418 in no geographic list, not 21. But that cache carries only TWELVE fields — no wagf_region, no wagf_block — so 13 of the 27 selectors cannot resolve against it at all, and deaf_predicate matches 0 of the 123 deaf records that ARE in it. Both figures describe the FIXTURE, not the product. Nothing on disk can answer this. AND THE QUESTION IS ILL-POSED BESIDES. clusters.json has FOUR buckets, and allLists() reads three: the two DEAF lists (61 + 62 groups, with their own poster) are excluded. Summing all 29 gives 2,209 against a source total of 2,106, so the deaf lists OVERLAP the geographic ones — a deaf group in West Africa is in both. They are a second, cross-cutting partition, organised by disability rather than geography, which is very likely WHY allLists() leaves them out: including them in a zone-to-list map would double-count. So "in no list" has no single answer until someone says which partition is meant. funnelPage already falls back to the provenance-only footnote when `unassigned` is absent, so removing it is the whole fix: the page now says "Counts from IMB / peoplegroups.org, captured 2026-07-16." and stops there. The share line is untouched — "1 of 27 lists · 2% of the 2,107" is provable. BOTH PATHS CHANGED TOGETHER. The Node generator computed the same figure; leaving it would have made the two packets disagree about page 2. CARDED FOR THE DRIVER, because two of these are product questions and not mine: whether a packet counts 27 geographic lists or 29 including the deaf ones, and whether "Central Asia + Pakistan" is a prayer list rather than a residual bucket. It has 55 groups, contiguous=true with contiguityExempt=false, a country-code selector, and the poster wall already files it under "Central Asia" — it is larger than four accepted lists (Europe 55, Oceania 53, SE Asia 50, Southern Africa 39) and reads exactly like them. Its neighbour uncovered-scatter is the genuine catch-all: 8 groups, contiguous=FALSE with contiguityExempt=TRUE, and that exemption flag is the tell. So "27 or 25" was a false binary and 26 is live. Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and the script check; the footnote no longer contains "not yet in any list"; the share line is unchanged. ⚠ NO BUNDLE REBUILD IN THIS COMMIT — dx-shepherd's shot window forbids public/ churn while rega reshoots the README screenshots. This is source only; whoever builds after the all-clear produces the matching artefact. |
||
|
|
6d6b656721 |
A packet says which list it holds, in the reader's language
TWO FINDINGS FROM rega'S ?list= RENDER, and the first one is a hazard I created in the same commit where I argued against it. I MADE PACKET_LIST FAIL CLOSED because "a packet that showed the wrong list is printed and handed to someone" — and then wrote the frontier packet to the SAME FILENAME as the default-list packet of that zone and language. rega measured the result: out/packets/ held frontier packets under the DEFAULT names, beside an `ar` file that was still the default list, and nothing in the name could tell them apart. Guarding the input while leaving the output ambiguous protects against asking for the wrong list, not against being unable to tell what you got. Fixed both ways. The list is in the FILENAME (asia-china-tibet-autonomous-region.frontier.hi.html) and STAMPED INTO THE DOCUMENT as data-packet-list, because a filename can be renamed, copied or emailed while the packet itself should be able to say what it is. Same reasoning as data-packet-build: out/ is gitignored, so a sweep grades whatever files happen to be there, and a packet that cannot describe its own provenance makes an accidental set indistinguishable from a current one. The gate prints it too. THE LIST'S LABEL WAS ENGLISH IN EVERY NON-ENGLISH PACKET. "Project 4700: frontier people groups" sat among Devanagari labels in a Latin serif face, because it came verbatim from library/lists/lists.json — which carries ONE label per list and has no locale dimension at all. The gate passed because it counts rows, not the row's language. It is translated in packet-strings.json rather than by restructuring the shared lists.json: that file is the packet's OWN catalogue, where a new language is a new block and never a code change, and it degrades per key like every other string. An unknown list still falls back to the data's English label. THE NUMERIC DESIGNATION STAYS — "Project 4700" / "مشروع 4700" / "4700 计划" — because it names the campaign, while "frontier people groups" is prose. That is the same line check-i18n-never-translated draws around "UUPGs": translate what a bilingual reader would not dispute, keep what is an identity. rega'S MEASUREMENTS, all confirming predictions I could only compute: 6 pages, 37 of 43 records, rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37, bars 1080/640/415/184/152/26 identical in en and hi, share "2.1% of the 1,745" with no "1 of 27 lists", provenance-only footnote, fail-closed rc=1 on an unknown list. And the number I most wanted: share-to-footnote clearance 314px en, 222px hi — so hi+frontier, the tightest combination that exists (128px rows, gap down to 147px), still clears by 222px and no label wraps. Verified: all 18 packets in out/ pass sheets, rows==bars and zero-population. The 11 laos packets FAILED the zero-population assertion until regenerated — they predated the fix, which is the gate distinguishing stale artefacts from current ones exactly as data-packet-build was added to do. Default path unchanged (43 groups, 7 sheets, 5 rows). frontier renders 6/6 in en, ar and hi with the label in each script. check:packet-strings-parity PASS with the 5 new keys. |
||
|
|
75e31e0126 |
The six-row funnel can finally be rendered
ITS ONLY WITNESS WAS MY OWN HARNESS. The browser path honours the universal
`list` prop — upg-100-list.vue calls applyPeopleGroupList() at every fetch site —
but library/scripts/posters/packet.mjs had NO list support at all. So the
non-default-list branch of funnelPage, including the six-row shape where a list's
own total is inserted before the reader's row, could not be produced by the only
pipeline rega can drive. I had unit-tested it through a script I wrote about it,
which is exactly the state I have been objecting to in other people's gates.
rega offered to shoot a ?list= packet and there was no command to give them.
PACKET_LIST=<id> closes that. Rendered for the first time, and it is right:
rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37
bars 1080 / 640 / 415 / 184 / 152 / 26 (six rows, six bars)
label "Project 4700: frontier people groups" between the network's list and
the reader's 37
share "This packet is 2.1% of the 1,745" (no false "1 of 27 lists")
foot provenance only, no coverage claim
row gap auto-adjusted 218 -> 147px for the extra row; last row stays 14px so
the share still reads as its conclusion.
IT READS THE MEMBERSHIP FILE FROM DISK, not through loadListMembership(), which
fetches from the asset pool over HTTP — there is no server in a Node build. Same
file, same slugs, same counts.doxa.
AND IT FAILS CLOSED: an unknown or empty list exits non-zero rather than quietly
producing a packet of every group. peopleGroupList.js makes the same choice for
the same reason ("a map that silently showed all 2,107 while an embed asked for
Project 700 would be showing the wrong list with no sign of it"), and a packet is
worse than a map — it is printed and handed to someone.
⚠ buildFunnel() IS NOW A FUNCTION, NOT A CONSTANT, because it depends on a list
resolved after the records load. It mirrors upg-100-list.vue's packetFunnel()
exactly: the default list is the only one the zone catalogue describes, so it is
the only one that may claim "1 of K lists" and an unassigned count.
── AND A CORRECTION TO MY OWN VERIFICATION ─────────────────────────────────
"Numerals untouched" is about DIGITS, not GROUPING, and I verified the digits and
assumed the rest. rega measured the rendered hi sheet: Indian grouping,
"8,28,85,63,969 लोग" (lakh/crore 2-2-3) rather than Western 3-3-3. That is
CORRECT hi-IN formatting, so changing it would be worse — the real risk was never
the grouping but INCONSISTENCY, the same number printed two ways in one document.
Measured and it is consistent: num() is the only formatter in the renderer, so the
funnel's populations and the card pages' populations group identically — confirmed
against the rendered hi packet (card populations 1,70,000 / 3,37,000 / 8,10,000,
Indian-grouped like the funnel's). Only values at or above 100,000 can distinguish
the systems at all; my first check classified "1,400" and read a mismatch that did
not exist.
Verified: default path byte-identical (43 groups, 7 pages, 5 rows, 5 bars); all
four fixtures PASS with rows==bars; PACKET_LIST=frontier gives 37 of 43 records
and 6 pages; check:packet-strings-parity PASS; vite build clean, bundle unchanged
(the change is Node-side).
|
||
|
|
127a2e0930 |
The funnel states the list it was actually drawn from
A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (
|
||
|
|
9d56943fae |
A complete India ADM1 (36 states) is staged beside UPG-100's 34-unit partition
The served gb-IND-ADM1.geojson is the 100-list's curated partition (Mizoram and Nagaland excluded, listId-tagged), so a map colouring every state had data for 36 and shapes for 34. data/boundaries/ gb-IND-ADM1-full.geojson is all 36 from geoBoundaries gbOpen (boundaryID IND-ADM1-1811400, DataMeet / Election Commission of India, CC BY 2.5 IN), with source and licence in gb-IND-ADM1-full.meta.json; generate-tree stages both, and the dev server serves data/ directly. Reproducible with library/scripts/boundaries/source-full-adm1.mjs (coordinates rounded to 3 decimals: 1.84 MB). The curated file is untouched. |
||
|
|
e3d25ff6db |
The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
|
||
|
|
65017fefa4 |
The prayer packet shows how its list was narrowed out of all the earth
Page 2 of every packet is now the funnel: 12,379 people groups on earth, 7,341
unreached, 4,754 frontier, 2,107 on this prayer network's list, and N of those
in the reader's hand. A partner holding one list can see where it sits in the
whole, which is the argument the packet is making.
★ IT RENDERS NUMBERS IT DID NOT COMPUTE. Every count is read from
data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates
from the IMB CSV and the live network list and which already drives the Vision
Card. Re-deriving them in the packet would give one fact two sources; they would
drift, and two internally-consistent surfaces disagreeing is worse than one
wrong number because a reader cannot tell which to trust.
K AND THE SHARE ARE MEASURED AT RENDER TIME, NEVER TYPED, and this mattered:
the brief said "~30 lists of 50-100" and the catalogue says K=27 with counts
from 8 to 118 (west-africa 118, asia-india-rajasthan 116, uncovered-scatter 8).
Only about half sit in that band, so no copy claims it. The page states what the
data can defend -- "1 of 27 lists" and "N of the 2,107" -- and a footnote states
plainly that 21 of the 2,107 are in no list, because a partner adding up rows
would find the gap anyway. Reported to the Driver; K=27 pending his ruling on
whether the two residual buckets count.
A SHARE MUST NEVER ROUND TO ZERO. One decimal gave the single-group fixture
"0.0%", which reads as "this packet covers none of them". Verified exhaustively
over all 2,108 possible list sizes against 2,107: no size prints 0 and none is
malformed. 1 group -> 0.05%, 20 -> 0.95%, 21 -> 1%, 43 -> 2%, 118 -> 5.6%.
NUMERALS STAY WESTERN, as required. num() is toLocaleString(lang), and CLDR's
default numbering system for the bare ar/hi tags is latn -- verified 12,379 and
2,107 in both. Reusing num() rather than writing a second formatter is also what
keeps the funnel's digits identical to every other page of the packet.
PAGE COUNT NOW COUNTS THE ARTIFACT. packet.mjs computed
`1 + ceil(n/PER_PAGE) + 2` under a comment claiming the number was "asked of the
renderer, not recomputed here". It WAS recomputed, and the funnel page silently
made it wrong by one -- exactly the drift the comment warned of. It now counts
the emitted sheets.
THE GATE IS TAUGHT, NOT LOOSENED. check-packet-render went red on every fixture
("43 cards should lay out as 6 sheets, but the packet has 7") -- correct
behaviour on a structure it had not been told about. The wrong fix is to accept
N or N+1: that assertion exists to catch silently dropped people groups, and a
±1 tolerance is the slack a lost list page hides in. So the funnel is DETECTED
via .funnel-title and added as a known term, and a funnel page that renders its
title but no share figure is now its own failure -- a number printed but not
asserted on is decoration.
The page is OPTIONAL: buildPacket omits it when no funnel data was supplied, so
every existing caller keeps working and a host without the data still gets a
correct packet. Better a 4-section packet than one asserting figures it could
not read. A multi-list batch selection keeps the steps but drops "1 of 27",
which is false for a custom selection, while "N of the 2,107" stays exactly true.
Verified: check:packet-strings-parity PASS (31 string keys x 11 locales, my 10
new ones present and translated in all 10 non-English blocks, 0 declared-absent
additions). A partial locale with 3 of 10 funnel keys translated renders those 3
translated and the other 7 in English, nothing blank. Arabic packet generated:
dir="rtl", funnel is sheet 2 of 7, all four steps carry Western digits. All 16
generated packets satisfy the corrected sheet arithmetic, including the 11 older
laos packets that have no funnel page. Mutation-tested: dropping a list sheet is
caught by the independent page-header witness (20 DROPPED), a funnel page with no
share figure is caught by the new assertion, and removing the funnel page passes.
BUNDLE=my-upg-100-list vite build 319 modules clean.
NOT verified in a browser: check:packet-render launches Chromium, which the
Playwright policy reserves to rega. The simulations above parse the same
attributes from the same generated HTML.
|
||
|
|
79bd6d2ac3 |
public/ is one folder: the output tree plus a README, with the Plugin Studio as its front page
Driver decisions (cards BUILD-OUTPUT-IS-ONE-FOLDER, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE).
- public/ holds only <tree>/ and a generated README.md. The build no longer writes public/index.html,
public/assets/ or public/__gallery/; `rm -rf public && bun run build` recreates exactly these two.
- The Plugin Studio is <tree>/index.html (tree-base "."), replacing the staging-forge chooser; nothing
is written above the tree. check:staging-lang, which tested that chooser, is retired with it.
- Assets are gathered in a temp directory and the fetched allowlist staged into <tree>/assets/ as
before. fonts/, images/, brand/ and legacy/ existed ONLY in public/assets: they are sources and move
to data/ (git mv). Poster previews keep their tracked sources in plugin-designs/*/assets/previews
(all 378 verified present).
- __gallery/ is archived out of public/ (a copy kept in the umbrella's _qa-cycle/archive) and out of
the build; generate-gallery renders only on request, into a directory you name, never public/.
- Native shells load the output folder: capacitor webDir public/<tree> (cap copy needs an index.html
there), Tauri frontendDist + window url relative to it. internal/sync-app-shells.mjs keeps both in
step with the tree's name on every build, so the coming rename stays one config line; the Capacitor
start page resolves its leaf tree-relative.
- dropin.include now lists only extras (README.md): the tree is always shipped and never named twice;
excludes are tree-relative ({tree}/...). clean.mjs removes the old root entries from existing
checkouts; the build lock and public/.gitignore's rules move out of public/.
- check:wp-embed read the BUILT manifest from gates:pre, before the build, so a from-scratch build
could never pass; it runs in gates:post and reads the tree name from config.
- Stale bundles refreshed by the clean build (key-cities, template-bundle, wall-of-people-groups);
the CLI doc's quoted scripts re-synced; capability exemplars regenerated; the '#' note leaves
check:stale-sweep-claim's command (it was already in //scripts).
Proof: rm -rf public && bun run build -> public/ = README.md + doxa-maps/; check:sri, check:drop,
check:parse, check:public-hygiene, check:quoted-scripts, check:capability-exemplars PASS; all 57 pages
(the front page + 56 leaves) mount under /doxa-maps/ on this tree. check:facet-cascade is red as on main.
|
||
|
|
412602cdef |
Five more gate scripts read the tree's name from the build config
The research verify-* scripts and research-map-plus-grid's two gates hardcoded 'doxa-maps' (11 literals); they now import TREE_NAME from internal/_tree-name.mjs like the rest of the tooling. |
||
|
|
7f700935c5 |
Simple Research + Big Picture: the host's style stops at the box, and the last English goes
TWO FIXES THAT BOTH SHOW UP ONLY WHEN THESE PLUGINS ARE SOMEBODY ELSE'S GUEST. 1. ATTRIBUTE FALLTHROUGH. These SFCs mount AS custom elements, so a style the host puts on the tag lands on the component's own root element. MEASURED with a host style of "display:flex; position:relative; height:100%": that exact string appeared verbatim on the inner root div of people-groups-grid, stack-demo, research-map-plus-grid and dashboard-plus-grid. The gallery sets one on every tab, and the same fallthrough collapsed the Prayer Walk Planner to 76px. inheritAttrs:false on each root — a host styling the TAG is styling the box, not our internals. AFTER: the inner root carries no inline style on any of the four, and each still renders full height (2997-4010px in the harness), so the guard did not become the collapse it prevents. Worth knowing for the next probe: shadowRoot.firstElementChild is a <style> node — Vue injects styles first — so a probe that trusts it measures nothing and reports "no fallthrough, nothing to fix". Take the first non-style child. 2. THE RESIDUAL CHROME the in-gallery re-measure found: the dashboard title, Align Dashboard and its tooltip, World, Vertical Legend, groups in scope, the per-row aria templates (Filter by / Remove filter / groups), and the map drawer's aria label. Catalogued in all 11 locales and read through tStatic, reusing existing wording where the catalogue already had the word. The aria TEMPLATES matter more than their size suggests: they repeat per row, so one untranslated template is dozens of English strings in a screen-reader's ear. |
||
|
|
33bd7ca387 |
All People Groups loads fast on a light map; the Vision Card becomes a true funnel from data
ALL PEOPLE GROUPS. The Driver: "not even loading, takes a very long
time", and its DOXA pins were dark on a dark map.
- It downloaded the 7.5 MB people-groups-slim.json (22 columns) to draw
ten fields. It now fetches people-groups-points.json: the same rows in
column order, repeated strings in dictionaries, coordinates at 3
decimals. 0.74 MB, generated deterministically by
library/scripts/people-groups/build-points.mjs and staged into the tree
by generate-tree. It falls back to the old file on an older tree.
- Pins no longer wait for the live prayer API. Every pin draws by GSEC
from the file, and the DOXA overlay recolours when the API answers. The
DOXA count reads "…" until then, not 0.
- Named style 'paper' (Mapbox light-v11 / MapLibre positron) instead of
'ink'. The four categories keep their meaning; yellow and green are a
step darker, and pins get a thin dark ring, so they read on near-white.
- Its words are in all 11 locales (allPeopleGroups.json), with a guarded
useI18n and values passed to vue-i18n so {n} is not blanked.
Measured on a served built tree, 3 fresh runs each, median time to first
rendered pins: 8.3 s before, 5.2 s after, on localhost. This box renders
GL in software, so most of the remaining time is map start-up; the 10x
smaller download matters more on a real connection.
DOXA VISION CARD. The Driver: "the data doesn't make sense … super false
information". The card rotated four typed-in figures with no source.
It is now a proportional funnel, every number computed from data by
library/scripts/people-groups/build-vision-funnel.mjs:
12,379 people groups (IMB rows: one group in one country)
7,341 unreached (IMB LostnessPriority 0-2, < 2% evangelical)
4,754 frontier (LostnessPriority 0, < 0.1% evangelical)
2,107 DOXA groups (the DOXA prayer list, snapshot saved in data/;
1,745 of them frontier)
The first three nest; DOXA groups do not all sit inside frontier, so the
card says how many do. The rules are on the card ("How these are
counted") and in HOW-THE-NUMBERS-ARE-COUNTED.md, which also explains why
the total is not the 16-17 thousand of Joshua Project's count. Material
light, all 11 locales, right-to-left in Arabic, sources and dates shown.
The old fake stats are removed from the instances file, the frontier
page, the manifest, the staging page and the gallery.
CHECKS.
- check:vision-card-numbers recomputes the funnel independently (own CSV
reader, the published rules) and compares with vision-funnel.json and,
with --url, the rendered bars. Negative-tested: a corrupted count exits
1. On the built tree: bars 12,379 / 7,341 / 4,754 / 2,107 and "1,745 of
them are frontier", all matching.
- check:map-embed-cells measures PRODUCTION-MATRIX cells B and C for the
seven gallery maps on a foreign origin; C proves the host's token is
used by handing the host a distinct token and checking every Mapbox
request. 14 of 14 pass.
|
||
|
|
51a3abe482 |
packets: render the MIXED photo case, and assert the fallback says something
Some checks are pending
build-gate / build (push) Waiting to run
Every packet ever produced was all-photo (laos: 95 photos, 0 typographic) or
all-typographic (every fixture: 0 photos, N typographic). THE MIXED CASE HAD
NEVER BEEN RENDERED — and it is the realistic one. fetchPhotos already degrades
per-photo on a failed fetch, so any zone where a few images 404 puts exactly
that layout in front of a reader.
PHOTOS_EVERY=N keeps only every Nth photo, deterministic by INDEX rather than
random so the same command always yields the same packet — a fixture that
differs run to run cannot be a gate. The build stamp gains `mixedphotos`,
because a deliberately mixed packet and a partly-failed photo fetch deserve
opposite verdicts and nothing could tell them apart.
★ IT IS ALSO AN I18N SURFACE NOBODY HAD SEEN. The fallback card renders
t('no_photo'), translated in all 11 catalogs, and the gate COUNTED .nophoto
elements without ever looking inside one. A blank grey box passed every
assertion: `photos + typographic === cards` held, the page count held, nothing
was broken. Counting proves the element exists; it says nothing about whether
the reader sees a label or a hole.
MEASURED on the first mixed packet (PHOTOS_EVERY=3 over laos, 31 photos and 64
typographic): all 64 read "no photo available", 0 blank, all 64 with a real box.
In Spanish "sin fotografía disponible", in Arabic "لا توجد صورة متاحة" — 63 and
64 cards, 0 blank in both.
MUTATION-TESTED: emptying the fallback to `<div class="nophoto"></div>` fails
the gate with "63 of 63 typographic card(s) render an EMPTY box", exit 1.
Restored, and check:packets-all stays green across all five shapes.
One trap worth recording: the language variable is PACKET_LANG, not LANG. The
file warns about it — process.env.LANG is the shell's POSIX locale
("en_US.UTF-8") — and I still lost a run to it, regenerating three full-photo
packets that proved nothing.
|
||
|
|
2f96f04b49 |
packets: fixtures at the PAGE BOUNDARY, which nothing had ever rendered
Some checks are pending
build-gate / build (push) Waiting to run
check:packets-all reported "every generated packet renders, in every locale present" and passed on 11 packets. All 11 were the SAME ZONE (laos) in 11 locales. It proved one zone's layout eleven times. The answer is not more zones -- 28 zones x 11 locales is a network fetch per zone and ~100MB of artefacts. It is more SHAPES, chosen where the layout can actually break, built from committed records with no network. ★ THE SHAPE THAT MATTERS IS THE PAGE BOUNDARY. packet-render.js:76 sets PER_PAGE = 20 and paginates `for (start = 0; start < groups.length; start += PER_PAGE)`. laos has 95 groups and tibet 43 -- both mid-range remainder cases, so the boundary ITSELF had never been rendered. Three fixtures, each a slice of the committed tibet records, so they are byte-deterministic and need no API: exactly 20 -- one full page; an off-by-one yields a second, empty one 21 -- two pages, the second holding a single card 1 -- the minimum a zone can have MUTATION-TESTED, and this is the argument for the whole change. Flipping the page loop to `start <= groups.length` -- an off-by-one that only shows when the count is a multiple of PER_PAGE: fixture-edge-exactly-20 FAIL <- the only one of 15 fixture-edge-21-groups pass fixture-edge-one-group pass fixture-tibet pass laos x 11 locales pass A real pagination bug would have shipped completely green under the old suite. The failure is specific too: "20 people cards should lay out as 4 sheets but the packet has 5" and "list pages disagree on the zone total: 20 vs 21". ⚠ EACH BUILD OVERWROTE THE LAST, which is why this is a script and not a line of shell. packet.mjs names its output for the ZONE, not the fixture, so four fixture builds against one zone leave ONE file -- and check:packets-all would go on seeing a single shape while reporting success. Each output is renamed to its fixture's own name so all of them coexist and every one is evaluated. packet:ci now builds all four. check:packets-all goes from 11 packets in 1 zone to 15 across 5 shapes; it still reports "5 of 28" and names what it does not cover. |
||
|
|
3fd1fbcaea |
Move the private field-access dataset out of the repo
data-private/ was a TRACKED folder, so field-access-visas.json would ship with the repo the moment it goes public. A tracked folder is not private. The table generator no longer writes inside the checkout: private tables go to DOXA_PRIVATE_DIR (a folder outside the repo) or are skipped. data-private/ is gitignored so it cannot be re-created by accident. The file is removed from HEAD only; it is still in history (5 commits). Publish from fresh history or scrub it before the repo goes public. |
||
|
|
3c17245c73 |
packets: give CI something to check, instead of a sweep that always SKIPs
Some checks are pending
build-gate / build (push) Waiting to run
check:packets-all is in gates:post. CI runs it on every build. It has SKIPped every single time, because library/scripts/posters/out/ is gitignored — both the records cache and every generated packet — so a clean checkout has none. Every packet assertion in this tree has therefore never once been evaluated there: the 12 prayer points, the wheel's 12 labels, page 1's geography paths, the print layer, the build stamp. Several ticks of work accumulating on a gate that had only ever graded files in my own working tree. A SKIP that is always taken is indistinguishable from an unwired gate — and the wave-membership census cannot tell them apart, because membership measures invocation, not evaluation. That census is how check:packets-all was created in the first place, which makes this the same class recurring one level up. I raised this at the end of the last tick and deferred it on the premise that closing it "adds a live-API call to every build". The premise was wrong. packet.mjs already had an OFFLINE mode, and a fixture needs no network at all. The fixture is the smallest zone — Tibet, 43 slugs, 20KB — with image_url stripped so CI can never fetch 43 photos from someone else's CDN. It is DERIVED, not hand-maintained: membership comes from the committed clusters.json, so check:packet-fixture regenerates and content-compares it. Re-adding an image_url fails; dropping a record fails naming the slug. A fixture packet stamps itself "fixture", and "fixture nomap" in CI, which has no poster sources either. `fixture` implies nophotos in the gate — without that it reported "every source record carries an image_url", a true sentence about real records and a false one about this packet. A real bug in my own change, caught only by running the script through its REAL invocation path: packet.mjs fails closed on a missing VITE_API_BASE_URL BEFORE loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a tree that had everything it needed. Every hand-test had passed because I was supplying a dummy host. The guard now exempts fixture builds and still fails closed otherwise. From a genuinely clean state — the whole out/ directory moved away — the sweep now reports "1 packet(s) checked, 1 passed". i18n verified through that same path: a partial block (1 string, 5/12 points, 3/12 rhythms) renders 12 points, 12/12 wheel labels, wheel 3 translated and 9 English, 0 blanks. check:packet-fixture joins gates:post (34 -> 35), README quote re-synced in the same commit. Wave 35/35, packets-all 11/11 here and 2/2 in the studio. |
||
|
|
64fed43449 |
packet: stamp how a packet was built, into the packet
Some checks are pending
build-gate / build (push) Waiting to run
out/packets/ is gitignored, so check:packets-all grades whatever .html files happen to be sitting there — an accidental set, not a current one. On this machine that was 11 freshly generated packets. In doxa-maps-plugin-studio, which shares main with this worktree, it was two NOPHOTOS=1 layout probes of mine from earlier ticks, and the sweep was RED there for anyone who ran the wave. The gate was honest about each file it checked; the set it checked was accidental. The underlying problem is that a fast layout-iteration build and a shippable one were byte-indistinguishable. The packet carried lang and dir and nothing about its own provenance, so the only way to tell them apart was for the person running the gate to remember and pass PACKET_EXPECT_PHOTOS=0. packet.mjs now stamps data-packet-build with the flags it generated under, and the gate reads it. The GENERATOR writes it rather than the gate inferring it: had the gate guessed "no photos present, so probably a probe", it would have excused genuine photo-fetch failures. Tested explicitly — a packet stamped "full" whose map svg is then stripped STILL FAILS. The stamp records intent and cannot excuse a defect the build did not declare. Deliberately not a timestamp. An mtime staleness rule was considered and rejected on evidence from this same session: packet-strings.json was 303s newer than the packets while byte-identical to HEAD, because a probe had restored it from backup, so an mtime rule would have failed a correct tree — the exact mistake fixed two commits ago in the library-index gate. Content comparison is not available here (regenerating hits a live API and takes minutes), so the build records its intent instead of the clock. The studio sweep goes from "2 checked, 0 passed" to "1 checked, 1 passed", with the packet self-declaring build="nophotos nomap". Env vars stay as an override for pre-stamp artefacts, and the declared-deliberate note now names whichever source actually declared it rather than always crediting the env var. All 11 locales stamp "full". i18n re-verified with a partial block (1 string, 3/12 points, 6/12 rhythms): wheel 6 translated and 6 English, 12 point rows with 6 translated, 0 blanks. Wave 34/34, packets-all 11/11 here and 1/1 in the studio. |
||
|
|
e130cb672a |
packet generator: say when page 1 has no map, instead of printing a tick
Some checks are pending
build-gate / build (push) Waiting to run
`✓ laos 95 groups 8 pages 0 photos` — for a packet whose first page is blank. The page count stayed 8 because a blank sheet is still a sheet. The map one-pager is lifted from out/posters/<zone>.html, which a separate manual step writes. When it is absent packet.mjs deliberately still builds, and that fallback is right — but it was silent. The empty-groups branch three lines above already warns and skips; the map branch had the same shape and no warning. A gate catching this is not the same as the tool reporting it. The gate added last tick runs later, optionally, and only when someone invokes it. The generator prints its tick at the moment of production, which is the one output an operator is guaranteed to see. Now it prints ⚠ with the reason inline and repeats it in an end-of-run summary naming every affected zone and the fix — a per-line mark scrolls off in a 27-zone run, and this is the one fault that cannot be seen by counting pages. Both silent branches are covered, distinctly: a missing poster reports "no out/posters/laos.html"; a poster that exists but carries no <svg> — a truncated generate run — reports "out/posters/laos.html has no <svg>". An operator can tell "never generated" from "generation broke". Also adds the `packets` script. Its absence is why the posters -> packets dependency was never expressible anywhere: packet generation was only ever a hand-typed node invocation, so nothing could state what it reads. Measured in both repos. doxa-maps-plugin-studio has 0 generated posters against wt-packet's 28 — out/ is gitignored in both, so posters are build artifacts and this is a missing build step, not a repo defect. A fresh studio run now warns; wt-packet prints a clean tick. The size difference corroborates: 0.4 MB with the map, 0.1 MB without. i18n re-verified end to end since this touches the generator: a partial locale block (2 strings, 3/12 points, 4/12 rhythms) renders the wheel 4 translated and 8 English, 12 point rows with 4 translated, 0 blanks, clean tick. All 11 locales regenerated with photos — packets-all 11/11, each 12/12 wheel labels, 12/12 points, 13 geography paths. Wave 34/34. |
||
|
|
e8ad1d56e6 |
packet: a partial translation renders 12 prayer points, not 3
Some checks are pending
build-gate / build (push) Waiting to run
packet-render.js resolved the prayer points with `L.points || EN.points`. That is a whole-block fallback when the key is ABSENT, and TRUNCATING when a translator has done some of the 12 — a 3-element array is truthy. Measured at the rendered layer with a deliberately partial locale block: a page headed "12 Prayer Points" containing THREE, points 4..12 silently gone, including the Mark 5:1-20 passage point 4 prints in full. Nothing blank, no error raised. Invisible in all 11 shipping locales because every one of them is complete. It would have appeared on the next language added — and "a new language is a NEW BLOCK, never a code change" only holds if a PARTIAL block degrades per key, since half-finished is the normal state of a language in progress. Now merges per point and per field against English: English fixes the LENGTH and supplies every missing field, so a point translated for `text` but not `sub` keeps the English `sub` — and keeps its NUMBER, which the merge supplies. The sheet-sum invariant could not see this: a 3-point page is still exactly one sheet, so every number check-packet-render printed stayed correct while nine points were missing. It now measures and asserts the count. Mutation-tested — reverting the fix prints "prayer points : 2 / 12" and fails. All 11 locales regenerated with photos and swept: check:packets-all 11/11, each 12/12 points, including RTL ar and Devanagari hi. Renderer, generator and gate synced to doxa-maps-plugin-studio, which also lacked the .points-title and .wheel-title test hooks the new assertion needs. Also wires check:capability-exemplars into gates:post (33 -> 34 steps). It reads public/doxa-maps/manifest.json, a build artifact, so ordering mattered: `tree` writes the manifest and is &&-chained immediately before gates:post, and the gate SKIPs rather than fails when the manifest is absent. Full wave: 34/34. The quoted gates:post line in the README is re-synced in the same commit, because check:quoted-scripts goes red on the drift this commit creates. |
||
|
|
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
|
||
|
|
4cb04480cc |
Localize tab labels for it/ro/zh, and prove the catalog is actually read
Some checks are pending
build-gate / build (push) Waiting to run
120 keys across three locales: legend.json (12 each), search.json kinds (10
each) and tabs.json (18 each, previously absent entirely). Each locale follows
its own established vocabulary rather than a parallel one — Popolo/Popor/民族,
matching the terms already used in that catalog's detail and legend strings.
zh stays Simplified here, as the rest of this tree is, even though the prayer
packet's zh block is Traditional: a reader sees 民族 next to 祷告进度, never
the two files side by side.
Ceilings drop 94 -> 54 per locale. regions.json is the only i18n debt left in
the app: 54 keys of full sentences, three locales.
WHY THE NEW GATE. check:i18n-parity compares catalogs TO EACH OTHER, so it
certifies a file no t() call ever reaches — perfect symmetry, zero consumers,
green forever. Grepping for t('tabs. found ZERO call sites and tabs.json was
nearly recorded as dead. It is not: the only consumer builds the key
dynamically (SemanticTreeLegend.vue:40, 'tabs.' + (tab.i18nKey || tab.id)),
which no literal-prefix grep can see. The reverse error appeared in the same
pass — regions. and options. matched only poster-regions.mjs and a JS options
parameter. Static reachability is unavailable in both directions, so
check:tabs-localized renders the labels and reads them.
It asserts DIFFERENCE from English, not mere presence: te() falls back to the
hardcoded English label, so "shows some text" passes on the exact failure
being tested. Mutation-tested three ways — missing catalog FAILs, an all-English
catalog FAILs (first draft PASSed it, taking a per-pair exemption seven times),
one legitimate collision still passes with a note.
Both i18n gates now run in gates:post; neither was wired into the build before.
Also fixed, found by the build rather than sought:
- check-i18n-parity hardcoded an absolute path, so the studio copy silently
graded wt-packet's locales and reported PASS on a tree it never read. Now
repo-relative. That exposed the studio's own locale tree as a strict subset,
103 keys behind including buttons.json:share — the very orphan this gate's
header says it was written to catch, reappeared unseen in a second repo.
Synced forward: zero studio-only keys, zero text conflicts, lossless.
- index.json still declared field-access-visas with rows:1146, a leftover from
the commit that removed that table as private. The generator already filters
private tables; the committed artifact predated the filter. Removing the
declaration also removes the row-count leak. Copying the file in would have
reopened the leak.
- check:sfc-parse needs an installed node_modules and is the first pre-gate to
fail without one, so a dependency-less worktree reads as a parse error.
Documented, with the reason switching it to bun only appears to fix it.
|
||
|
|
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.
|
||
|
|
52b7a110d8 |
fix(posters): one bad zone no longer costs the other 28
Some checks are pending
build-gate / build (push) Waiting to run
generate.mjs threw on the first zone whose header overflowed, so a single bad sheet ended the run: 16 posters written, the remaining 13 silently unbuilt, and a non-obvious short count as the only symptom. Failures are now collected, the run continues, and it exits 1 at the end with a census naming every zone that has no poster. The guard stays loud — an overflowing header is a real defect and must not be downgraded to a warning — but it fails the RUN rather than truncating it. That change immediately surfaced a FOURTH broken zone the abort had been hiding. All four overflow the same way: oceania-indonesia 504 vs HEADER=460 (+44px) andean-caribbean 504 vs HEADER=460 (+44px) asia-china-tibet-autonomous-region 481 vs HEADER=460 (+21px) deaf 619 vs HEADER=460 (+159px) Root cause, found but NOT patched here: measureHeader() returns a fixed HEADER_BASE when SPARSE is off instead of measuring the zone's own content — so the per-zone measurement its own comments describe only runs in SPARSE mode. That is a separate fix with its own layout risk; carded rather than bolted on. Also adds publish-html.mjs, the missing half of the browser-rasterise fallback. That fallback fetches posters/<id>.html and NOTHING published it: out/ is gitignored and public/ had zero .html, so a zone with no image 404s today. The script writes zone poster HTML into public/assets/posters/, skips city-* posters (they need OSM tiles to regenerate, so their images must stay), and warns loudly about zones with no HTML instead of quietly writing fewer files. ★ IT IS NOT RUN AS PART OF THIS COMMIT, and the reason is worth recording. Measured with real git packs: webp 5.0 MB raw -> ~5.0 MB packed (already compressed; git cannot pack it) HTML 21.4 MB raw -> 5.78 MB packed (text, repetitive across posters) Swapping zone webp for zone HTML is about +0.8 MB PACKED — a net LOSS, not the 42 MB win the headline implies. The raw-byte comparison that made it look like a win (21.4 vs 5.0) and the gzip comparison that made it look better still (5.8 vs 5.0) both mislead; what git stores is the number that decides it. The real 22 MB is 176 city-* posters, which cannot be replaced this way at all. So the deletion this was leading to is deliberately NOT done: it would have made the repo bigger while being reported as a saving. |
||
|
|
4475581c1b |
Close the visa-data leak at its factory + harden the gate
Some checks are pending
build-gate / build (push) Waiting to run
The field-access-visas table (580 KB -- how workers enter closed countries) was found PUBLIC in three copies and LIVE-SERVED at 200: the key-cities bundle data dir, the research out/ dir, and a stale staging tree inside the served catalog. Root cause: emit() enforced PRIVATE_TABLES but the ROUTE bundle-copy loop did not -- a privacy rule enforced at one of two writers is not a rule. The loop now skips PRIVATE_TABLES; the redundant source copies are deleted (data-private/ keeps the one canonical file); and the hygiene gate flags the filename and visa_ fields as HARD findings in every mode -- this data class never rides the strict-tier softening. All three public copies and the live URL were purged first and verified 404 by anonymous fetch before this commit. |
||
|
|
7bba1c0771 |
feat(upg-100): the prayer card deck prints as CARDS, not as paper
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "the print dialog that opens up needs to show all the cards not in a
paper format but a CARD format, the aspect ratio that is used in the card
generator."
The deck set @page{size:letter portrait} and gave each card height:100%, so 95
cards printed as 95 mostly-empty 8.5x11 sheets — and with NO photo, because
image_url was never in the fetch. Measured: 95 of 95 groups DO have a portrait,
so the omission cost every card the one thing that makes it a prayer card.
Now the PAGE IS THE CARD. @page takes the stock's own inches, so one card is
exactly one page: 95 cards -> 95 pages at 180x252pt (2.5x3.5in). Print on
cardstock and cut, or send the PDF to a card printer. The stock is a user
choice, per the Driver's ask to "choose the layout of the print dialog to choose
the aspect ratio": poker 2.5x3.5in (default), postcard 4x6in, A6.
⚠ @page SIZE MUST BE EXPLICIT INCHES. Chromium SILENTLY falls back to letter for
a named size it does not know: `size: A6 portrait` yields 612x792pt with no
error of any kind, while `size: 2.5in 3.5in` yields exactly 180x252pt. The gate
therefore asserts the MEASURED MediaBox — a check that grepped the stylesheet
would pass a deck that prints as paper.
Card content follows doxa-life/promotional-material-generation, the Python data
pipeline feeding the InDesign template: per language it emits Description,
Religion, Area, Country, a QR to that group's page, and the URL. Ported to
JavaScript here so the deck generates in the map app on click. Description and
Area are NOT available from this API — measured 0 of 2107 records carry either —
so the card is built from what genuinely exists: portrait, name, country,
population, language, religion. An empty block reserved for data that never
arrives is worse than a card designed around the data there is.
QR is hand-rolled (library/packet/qr.js, ~200 lines): byte mode, level M,
smallest fitting version, mask 0. A symbol is pure computation, so it costs one
small file instead of a package and runs identically under Node and in a tab —
the standing constraint is "without adding a lot of dependencies on the
internet". A QR that does not scan looks exactly like one that does, so it is
verified by DECODING: an independent reverse-path decoder re-derives the data
placement, unmasks, de-interleaves and parses the header. 5/5 round-trip,
including version selection (v2 for a short URL, v3 for real ones).
I18N: the QR encodes the LOCALISED profile URL, so a Spanish card scans through
to the Spanish page — verified, pray.doxa.life/es/aheu. Strings fall back per
key; checked with a partial Spanish block ("Ora por ellos" rendering while
Language/Religion stayed English).
★ ONE BUG THE NUMERIC GATE PASSED, worth recording. The first deck rendered
"LAO", "thm", "EAN" — raw codes on a card a person is meant to pray from —
because normalizeRecords() flattens each { value, label } dict to its VALUE and
parks the label under <field>_label. A gate counting "4 rows rendered" passed
it: the rows were there, just unreadable. The gate now asserts the values are
HUMAN-READABLE, not merely present.
Verified by check:card-deck across all three stocks: 95 cards / 95 pages, 0
broken portraits, 95 QR codes with real symbols, 0 raw codes, 0 overflow.
|
||
|
|
301cc38fba |
feat(upg-100): print a prayer packet from the map legend
Some checks are pending
build-gate / build (push) Waiting to run
Driver, as a first-time user: "I select one and it gives me print options at
the top right of the legend... that's the only way that map would be usable."
The affordance was already built and switched OFF. The shared legend emits
'export' with 'poster' | 'packet' for the selected node, the profile wired
@export, and SHOW_EXPORT_BUTTON was held false with the note "until the export
packet is finished: the button currently produces a broken packet". That
precondition is now met, so the switch is on.
ONE RENDERER, TWO CALLERS. The pages move to library/packet/packet-render.js,
imported by BOTH the build-time generator and the browser export, so the printed
packet and the generated one cannot drift. The card asked for exactly this:
"reuse the existing print actions rather than growing a fourth implementation."
The extraction is behaviour-preserving — the generated packet is byte-identical
to before apart from leading whitespace (the code is now inside a factory), and
check:packet-render still passes 8/8.
★ THE PACKET IS GENERATED ON CLICK, NEVER SHIPPED. One packet is ~3.5 MB (photos
dominate) and 28 zones would add ~100 MB to a repo that ALREADY had a publish
blocked at a 74 MB asset tree. Generating client-side costs zero repository
bytes; the photos come from URLs the records already carry. This is the Driver's
instruction: "instead of fetching the poster from the public folder I want the
client side code to generate the poster real time when you click on the button
so it does not take a lot of mb storage." Bundle cost: +21 KB (494 -> 516 KB),
which includes all 12 prayer points and Mark 5:1-20 in full.
Printing goes through the SHARED openPrintFrame — a hidden same-page iframe, not
window.open(). A popup is blockable and drops the reader out of the map.
Two traps the browser path had to handle, both already known to this codebase:
- pg_slugs BYPASSES the shared selector evaluator (it intentionally throws on
that type, and 12 of the 27 lists use it). Same special-case the map and the
poster wall apply, so all three agree about who is in a list.
- evaluate() returns a Set, not an array.
VERIFIED IN A BROWSER, not by reading source (new gate check:packet-browser):
live records -> shared renderer -> page.pdf(), giving 8 sheets -> 8 pages at
612x792pt, 95 people cards, 12 wheel segments, Mark 5 in full, 2 metacamp.org
attributions, 0 page errors. I18n verified with a partial Spanish block: the
translated headings rendered in Spanish while Mark 5 and the attributions fell
back to English, per key, never blank.
Card BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST part (d). Parts (a) and
(b) shipped earlier. Part (c) — removing the People Group tab and retabbing the
legend — stays OPEN: that card is explicitly driver_gated on "what is the
legend's tree once Region/Zone/People Group are removed", and the current tabs
are wired to real hierarchy data.
|
||
|
|
fd2077625a |
feat(posters): capitals and major cities on the country maps
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for map detail: "a typical map poster needs details on the map that is not hard to see, but not so much that it becomes too complicated — cities and capitals makes sense." Natural Earth 1:10m, PUBLIC DOMAIN: no attribution to carry onto a printed packet, vendored so a build is reproducible offline with no runtime dependency. Trimmed at vendor time from 4.9 MB / 7,342 places to 170 KB / 2,573 (national + admin-1 capitals, plus cities >= 250k) because a poster cannot legibly carry 7,342 dots and the packet is downloaded. The layer is deliberately SUBORDINATE. It answers "where am I?"; the people-group pins answer "who do I pray for?", and they keep the only saturated colour on the sheet. Capitals are ringed dots (the cartographic convention), cities plain dots — shape and value carry the rank so no extra hue is spent. Reading order stays country > province > city > people group. Three things had to be right, each found by measuring rather than by looking: 1. RANK BY RELEVANCE TO THE SUBJECT, not by global importance. Sorting by capital-rank then population is the obvious choice and is wrong: the neighbours are usually bigger. On the Laos sheet that sort spent the budget on 12 Vietnamese and 8 Thai cities and printed exactly ONE Laotian place while 12 more sat available — a poster of Laos labelled with other countries' cities. Subject countries now win the budget. 2. DERIVE THE SUBJECT FROM THE PINS, not from the zone. zone.adm0 exists only on generated city/country zones; the hand-authored lists carry a selector in SEVEN shapes (pg_slugs, country_code, country_codes, wagf_block, wagf_region, union, deaf_predicate) and the most common names no country at all. Reading zone.adm0 produced an empty set and the whole sort silently did nothing. The pins always know their own country. 3. AVOID THE PINS USING WHERE THEY ARE DRAWN. Two distinct errors here. Testing the label's ANCHOR is wrong for a right-aligned label, whose glyphs run leftward from it — so the box is tested, not the point. And the de-overlap relaxation MOVES badges after projection, so proj(pin.lng,pin.lat) is not where the badge lands; this block therefore runs AFTER layoutLabels and reads laid[].px/py. With the naive tests, 7 of 25 labels sat within 14px of a pin and Saravan landed 1.4px away while the check reported it clear. Now: 291 labels across 16 posters, ZERO overlaps. A city that cannot fit clear of a pin is dropped — a missing city costs nothing, an obscured people group costs the poster its purpose. ⚠ ROADS WERE REQUESTED AND DELIBERATELY NOT SHIPPED. Natural Earth's 10m roads layer was fetched and measured: ZERO features for LAO, MMR, KHM, VNM, THA, NPL, BTN, TCD, NER, MLI, SOM, AFG, YEM, IDN, IND and CHN. Its coverage is essentially USA/CAN/MEX. It would draw a dense network on a US map and nothing on every country these packets are for — a broken-looking map exactly where it matters. Real road coverage needs an OSM extract: a much larger dependency with an ODbL attribution obligation, so it is carded rather than half-shipped. See data/README.md. Note: zone oceania-indonesia fails generation with a header-overflow error. Verified pre-existing on a clean tree — unrelated to this change. |
||
|
|
1c24759e21 |
feat(posters): UPG-100 prayer packet generator, letter portrait, i18n
Some checks are pending
build-gate / build (push) Waiting to run
The packet as specified: page 1 the map one-pager, pages 2..n the people
groups 20-per-page with photos, then the 12 prayer points, then the prayer
wheel. Every string is externalised so a new language is a new block in
packet-strings.json, not a code change; fallback is PER KEY, so a partial
translation renders what it has and leaves the rest in English rather than
blanking. Verified with a partial es block before removing the stub.
Three bugs worth recording, each of which rendered silently wrong:
1. zoom, NOT transform:scale(), for print. transform is paint-time, so the
sheet still RESERVES its 1941px layout box and the paginator breaks it
across two pages, inking only the top of the first: 8 sheets came out as
16 PDF pages. Negative margins do not help — they collapse the outer
space, not the fragmentation box. zoom is layout-time, so pagination sees
the scaled box. Measured: transform 2 pages/sheet, transform+margins 2,
zoom 1.
2. The map SVG carries almost no presentation attributes — generate.mjs
styles it from the poster document's stylesheet. Lifting the <svg> alone
left those rules behind, and SVG fill falls back to its initial value,
which is BLACK: 670 elements painted solid black with no error, no broken
image, nothing any existing assertion could see. Fixed by carrying the
poster's stylesheet minus its document-level rules, so a palette change
in generate.mjs still follows the packet automatically.
3. LANG is the shell's POSIX locale ("en_US.UTF-8"), not a BCP-47 tag, and
throws in localeCompare. PACKET_LANG is the only source.
The verification lesson is the bigger one. The probe PRINTED "PDF pages: 16"
for several runs while reporting the packet verified, because the bad counter
never looked at that value — a number that is displayed but not asserted on
is decoration. Then the first fix for the black map asserted on the first
<rect>, the one element just repaired, and went green while 647 others were
still black. Both assertions now test the property rather than a proxy: one
sheet must be exactly one page, and the map as a whole must not be
predominantly black-filled. Both were confirmed to FAIL on the known-broken
input before being accepted.
|
||
|
|
839b0c453d |
A letter-portrait sheet, an honest credit, and a privacy line
Some checks are pending
build-gate / build (push) Waiting to run
FORMAT=letter. The same poster on paper a home printer takes. Width stays 1500 for the reason the 3x4 note already gives -- every width-derived measurement (the 330 gutter, the name columns, the header budget) was calibrated there and carries over untouched. Only height moves: 8.5x11 at width 1500 is 1941. Layout, text and spacing are unchanged; the extra height goes to the label columns, which is where 95 names wanted it. PRINTS AS ONE PAGE -- and this fixes the SQUARE sheet too. The stage is sized in CSS pixels while @page declares a physical size, so a browser mapped 1500px to 15.6in and TILED it: measured, the 3x3 sheet printed as FOUR pages. It went unnoticed because these posters were always screenshotted by shoot.mjs, never printed from a browser. A scale computed at generate time (page width in px over stage width) makes one sheet equal one page on every format. Verified via page.pdf: letter 612x792pt x1 page, square 216x216pt x1 page (was 4). ATTRIBUTION CORRECTED. The credit said "(c) Mapbox (c) OpenStreetMap". This file's own header says NO Mapbox, NO token, NO external fetch at render time, and the basemap is bundled GeoJSON -- so it credited a vendor contributing nothing, which is the kind of line that makes a later reader add a token "to comply". The real source is geoBoundaries (the adm0/adm1 files carry its shapeGroup/shapeName fields), CC BY 4.0, which REQUIRES attribution. OSM is named only when OSM tiles are actually fetched (BASEMAP=1). PRIVACY LINE on the sheet. People-group coordinates arrive already generalised from the publisher -- deliberately not precise locations. A printed sheet travels past the person who made it, so it now carries that fact itself. Worded as provenance, not as a claim about processing we do not perform. It gets its own row: sharing the credit row was a measured collision (516-984px against a stamp ending at 543), and it is a sentence to READ, not a provenance stamp. Also: a backtick inside a CSS comment inside a JS template literal broke generation outright -- the trap check:template-literal exists for. |
||
|
|
3a7913df64 |
Record why the table generator keys by ISO code and never by name
Extending last tick's external-source check from legends to the baked tables turned up a real discrepancy: gaps-and-opportunities.json has 2,106 rows against a 2,107-row corpus, with five name mismatches. The table says "Jerba" and "Tmagourt" where the live API now says "Amazigh of Djerba" and "Amazigh, Matmata" — the same Tunisian peoples, relabelled upstream since the snapshot. It is harmless, and the reason is the point. Nothing joins on the name: the generator keys everything by iso639_3, and the consumer does the same at resources-map.vue:194, whose comment already states the rule and its stake — "Never a name match: language names collide and drift ... a fuzzy hit here would put the wrong scripture in someone's hands." The generator followed that rule in four places and never said why. So the decision lived only in the consumer and in my head, and a future author tidying this file toward a name join would find nothing to stop them. They would silently drop three groups and orphan two rows: no error, no empty state, just fewer people on a map about people nobody has reached. That is precisely what check-label-joins.mjs was written to prevent. Verified rather than asserted before writing it down: the join key is clean on every row — heart_iso639_3 present and well-formed on all 2,106, zero blank, zero malformed, 862 distinct codes; bridge_iso639_3 blank on 461, which is expected because not every group has a bridge language. gospel-resources/ data-tables renders 50 rows by 21 cells with zero console errors and zero responses >= 400. Both citations in the new comment were checked against the files they name. Comment only; no behaviour change. Five gates pass, including check:label-joins itself and check:legend-source from last tick. |
||
|
|
159acac940 |
Point the app-tables guard at pages that exist, and let it count rows it can see
verify:full runs two browser guards that `bun run verify` skips, and both were failing. Neither was a product defect; both had rotted unnoticed precisely because verify-all.mjs marks them `browser: true` and skips them unless --full is passed, so the build chain has never run them. verify-app-tables was broken four ways, each of which alone made it useless: Its default port was 8917, which nothing in this repo has ever bound. Now 4173, the port `bun run serve` binds, so it runs with the same server as every other browser gate. APP_BASE still overrides. It navigated to `index.html#key-city-posters` and two sibling fragments, resolved against the PROFILES object in layouts/app-wrapper-material. That shell is not what `/` serves — as of 2026-09-22 the root page mounts <plugin-studio>, and the built root contains ZERO of those three keys. The list is now three real profile paths taken from manifest.json. key-city-posters is dropped rather than renamed to something that merely looks similar: no such profile ships. Its "are we on our page" check matched the shell title "Doxa Maps". A leaf titles itself "<profile> · <bundle>". My first replacement matched an absolute script path and was equally wrong, because a leaf references its bundle relatively (src="../gospel-resources.js") — measured, on a page that was fine. It now matches the SRI integrity attribute, which this build guarantees by construction and no other server emits. Its row counter only counted rows ONE level inside a shadow host, with no seen set. A row in the light DOM, or one shadow level deeper, counted zero. Now walks every depth. The last defect is the one worth reading twice. The app header printed near the END of the loop body, after the row check, so every verdict appeared ABOVE the app it described and the whole report was offset by one. That made a PASSING app (gospel-resources/data-tables, 40 rows) look like the failure and the real failure look like it had rendered rows. I chased the wrong app through three rounds of instrumentation before reading the output in order. A report that is correct but misordered costs a wrong diagnosis, not just time. Read correctly, the guard now says: cities-internet/data-tables 40 rows and 1 table fetched; gospel-resources/data-tables 40 rows and 1 table fetched; and gospel-resources/resources-list fetches NOTHING and renders nothing. That last one is the already-carded gospel-resources data gap, waiting on the three Driver decisions — the component is built and correctly showing an empty result for data it was never given. So the guard is reporting one real, known, Driver-gated absence, which is what it is for. Everything else in it was already sound and is untouched: the recursive shadow-root walk for row counting, fail-closed on zero fetches, and third-party request detection, which the file rightly calls a persecution risk rather than a performance note. |
||
|
|
9dc4dda8cf |
consolidate docs into documentation/ and deployment shells into deployment/
Some checks are pending
build-gate / build (push) Waiting to run
Driver 2026-09-21: 'a folder called deployment which will store the capacitor
folder, wordpress folder and tauri folder', and 'a folder called documentation
to store the contributing folder so docs are in one place'.
docs/ + contributing/ + CONTRIBUTING.md -> documentation/ (three doc homes into
one). capacitor/ + wordpress/ + src-tauri/ -> deployment/.
The public/ -> dist/ rename the Driver asked about is NOT here: after seeing the
measured blast radius (855 tracked files, 54 source refs, and public/ is the
served web-root four consumers point at) the Driver withdrew it - 'keep the
public folder the same'.
Path references were rewritten mechanically (258 across 87 files), but three
classes needed hand fixes because a prefix regex cannot see them:
- join(ROOT,'docs',...) in 10 internal scripts - a quoted SEGMENT, not a prefix
- tauri frontendDist '../public' -> '../../public', since src-tauri is now one
level deeper; check:tauri caught this exactly as the card predicted
- the WordPress contract test's dirname(__DIR__,2) -> 3, same reason
Also reverted one over-reach: the rewrite renamed a FINDING-CATEGORY label
'docs' in public-hygiene-check.mjs, which is a rule name in its own header, not
a path.
Verified: full build ALL GUARDS PASS, SRI consistent; check:tauri, check:mobile
(cap sync + webDir OK), check:wp-embed, quoted-scripts, capability-citations,
capability-exemplars, library-index, public-hygiene, parse and drop all pass.
|
||
|
|
2d64e21fbf | Complete app-profiles->plugin-designs sweep across comments/docs; build green; layouts kept at root (load-bearing) | ||
|
|
1a3925c6a6 | Rename app-profiles -> plugin-designs; build green under new naming (load-bearing paths updated, guards pass) | ||
|
|
b7d91e9aca |
Public data ships without working notes; unused data files stay out of the drop-in
- data/ tables and the people-groups file: meta blocks reduced to provenance (source, url, downloaded, rows, grain, attribution); the link-verification table keeps only state and detail per link; generators write the same shape - dropin.exclude in the build config: people_groups.csv, open-doors.json, platform-reachability.json and tables/index.json are not shipped (nothing loads them); sync-dropin honours it - public-hygiene --strict flags note-style meta keys in shipped data - Rebuilt bundles and gallery |
||
|
|
3fee91a193 |
Release governance: field-access data is private, never shipped
data-private/ is born: datasets whose publication could help close doors stay out of the web-root and out of public repos, permanently. field-access-visas moved there; the tables index no longer lists it; the key-cities Field Access tab is removed from the public build; the table generator routes it (and any future PRIVATE_TABLES entry) to data-private/ and excludes it from the index. Public-good data (internet gaps, gospel-resource gaps, people-group pairings) ships as before. |
||
|
|
994b8d7dcf |
Prepare repo to the vision: BLUF vision-cast README, library warning, SRI injection, term scrub
- README rewritten as a BLUF-first vision cast for vibe coders (key-terms glossary, origin story, features, requirements, data caveat, developer's note). - library/components/WARNING.md: prototype in your app-profile; only proven, parameterized components graduate into the shared library. - generate-tree.mjs: inject Subresource Integrity (sha384) + crossorigin into the standalone page <script> tags and the copy-paste embed snippets (manifest = registry). - Scrubbed identity-tied phrasing from comments/docs, keeping the technical rationale and the mission content. |
||
|
|
35ac1c5607 | docs: simplify all folder READMEs (what's here + graphic + link); migrate complex reference to docs/architecture/{data-sources,i18n,components,app-wrappers,poster-generation}.md | ||
|
|
d5ba21fa76 |
doxa-maps — portable bundler for embeddable JAMstack maps
Standalone repo that builds embeddable JAMstack map bundles (web components), composes them into a lazy-loaded SPA via layouts/, and optionally wraps a Capacitor Android/iOS app — one build, choose your output/CDN. Diátaxis docs. History reset to a single clean commit; no PII, IPs, or internal terms. |