Commit graph

30 commits

Author SHA1 Message Date
Doxa Maps
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 (e3d25ff6) and this profile already honours it — applyPeopleGroupList()
filters the rows at all four fetch sites, packetRecords included — and the
membership files are on disk. So under ?list=frontier-500k the packet's rows were
the 71 network groups that are Project 700, while the funnel took its denominator
from the last step of vision-funnel.json and printed "of the 2,107". Off by a
factor of 30, with nothing on the page looking broken.
Measured from data/people-groups/lists/*.json (counts.doxa, what a Tier A map
shows): frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 ·
the default 2,107.

THREE NUMBERS WERE WRONG, NOT ONE, because all three descended from one total:
the share's denominator; the footnote's "N are in no list" (total minus assigned);
and "1 of 27 lists", which is a property of the UPG-100 ZONE CATALOGUE, not of the
active list — the zones partition the default list and nothing else.
A non-default list now gets its own step, labelled with the list's own label, so
the narrowing does not jump from one list's total to another list's packet.

AND EACH CLAUSE IS SUPPRESSED SEPARATELY, because they are provable separately.
A first pass dropped the whole share line whenever listCount was absent, throwing
away a true statement ("5.4% of the 1,745") to avoid an unprovable one. The
footnote splits the same way: provenance holds for every list, coverage does not.
⚠ The footnote is NOT split by parsing the translated sentence — hi ends in "।"
and zh in "。", and reading translated prose for structure is the trap
check-packet-render's own comment warns about. It is a second key.
⚠ And `funnel.unassigned ?? 0` would have printed "0 of the 1,745 are not yet in
any list" — a confident falsehood whose zero reads as a completeness claim.

── AND THREE FINDINGS FROM rega'S RENDER, none of which I could see ──────────
"0 people" PRINTED ON THE READER'S ROW OF EVERY PACKET. Number(null) is 0 (only
Number(undefined) is NaN), so `Number.isFinite(Number(x))` admitted the
deliberate `population: null` marker as the number zero. I asked "is this a
finite number?" when the question was "did anyone give me a population?" — the
same presence-vs-value confusion as inferring "did it work" from a field that
answers "did it isolate". Zero is suppressed too: a people group with no people
is missing data wearing a number's clothes.

THE SHARE LINE READ AS PART OF THE FOOTNOTE. Measured: 218px below the reader's
row, 42px above the footnote, because the last row kept the full inter-row gap.
Proximity is the only thing grouping them, so an even gap grouped the share with
the wrong element. The last row's margin is now 14px: share 14px under its row,
288px clear of the footnote.

THE GATE ASSERTED PAGE 2 AND PRINTED NOTHING ABOUT IT. rega: "a reader cannot see
WHAT it asserted." A silent pass is the mirror of a printed-but-unasserted number
— the check is real and the evidence invisible. It now reports rows, bars and the
share line, and gained two assertions: every row must have a BAR (the reader's
step had none, so the funnel never visibly narrowed to "yours"), and no row may
print a zero population.

Verified: all five list cases render their own true numbers — doxa keeps the full
claim, unreached/frontier/unengaged get their own step and a percentage with no
false "1 of K", frontier-500k reads "100% of the 71" with the duplicate row
suppressed (a step that repeats its neighbour is not a step). The four fixtures
are byte-for-byte unchanged on the default path, so the list-awareness is purely
additive. check:packet-strings-parity PASS with the three new keys. Both new gate
assertions mutation-tested in en and ar: removing a bar is DETECTED, injecting a
zero population is DETECTED. 5 rows = 5 bars, 0 zero-population rows, "0 people"
absent. Six-row layout recomputed: fits with 318px headroom, 206px if every label
wraps. BUNDLE=my-upg-100-list vite build clean.

NOT re-rendered in a browser: rega measured the previous state and these are
responses to it, so the new geometry wants one more look.
2026-09-26 08:13:34 +00:00
Doxa Maps
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.
2026-09-26 04:38:59 +00:00
doxa-maps-88655c70
79bd6d2ac3 public/ is one folder: the output tree plus a README, with the Plugin Studio as its front page
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-25 17:41:56 +00:00
doxa-maps-88655c70
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.
2026-09-25 08:19:56 +00:00
doxa-maps-88655c70
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.
2026-09-25 02:42:04 +00:00
doxa-maps-88655c70
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.
2026-09-24 23:53:07 +00:00
doxa-maps-88655c70
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.
2026-09-24 14:33:36 +00:00
doxa-maps-88655c70
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.
2026-09-24 14:21:45 +00:00
doxa-maps-88655c70
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.
2026-09-24 13:53:57 +00:00
doxa-maps-88655c70
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.
2026-09-24 12:20:38 +00:00
doxa-maps-88655c70
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 d154a76 — PosterWall fetches posters/<id>.html and rasterises
it in the browser. The PRINT path did not: it handed an <img> a url it never
checked, so on a default drop the SAME poster downloaded fine and PRINTED as
"Poster not available yet." That is the bad first impression
IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF called out by name.

Three things were missing; all three are here:

1. printPoster now climbs the same ladder as the download path — probe the webp,
   and only on a miss fetch the .html, rasterise, and verify PIXELS before opening
   the dialog (an SVG in an <img> is an isolated document: a lost stylesheet prints
   a black square with no error). Blob urls are held until unmount, not revoked at
   print time — the preview <img> is still reading from them.

2. publish-html.mjs wrote into public/assets/posters, which is a GENERATED MIRROR
   that every `bun run tree` rmSync's. Its output never survived, which is a large
   part of why the fallback still had no input months after it shipped. Retargeted
   at data/posters, the tracked source the mirror is made from.

3. bundlePosters is one boolean over a directory holding 40.9MB of rasters AND the
   28 sources, so it could only ever ship both or neither — and "neither" is what
   made both full-size paths 404. posterSources:true ships the sources without the
   rasters, the combination that was previously inexpressible. DEFAULT UNCHANGED:
   28 sources are 27.9MB raw (6.9MB packed, measured with pack-objects), real
   weight in a published tree that needs a deployment decision, not a silent one.

VERIFIED AT THE RENDERED LAYER, not by reading source. check:poster-print-fallback
clicks the real control with every posters/*.webp ABORTED at the network layer —
the default-drop condition forced rather than hoped for — and asserts the HUD
enters "generating the poster" and settles clean. Mutation-tested: removing the
fallback produces 4 failures including "no posters/*.html was fetched". The other
branch is verified too — with a webp present it is used directly, zero html
fetches, no rasterise — so the extra probe is paid only on a miss.

★ THE GATE WAS WRONG THREE TIMES FIRST, each time blaming the product for a
harness fault, which is why it now asserts the HUD instead of an internal:
  - it matched /print/i, but the control reads "UPG prayer poster" — the product
    names the ARTEFACT, not the action;
  - it patched Document.prototype in the PARENT realm while openPrintFrame writes
    into the iframe's own;
  - openPrintFrame calls doc.open() AFTER append, and open() RESETS the document,
    discarding a write patched at append time.
Through all three the .webp/.html request counters looked corroborating, because
the wall loads both by itself. The HUD is what a user sees, survives a refactor of
the print internals, and cannot silently miss.

The 28 poster sources are committed because the fallback has no input without
them. city-* posters are deliberately NOT covered: they fetch OSM street tiles at
generation time, so they are not reproducible offline and their rasters must stay.
2026-09-24 08:07:22 +00:00
doxa-maps-88655c70
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.
2026-09-24 04:28:25 +00:00
doxa-maps-88655c70
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.
2026-09-24 04:26:29 +00:00
doxa-maps-88655c70
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.
2026-09-24 04:07:17 +00:00
doxa-maps-88655c70
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.
2026-09-24 04:03:57 +00:00
doxa-maps-88655c70
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.
2026-09-24 03:50:35 +00:00
doxa-maps-88655c70
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.
2026-09-24 03:37:05 +00:00
doxa-maps-88655c70
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.
2026-09-24 03:31:01 +00:00
doxa-maps-88655c70
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.
2026-09-24 03:20:50 +00:00
doxa-maps-88655c70
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.
2026-09-24 02:56:20 +00:00
doxa-maps-88655c70
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.
2026-09-24 02:43:49 +00:00
doxa-maps-88655c70
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.
2026-09-24 02:21:03 +00:00
doxa-maps-88655c70
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.
2026-09-24 01:39:26 +00:00
doxa-maps-88655c70
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.
2026-09-23 23:57:42 +00:00
doxa-maps-88655c70
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.
2026-09-21 23:23:23 +00:00
doxa-maps-88655c70
2d64e21fbf Complete app-profiles->plugin-designs sweep across comments/docs; build green; layouts kept at root (load-bearing) 2026-09-17 18:18:19 +00:00
doxa-maps-88655c70
1a3925c6a6 Rename app-profiles -> plugin-designs; build green under new naming (load-bearing paths updated, guards pass) 2026-09-17 18:18:18 +00:00
doxa-maps
994b8d7dcf Prepare repo to the vision: BLUF vision-cast README, library warning, SRI injection, term scrub
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
- 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.
2026-09-15 22:58:19 +00:00
doxa-maps
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 2026-09-11 18:50:31 +00:00
doxa-maps
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.
2026-09-10 01:53:07 +00:00