Commit graph

3015 commits

Author SHA1 Message Date
Doxa Maps
fc369cbd9d Teach frozen-labels the two shapes a function body hides
The existing rule treats "inside a function" as safe, because a function
re-runs. That is true of the function and false of the label when nothing
calls the function again after the locale changes. upg100 hit both shapes
for real in their UPG-100 i18n batch.

A tree built once from t() and stored: opts() returns labels, onMounted
assigns the built tree to a ref, and no call site sits inside a watcher. The
translator call is shielded by the function, so the walk reports it clean
while the labels stay in the boot language.

A computed whose only locale source is getRuntimeLocale() or detectLocale().
Those are plain getters that notify nobody, so the computed has no reactive
dependency on the language and resolves once. It is the existing tStatic
rule generalised: the getter can be reached through any helper, not only
through a translator.

The watcher does not have to be watch(locale, …). upg100's own fix watches
() => packetLang(), which reads packetLocale.value — a real reactive source
under a local name. A signal looking for the literal `locale` would have
flagged the fix as the defect, which is the worst thing a gate can do to
someone who just did the work.

The first draft flagged five PRODUCTION files, none of them a label tree:
fetchPhotos, relabelSheet, popupHtml and two more — large functions that
call a translator once for an error string and return something unrelated.
"The body mentions t()" is not the shape. The rule now requires the
translator call to sit inside a RETURNED object or array literal, which is
what "builds a structure of labels" actually looks like.

Six cases added to the gate's own table, each bad shape paired with the good
one it must stay quiet on, plus that false positive. Self-test 20/20.
2026-09-29 09:19:04 +00:00
Doxa Maps
07eb74c33c Move four identical bodies into the library
From the fleet census: 620 function bodies indexed across 29 plugin trees,
16 identical in two or more. These are the four cheapest — none closes over
anything beyond its own parameters, so each is a file move plus an import
rather than a new dependency to thread.

hostElement resolves the custom element a component is mounted inside, one
hop out through the shadow root. It was identical in three plugins, and it
is the piece worth sharing for a reason other than line count: every plugin
ships through defineCustomElement, a composite nests two shadow roots, and
code that forgets the hop operates on the wrong node while looking correct.
A document-level query for a node inside a shadow root returns null on a
working build, which reads as a broken feature rather than a wrong query.
Three copies were three chances to write that.

findScrollportAncestor walks up to the nearest ancestor that actually
scrolls, crossing shadow boundaries on the way — parentElement stops at a
root, so a plugin in a custom element would otherwise find no scrollport and
conclude the page does not scroll.

findNodeInTree and buildFlatLegendMatchExpr join useLegendMapBridge, which
already holds four functions from the same seam. They sit at module level
rather than inside the factory because they close over nothing: a pure
function hidden in a closure can only be reached by constructing the whole
bridge.

Call sites keep their old names through a one-line local binding, so no
call changed and the diff is the declaration plus an import.

Rebuild-and-compare, all five affected bundles, before -> after:
  regions-dashboard   1,098,469 -> 1,098,479   +10
  country-posters     1,484,053 -> 1,484,028   -25
  doxa-research-map   1,354,051 -> 1,354,061   +10
  dashboard-plus-grid   915,660 ->   915,644   -16
  big-picture           366,907 ->   366,719  -188
  net -209, and country-posters gains 25 bytes against the 1.5 MB ceiling.
Every behaviour-bearing token counted, not set-compared, and unchanged in
all three map bundles: regions-fill, peoplePraying, engagementStatus,
adoptionStatus, overflowY, scrollHeight, getRootNode, _flat_filter, religion.

Owners cleared their own files first: regions for regions-list.vue, upg100
for countries-map.vue. check:selection-kinds, check:legend-matches-source,
check:legend-data-contract, check:packet-variants, check:no-dead-library and
check:no-cross-plugin-imports all pass.
2026-09-29 09:19:04 +00:00
Doxa Maps
a71cc4b5e8 Pan the map when a popup would open half outside it
The popup is built with no explicit anchor, so the engine puts it above the pin
when there is room and below when there is not. Measured on the drop-in: a 452px
popup on a pin in the lower half of a 780px map fits NEITHER way, so it opens
below and the map's overflow:hidden cuts off the Pray and Full Profile buttons.
A real click at their centres then lands on the page behind, though a scripted
click still navigates -- so the buttons look present and are not reachable.

Moving the PIN is what makes an anchor fit, so the map pans rather than the
popup changing. Its design is untouched, which is what keeps this a clone; it is
what Leaflet does by default. The alternative, a shorter popup, would have been
a visible departure and a product decision.

The pan is the smallest one that works, and most pins need none: on a 780px map
only a narrow band moves at all, by 74px at the worst position rather than a
jump. It is animated over 200ms.

It resolves at every width this component renders at. The container is
max(780, 16/9 of width) at >=768px and twice the width below that, so even at
320px wide there is 640px of container against a 452px popup -- 164px of
headroom, and the popup would have to grow by that much before any width could
overflow. So no inner scroll and no residual case.

It returns 0 rather than panning when the popup is TALLER than its container: a
host can give the map a short slot, and there a pan would only move the clipping
around. The failure stays what it is today instead of becoming a clipped popup
and a camera that jumped. Unmeasurable inputs -- a map mid-load, a zero-height
container, an unprojectable point -- also return 0 rather than throwing, and the
pan itself is wrapped, because a pan is a nicety and must never break a click.

The offset lives in one constant now. The pan arithmetic has to use the same
number the popup was built with, and two literals drift.

Proven against the shipping function, lifted out of this file by source rather
than restated: 16 checks, including every pin position at 320, 390, 768 and
1440px wide -- none left clipped. The fit check is stated independently of the
function, because an earlier version asked the function whether it had
succeeded, and that hid a dropped offset: the pan was 12px short and its own
test 12px lax, so the two agreed while the button still clipped. Mutation-tested
four ways -- dropped offset, zeroed offset constant, flipped sign, removed
squeeze guard -- each caught.

Both consumers rebuilt: country-map +623 B, regions-dashboard +672 B.
2026-09-29 09:18:56 +00:00
Doxa Maps
f29df44140 Cloud container: main is the default production branch; the embed address and open mode are documented
- PRODUCTION_BRANCH defaulted to `production`, a branch this repo does not have (development is the
  table, main is production). compose.yaml and every deployment already set main; a platform deploy
  that left it unset watched a missing branch and /cdn never built. Default main in build-cdn.sh,
  pb-prod-migrations.sh and admin.mjs, and in the README.
- EMBED_BASE_URL was missing from the settings table. Unset, the published snippets stay relative.
- Open mode is for a laptop or a private network: it has none of the public mode's guards. Said so.
- compose.yaml reads the repo-root .env, the same file bun run dev reads, so laptop-only settings
  would reach the container. Said so in compose.yaml and the platform table.
2026-09-29 09:18:56 +00:00
Doxa Maps
6289d3b969 README: what a fresh reader hit, fixed (V8 fresh-reader test)
A clean clone of development, run with no settings and only the README:
- The drop-in's `bun run build && bun run dropin` stopped at the build, which refuses without
  BUILD_ALLOW=1, so dropin never ran. The built maps are in the repo: the route is now
  `EMBED_BASE_URL=https://your-site bun run tree` then `bun run dropin`, with the one-bundle rebuild for
  changed plugins. Without the tree step the manifests' snippets stay relative and no other site can paste
  them (measured: embedBase "." before, the site's address after, and the snippet's page answered 200).
- smoke:studio checks :5173 by default; when Vite took another port it measured a different server.
  The step now passes the printed URL, and says it drives a headless browser.
- `bun run serve` crashes if 4173 is taken; PORT=<n> works and is now named.
- Vite prints http://127.0.0.1:5173/, not localhost.
- The sync example's branch must exist on the remote; the public repo has only main.
- bun run dev also downloads and starts a local PocketBase on 8090; now said. A copied template keeps
  its element name until renamed as its README says.
2026-09-29 09:18:56 +00:00
Doxa Maps
3575631935 The one-command browser suite runs smoke:legend-info, which crashed on load for four days because only a hand ran it 2026-09-29 09:18:56 +00:00
Doxa Maps
f96fb4290f A desktop-only composite blocks on a phone too: the phone rule is a composable its root can call
rega measured dashboard-plus-grid's own leaf at 390px: the page carried
"mobile": "desktop-only" in its profile-config, and the dashboard, the grid
and a MapLibre canvas mounted anyway. The rule lived in ProfileLoader, and
this composite defines its element without ProfileLoader (it bakes its
children in), so nothing on that page read the flag.

- library/composables/useScreenGate.js holds the rule (narrow screen, not
  inside the Gallery, dismissed for the session); ProfileLoader uses it,
  unchanged in behaviour.
- dashboard-plus-grid calls it: on a narrow screen the desktop-only message
  replaces the element, and the dashboard and grid never mount.
- check:mobile-gating (g): every flagged profile's element must mount
  through ProfileLoader (an import in its index.js, not a mention in a
  comment) or call useScreenGate itself. The pre-fix composite fails it.
2026-09-29 09:18:55 +00:00
Doxa Maps
cafee0d432 The default embed snippet preloads MapLibre, the engine it draws with; Mapbox gets its own snippet
Every map profile's copy-paste snippet began with a preload of
api.mapbox.com's mapbox-gl.js, while its own profile-config said provider
"maplibre", and since 2026-09-28 only an explicit provider "mapbox" selects
Mapbox. So an unedited paste downloaded the Mapbox engine on every view,
never used it, and made a Mapbox request from a keyless page. Its only
guard was a stale comment asking the partner to delete the line.

- embed.script now preloads MapLibre's script and stylesheet, pinned the
  way useMapInstance loads them so the preloads are reused, and its note
  points a host who wants Mapbox at the variant.
- embed.scriptMapbox is that variant: the Mapbox preloads and the same
  element with "provider":"mapbox".
- check:snippet-engine (gates:post, with a self-test) reads the built
  manifest: no default snippet names api.mapbox.com, every map snippet
  preloads MapLibre, and every Mapbox variant preloads Mapbox and sets its
  provider. Planted: the old snippet and a provider-less variant both fail.
2026-09-29 09:18:39 +00:00
Doxa Maps
5e81b05281 check:no-free-identifiers reads internal/ and .mjs, which is where two gates had crashed on load
A commit on 09-25 gave some 30 browser gates `const ROOT = …(_fileURLToPath(…))`.
Two never imported it, and crashed with a ReferenceError the moment they
loaded: check-legend-info-localized (found by hand after four days, fixed
this morning) and library/scripts/posters/verify-posters.mjs, found now by
this change and fixed here. Both run only by hand, so nothing noticed.

The gate read only .vue and .js under plugin-designs/ and library/. It now
reads .mjs as well, and internal/. Scanning them surfaced standard globals
the list lacked (eval, EvalError, Bun, and the window members that
page.evaluate() callbacks use), which are added. A file tagged @one-shot
is skipped: it is kept as a reproduction recipe, not run.
2026-09-29 09:18:26 +00:00
Doxa Maps
89471b2880 A bundle carries a translation area only where its code uses it, not wherever the area's name appears
i18n-areas kept an area whenever its name showed up as any string literal
in a bundle's source graph, to cover keys assembled at runtime. But
'gospel', 'religion', 'regions', 'detail', 'poster' and others are also
ordinary ids (a colour strategy, a data source, a field, a mode), so every
bundle importing those modules carried catalogues it never shows, in all
11 locales: 80 area inclusions across 26 bundles, and gospel's catalogue
alone pushed country-posters 9.5 KB over the ceiling.

A bare name now counts only where it really builds a key: a namespace
helper (useOptionalT, useOptionalI18n, qualify) or a translator reading a
whole area (tm('regions') in useRegionsI18n is the one in use). Key
prefixes ('area.' / `area.${x}`) count as before. A bundle that assembles a
key with a leading `${…}` keeps the old generous rule, since its area
cannot be read from the source.
2026-09-29 09:18:26 +00:00
Doxa Maps
6416412891 Rebuild my-upg-100-list and country-posters, and regenerate the tree 2026-09-29 09:11:46 +00:00
Doxa Maps
77889d3df9 Name each Country Posters card in the reader's language
item.label is the card's accessible name, the print HUD's title and the
title of the printed list, card deck and packet, and country-posters.json
carries it in English only, so every locale printed "Chad" and "India".
Cards are now named through countryName (Intl, joined from our ISO-3):
all 175 countries localize in ar, zh and ru. English keeps the data's
own names; Intl's English differs for 32 of them, and countryName.js
records that swap as a product decision. The list reads the i18n locale
ref, so a runtime language switch renames the cards.

The generated poster sheet still receives the data's own English record
(the poster surface is English by recorded decision), and its series
index is found by id, because a language switch rebuilds the cards.
check:poster-browser-equiv still passes, byte for byte.
2026-09-29 09:10:27 +00:00
Doxa Maps
2067233553 Re-render the UPG-100 legend and wall names on a runtime language switch
ProfileLoader switches the i18n locale mid-session, and two things the i18n
batch localized were resolved once and then kept:
- the legend tree, built through legendOpts() and stored, so every region
  and zone name stayed in the boot language (as did the flat zone list and
  the search feed built from its labels);
- poster-wall's card names, a computed that read only getRuntimeLocale(),
  a plain getter, so it never re-ran.
check:frozen-labels cannot see either: t() runs inside a function, and the
computed has no t() in it at all.

One buildLegend() now serves all three build sites and remembers the last
records; a watch on the locale rebuilds the tree, relabels the flat zone
list in place (colours and selectors unchanged) and rebuilds the search
feed. The wall's computed reads the i18n locale ref, so it re-runs.

The first-status watch (ac5a14ffa) now watches packetLang() rather than packetLocale:
the host may install i18n with a plain-string locale, and watch() on a non-ref never fires.
2026-09-29 09:10:27 +00:00
Doxa Maps
538e2fb782 Keep the reason, drop the speaker, in the UPG-100 and Country Posters plugins
The 40 baselined references in my-upg-100-list (32) and country-posters (8),
plus 3 unbaselined ones in data/*.js, each wrapped an engineering reason in
an attribution. The reason stays; the speaker, the quotation marks and the
pronouns go. Dates and measurements are kept where they were ("decided
2026-09-24", "Reported 2026-09-24: the UPG-100 list legend on the map
overflowed beyond the height of the map"). Where a quote carried a
requirement, the requirement is now stated directly.

Comment-only, one line for one line (73 against 73), including the
meta.json note, which does not reach the manifest. Both bundles build
byte-identical to the base. Route test: 0 files under library/ or
internal/.
2026-09-29 09:10:26 +00:00
Doxa Maps
4ea988a4c9 Ten facet labels froze in the boot language
GiCombos built its facets object inside provide() at setup, so T() ran once and
the result was handed to the consumer as a literal. After a live switch to Arabic
every facet name stayed in English — the labels were keyed, the keys resolved,
and the reader still saw the wrong language, because what was wrong was WHEN they
were read rather than whether they were translated.

check:frozen-labels cannot see this: every call site passes a real key and every
key exists in all eleven locales. The defect is a lifetime, not a string.

The facets are a computed now, provided through a getter so a consumer reading
.facets re-reads it rather than taking a snapshot. Demonstrated rather than
asserted: with a locale ref driven en -> ar, the old shape keeps returning
"Access" and the new one returns "الوصول".

Audited all 179 t() call sites across the three plugins by enclosing scope, not
by grep. This was the only frozen one; the six GiDrawer hits my first pass
flagged are inside a computed 60 lines up, which is why a lookback window is the
wrong tool for a lifetime question.
2026-09-29 09:06:28 +00:00
Doxa Maps
6baf6dba65 Keep the reason, drop the speaker, across all three plugins
The 36 baselined references in gospel-internet (23), gospel-resources (9) and
cities-internet (4) each wrapped a real engineering reason around an attribution.
The reason is what a reader needs; the speaker is process vocabulary that does not
belong in shipped text.

Every one is rewritten in place, keeping the measurement and the date where they
were there: "NUMERALS ARE HELD fleet-wide (2026-09-26)", "(2026-09-27: one more
level of zoom-out was needed)", "Reported from use: it overlapped the bottom of
the page". Where a quote carried the requirement, the requirement is now stated
directly rather than quoted.

Comment-only: 36 insertions against 36 deletions, one line for one line, and no
built text changes. Route test passes — 0 files under library/ or internal/, so
this self-lands rather than going through studio.

Two things I nearly shipped by accident, both caught before committing:
`git add -A` swept in a 1,961-line vite.config timestamp artefact from my own
build, and editing the JSON meta through a parser reformatted "composes" from one
line to three. The artefact is deleted and the JSON is edited as a string, so the
diff is only what I meant to change.
2026-09-29 09:06:28 +00:00
Doxa Maps
c8f9b38820 Rebuild all-people-groups doxa-simple-map doxa-vision-card pplr-data-maps prayer-walk-planner upg-neighbor wall-of-people-groups, and regenerate the tree 2026-09-29 09:01:05 +00:00
Doxa Maps
c2473d0e41 exp's maps keep the reason and drop the speaker: no comment quotes or cites the Driver
21 lines in seven of exp's bundles (upg-neighbor 4, prayer-walk-planner 8, doxa-simple-map 2, pplr-data-maps 2, wall-of-people-groups 1, doxa-vision-card 1, all-people-groups 3): each keeps its engineering reason, its dates and its measurements; the speaker and the quotes go (a quote becomes a plain statement of the defect). Comments only. Five bundles rebuild byte-identical; doxa-simple-map and pplr-data-maps change only in their Vue scoped-style ids, which hash the SFC source.
2026-09-29 08:56:44 +00:00
Doxa Maps
7e309d1777 Wall of People Groups: the poster print counts its photos while they load, and says how many could not load
The HD poster prints on its frame's 'load', which waits for every photo: up to 2,107 photos, 132 MB, which can take minutes on a slow connection, and a photo that fails prints as a grey box without a word. The print button now reads "Preparing the poster: n of 2,107 photos" while the frame loads, and if any photo could not load, a toast says how many print as grey boxes. The counting is src/printWatch.js (it never decides when to print; the frame's own 'load' does). It finishes at 'load', at the frame's 10-minute ceiling, or at once if the frame is gone (closed), so the busy button never outlives its frame. The busy state's fallback moves from 120 s to that ceiling, because the frame is now kept until it has loaded (printInPlace, exp-shepherd/print-floor).

Headless, fake timers and a fake frame (_qa-cycle/exp-shepherd/check-wall-print-watch.mjs): progress climbs as photos load; 3 photos with no pixels are counted as failed; a load that never ends finishes once at the ceiling and not before; a frame removed at 60 s ends the watch within a tick; 4 planted defects (failures not counted, no ceiling, no progress, a removed frame holding the watch) each caught.
2026-09-29 08:56:44 +00:00
Doxa Maps
3a004068b7 Rebuild the consumers main left stale (build owner, README rule 5)
check:composite-freshness found stale on main: template-bundle. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-29 08:48:43 +00:00
Doxa Maps
ac5a14ffaf The UPG-100 map's first status re-resolves on a language switch: it was set once at setup, which check:frozen-labels refuses 2026-09-29 08:39:07 +00:00
Doxa Maps
8287f0c087 smoke:legend-info measures again, and says so when it cannot
Since a9550858e the gate called _fileURLToPath, which it never imported
(only fileURLToPath is), so it died with a ReferenceError at load and
measured nothing — the UPG-100 legend's info popovers in es/ar/zh
included. And with no leaf URL it exited 0 as SKIP, which the wave runner
reads as a pass; it now exits 75 (NOT MEASURED), the convention the file
itself states. Swept every gate for the same undefined path aliases:
none.
2026-09-29 08:33:44 +00:00
Doxa Maps
64a15f4a03 Size the legend's compact population to its fixed column in every locale
Intl compact forms are longer than the old '12.3M': with one decimal
everywhere, German reached '123,5 Mio.' (10 chars) on the region rows,
past both the 60px mobile and 70px desktop Population column. One
decimal now only below 10 million; whole units above. Worst case over
all 11 locales: 8 Latin chars (de/ro/ru '123 Mio.').
2026-09-29 08:33:44 +00:00
Doxa Maps
a3e58ac7ca Serve the packet text at print time instead of bundling all 11 languages
A single-file build inlines every import, dynamic ones included, so
packet-strings.json (~115 KB minified: every language's prayer points,
captions and headings) rode in every bundle that can print a packet, list
or card deck. With the wall's print messages translated, country-posters
crossed the 1.5 MB ceiling (1,502,226 bytes).

The table is now served at <assets>/lib/packet-strings.json and fetched at
the first print by library/packet/packetStrings.js, the same move as the
sheet generator and ne-places.json:

- Pinned like them: PACKET_STRINGS_SHA384 in poster-render.integrity.js
  (pin-poster-render.mjs); the text is hashed before it is parsed, and an
  unmatched table is not used. These strings are laid into the print
  document, so an unverified copy never reaches it.
- English is bundled as the fallback (a named import of the JSON, so only
  the en block ships): with no asset base, on file://, on a network error or
  an integrity mismatch the print comes out in English and the console says
  why. A print never fails for want of this file.
- generate-tree copies it into the pool AND the served allowlist; verified
  by regenerating the tree (the served copy is byte-identical to source).
- check:poster-render-pinned, two-sided: the pin matches source and served
  copy, the genuine copy loads all 11 languages, a tampered copy falls back
  to English (mutation-tested: disabling the hash check fails it), and an
  unserved one falls back rather than failing.
- sha384 is one shared helper (library/packet/sha384.js).

Sizes: country-posters 1,378,463 (was 1,484,053), my-upg-100-list 1,143,456
(was 1,243,509), regions-dashboard 973,802 (was 1,079,448).
2026-09-29 08:33:44 +00:00
Doxa Maps
03839179b7 Speak the reader's language on the UPG-100 map, its wall and the print HUD
The legend's region sections, its Deaf row and its one-country zones, the
wall's card names, the print progress and error messages, the map's status
badge and the Population column all rendered English in every locale. None
of them was visible to check:hardcoded-english: they were single words, .js
modules, Error() arguments, library components or number formatting.

- Legend: region rows through one REGION_KEY table shared with the wall
  (poster.region_*, new region_deaf); zones through zoneTitle (tier A by
  country, the Deaf row by its bloc name); node ids stay English-derived.
- Wall: card labels through zoneTitle, so the Show-on-map name, alt text,
  tooltip, HUD title and printed title agree with the legend and packet.
- Errors a reader sees are keyedError(key, English, params);
  errorText(e, t) shows them translated, and an unkeyed error still shows
  its own message. Developer detail for a failed data load goes to the
  console; the badge says upgMap.mapDataFailed.
- Population: Intl compact notation in the app's locale, same precision.
- Country Posters: the unsupported card's reason is
  messages.countryPosterTooMany, not the data file's English note.
- Strings only the UPG-100 map shows move to a new upgMap area, so other
  bundles do not carry them (country-posters 1,490,907 bytes).
- check:hardcoded-english judges keyedError's English as a fallback
  (quiet when the key resolves everywhere, a finding when it does not);
  check:label-key-coverage follows REGION_KEY to legend-tree.js and covers
  the legend's table and its regionFor producer. Both self-tests two-sided.
2026-09-29 08:33:43 +00:00
Doxa Maps
3233df2bc1 A landing whose tree is stale is refused: regenerating it must change nothing
fix:sri rehashes the leaf pages but does not carry a <profile>.meta.json
change into their BASE_CONFIG, so a pinHint, a label or a mobile flag could
land in the source and stay invisible in the tree until some later landing
happened to regenerate it (PPLR's pinHint:false did exactly that).

The generator is deterministic: a clean tree regenerates to 0 changed
files, in about 3 seconds. So landing-preconditions now runs it (step 2b)
and refuses if anything under public/ changes, naming the files and the
command to run. Checked both ways: a meta.json change without the tree
regenerates one leaf page (refused); after `bun run tree` it is 0 (passes).
2026-09-29 08:13:08 +00:00
Doxa Maps
2795455688 The legend toggle, the chart titles and "no code" were never translated
Found while classifying the remainder rather than by the gate pointing at them:
the search box's aria-label was hardcoded English — screen-reader text, the same
class as the resize-handle bug that check:i18n-keys-exist documents — along with
the legend's open/close label, the three gap-chart titles, "unnamed language",
the unchecked-link notice, and "no code", which appears at six call sites and is
now one key.

Nine keys, sixteen call sites. 102 -> 88.

These were hiding inside the 102 I had described as mostly-deliberate. The
classification was worth doing for its own sake: four of the five classes really
are deliberate, but the fifth had real defects in it, and I would not have found
them by reading the count.
2026-09-29 08:11:50 +00:00
Doxa Maps
04b38560d1 The loading lines and the caveat lead-ins get their own keys
Five more keys: the table's loading line, the language sheet's population and
countries line, and the two bold lead-ins that introduce the caveat prose in
resources-list. The caveat prose itself stays inside its dir="ltr" island, which
the gate already marks deliberate — only the heading that introduces it is
language.

Not touched, and not oversights: "· GRN", "· Zume" and "· Door43" are a separator
plus a publisher's name, on lines whose translatable half is ALREADY keyed. A
brand name after a middot is not a sentence.

106 -> 102.
2026-09-29 08:11:50 +00:00
Doxa Maps
bfefb87d4f The list sheets count their own rows in one key
"Showing {shown} of {total}." appears twice, for the with-resources and
without-resources lists, and is ONE key used twice. The population suffix and the
related-language line join it; "way in:" reuses the key the gap list already has
rather than authoring a second one for the same words.

Three new keys, five call sites.
2026-09-29 08:11:50 +00:00
Doxa Maps
f24a9e2531 The data-tables hero is one key too
Four fragments around three counts, two bold words and a bdi island — "people
groups are", "people,", "an estimated", "deaths per day." — become one message
with named slots, the same shape as the charts hero.

Verified through a real createI18n: en renders as today, ar carries the clause
order Arabic needs, zh reorders too. The counts and the quoted phrase go in as
values, so the bold and the dir="ltr" survive without a translator reproducing
markup.

115 -> 111.
2026-09-29 08:11:50 +00:00
Doxa Maps
e08d0552d3 The resource sheet's lines are sentences carrying their counts
Twelve more keys across resources-map: the two resource chips ("Scripture,
{medium} · {source}" and "Bible video · {source}", where the type word is
language and the source is data), the sheet headings that carry a count, the
country line, the pray count, and the loading notice.

The separators stay out of the catalogue. A "·" between a type and a source is
structure, not prose, and a translator asked to reproduce it would eventually get
it wrong in a language that does not use it — so the punctuation lives in the
message and only the words vary.

124 -> 115 findings. The ratio is back to roughly one finding per key now the
duplicated spans are gone, which is what I said would happen once the one-off
sentences began.
2026-09-29 08:11:50 +00:00
Doxa Maps
6914f44b2e The gap list's lines are sentences, so each is one key
resources-map's gap list read as fragments around its values: "{count} people ·
{iso3}", "way in: {via}", "has {what} — no text found", and a "Showing only …"
whose bold phrase came from a four-branch conditional. Each was a sentence broken
around the data it carries, which is the shape that cannot be reordered.

Nine keys, and one of them does three jobs: the population line appears three
times in near-identical sibling spans, so it is ONE key used three times rather
than three keys — the duplication was in the template, not in the language.

The four filter descriptions are their own keys and slot into the sentence, so a
translator can move "Showing only" around the phrase rather than being handed two
halves to join in English order.

144 -> 124 findings for eleven keys. The ratio is better than the proving case
because the duplicates collapsed; it will worsen again on the one-off sentences.
2026-09-29 08:11:50 +00:00
Doxa Maps
c7aa670426 The gap sentence is one key, so a translator can move its clauses
charts.vue's hero read as six fragments concatenated around three counts and a
bdi island: "of", "languages have", "— the languages of", "people." Arabic does
not put those clauses in that order, so translating the fragments would have
given Arabic words in English order — grammatically broken, and worse than
leaving it English, because it looks translated and nobody re-checks it.

Now one message with named placeholders through useSentence. The counts and the
quoted phrase go in as VALUES, so the bold and the dir="ltr" survive without a
translator ever reproducing markup, and keep() hands the placeholders back to t()
intact — without it the lookup blanks them and the sentence renders with no
values at all.

Proven through a real createI18n rather than in isolation, which is the lesson
from the keep() trap: en renders as it does today, and ar and ru both LEAD with
the object and put the counts after — the reordering that fragment-by-fragment
translation cannot express. zh reorders too.

The gate moves 144 -> 142 for this one sentence. That is the honest number: six
fragments collapse into one key, but the gate counts findings, not fragments, so
the count falls more slowly than the work proceeds. ~26 fragments remain.
2026-09-29 08:11:50 +00:00
Doxa Maps
d9fb0a137b The phone notice holds its place above any profile, and pads the right side in Arabic
A larger-screens profile whose root is position:absolute; inset:0 would
lay out against ProfileLoader's wrapper and paint over the in-flow bar,
hiding the notice and its dismiss button. While the bar shows, the
profile's root is held in flow as the column's second item. No flagged
profile has that shape today; the next one will not need a special case.

The bar's padding is logical, so in Arabic the wider side stays on the
text rather than moving to the dismiss button.
2026-09-29 08:11:49 +00:00
Doxa Maps
f9c621c647 The root build files keep the reason and drop the speaker
package.json's gate descriptions, vite.config.js, the build config's
comment keys, capacitor.config.json and .gitignore named or quoted the
Driver in 13 lines. They sit outside check:no-new-driver-references'
areas, but they ship in the giveaway all the same, so they are reworded
the same way as the sweep. The two left are the reference gate's own
description, which has to say what it checks.
2026-09-29 08:11:49 +00:00
Doxa Maps
64a45967e6 Wall of People Groups keys: the print says how many photos it has loaded, and how many could not load 2026-09-29 08:11:49 +00:00
Doxa Maps
f30006d02a A print frame is kept until it has printed: its 60 s clock starts at load, not at creation
openPrintFrame's autoprint prints on the frame's 'load', which waits for every image, but the frame was removed 60 s after it was CREATED, loaded or not. The Wall's poster loads 939 photos (132 MB), so on a connection below about 18 Mbps the frame was gone before 'load': no print dialog, and nothing to say so. Now the frame is reclaimed 60 s after its load, at afterprint (as before), or at a 10-minute ceiling for a load that never ends. printImageBlob revoked its object URL on the same creation clock, which blanks an image that has not loaded; it now follows the frame's clock too.

New gate check:print-frame-reclaim (gates:pre), fake timers and a fake frame, so 61 s and 10 minutes take no time: a load at 61 s still finds the frame (and the object URL), 60 s after load it is reclaimed, a frame that never loads goes at the ceiling and not before, afterprint reclaims at once. Two-sided: the copy before this fix fails 6 of its 12 cases (the load-after-61-s and ceiling arms); the controls pass on both.
2026-09-29 08:11:49 +00:00
Doxa Maps
592880ac75 Teach the gate the two classes gospel classified by hand
gospel found 12 real defects — including a search box's aria-label, which is
screen-reader text — inside 88 findings they had already described as mostly
deliberate. None were visible from the count. They classified every remaining
finding by hand and named the two shapes the gate miscounts.

A string compared with === against table data is a value, not a label.
`scope === 'Full Bible'` tests a cell of the shipped resource table; the
reader never sees that literal, and the row it selects renders through a key.
Translating it makes the comparison miss and the feature stop, while the
count falls and it looks like progress — the same inversion as a lookup-table
key, which is why gospel classed them together.

A separator followed by a brand is not a sentence. `{{ t('…') }} · GRN`
splits into a keyed half and the text node "· GRN", and only the second
reaches this scan.

The names are LISTED, not patterned. "Punctuation then a capitalised word"
would swallow "· Not found in any pool", a real caveat, and that is the class
gospel's 12 defects were hiding in. A name earns its place by being a
publisher or standard that is the same word in every language.

Each quiet case is paired in the self-test with the nearest thing that must
stay loud: a caveat after the same separator, prose that merely begins with a
brand, a real English sentence used as a table key, and an .includes()
argument. Poisoning the name list into a punctuation rule fails.

Poisoning the operator check did NOT fail, which is why there is now a
concatenation case: `greeting + 'Welcome to the prayer map'` is a
BinaryExpression too, so accepting any operator would silence a sentence
assembled for the reader. Only an equality test means "compared against
data".

Also the last finding in this owner's plugins. All seven main tabs resolve
through tabs.<id> in all eleven locales, so their English labels could never
render — a second copy of each name that could only drift. The sub-tab tables
lost theirs the same way.

Fleet 152 -> 143. This owner's plugins: 1 -> 0.
2026-09-29 08:11:49 +00:00
168b1f1bd9 Country posters print their list, deck and packet, with the map drawn at the click
The regions list showed a poster per country and could print the poster alone.
Supplying a records provider is what makes the Prayer List, Card Deck and Prayer
Packet buttons appear at all -- PosterWall hides them when a wall cannot supply
rows, rather than failing at click time -- and this wall never supplied any.

Records come from the shared provider, not a copy of anyone's fetch. It caches
one request per language and every country in every region shares it, so opening
five regions is still one request, and the same rows feed the sheet, so the
printed map and the printed list cannot disagree about which groups are in a
country.

Page 1 is GENERATED, because there is nothing to load. No country sheet ships:
data/posters holds 28 .html and all 28 are zones, and emit-countries.mjs writes
only country-posters.json, whose `sheet` field is a paper size rather than a
filename. So the packet's default lift asked for posters/country-<iso3>.html and
404ed on all 175. country-sheet-print.js draws it instead, in the same shape as
the zone sheet next door so the two surfaces cannot draw the same sheet two
ways, and returns '' when there is no country or no rows so the caller falls
through rather than printing an empty country.

There is ONE loading path for the generator, not two: this calls
generateCountrySheet, and posterSheets resolves the served, sha384-pinned
poster-render.js itself. Nothing here fetches or imports the generator directly.

MEASURED against the development baseline, both built here: 1044 -> 1061 KB,
+17 KB. An earlier measurement of this same wiring on the pre-pinned base read
+208 KB; almost all of that was the printing subsystem that is now served rather
than bundled, so the cost fell by about 190 KB without this file changing.
createPosterGenerator does not appear in the bundle.

item-count-key replaces nothing: the noun pair still names the section, and the
count now goes through the plural-aware key.
2026-09-29 08:07:36 +00:00
Doxa Maps
9991abbdd7 Rebuild the consumers main left stale (build owner, README rule 5)
check:composite-freshness found stale on main: template-bundle. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
2026-09-29 07:44:06 +00:00
Doxa Maps
69c0966117 Regenerate the library index for this round's library changes 2026-09-29 07:31:17 +00:00
Doxa Maps
f18980f641 Pages whose security policy forbids blob: scripts can still print generated sheets, verified natively
The served generator was run by importing its VERIFIED text from a blob: URL, which fails closed on a
partner page whose Content-Security-Policy forbids blob: scripts: safe, but no generated sheet there
(dx-shepherd's FIX-POSTER-RENDER-LOAD-WITHOUT-BLOB-FOR-STRICT-CSP).

The tree now also serves a CLASSIC-SCRIPT copy, lib/poster-render.global.js: the same file under a
mechanical transform (strict mode kept; the one export becomes window.DoxaPosterRender), written by
generate-tree with the function the pin is computed from (globalVariant, pin-poster-render.mjs) and on
its served allowlist — produced by regenerating the tree, not copied in by hand. posterSheets keeps the
proven path first; only when the blob import itself is refused does it load the classic copy with
<script integrity=sha384-…>, so the browser verifies it natively. An integrity FAILURE never falls back:
it throws before any import is tried.

check:poster-render-pinned adds: the classic copy matches its pin and the served copy, and — run as a
real classic script — draws the module's sheet byte for byte. The <script> path under a strict policy
needs a browser (rega). country-posters 1,473,964 bytes (ceiling 1,500,000); verify-bundles-shipped
PASS; gates:pre 74/74.
2026-09-29 07:31:17 +00:00
Doxa Maps
5203bf06c4 A landing cannot grow a bundle past the size ceiling: landing-preconditions runs the size check
verify-bundles-shipped (every shipped bundle under 1,500,000 bytes) ran only
inside `verify`, in gates:post, and nothing in the landing called it. So a
landing that grew a bundle past the ceiling landed and then went red:
694b00991 shipped country-posters at 1,662,962 bytes. It is now step 3c of
the landing precondition, on the rebuilt tree, before the push (~0.2 s).

Its message now reports bytes: it printed "1.6MB (ceiling 1.4MB)" for
1,662,962 against 1,500,000, mixing MiB with the decimal ceiling.
2026-09-29 07:31:17 +00:00
Doxa Maps
c0ffb13f86 check:no-free-identifiers counts __DX_LOCALE_SRI__ as build-bound: the locale plugin prepends it, and its one read is typeof-guarded 2026-09-29 07:31:17 +00:00
Doxa Maps
a89c4f2e72 landing-preconditions says where it is: a first line, a deadline on every child, a time on every step
A landing scan sat 10+ minutes in state S before printing step 1, with no
build under it, and the log could not say what it was waiting on. Now:
- the first line is written before any child starts (pid, node version);
- every child gets an empty stdin and a deadline (git 3 minutes, the
  freshness rebuild 20), and a timeout REFUSES by name, with the elapsed
  time and a pointer to main-capture diag, instead of holding the lock;
- each step prints how long the scan has run.
2026-09-29 07:31:17 +00:00
Doxa Maps
16f2d51531 check:mst-clustering times its own CPU against a 1,500 ms budget, so a loaded box cannot fail it
gates:post runs several checks at once, and a Date.now() budget measured the neighbours too: 723 ms of work failed the 3,000 ms budget once. The budget is now the process's own CPU (process.cpuUsage, user + system); the output says "CPU" and prints the wall time beside it as not budgeted.

The budget moves to 1,500 ms, near the log-midpoint of the two builds measured here: the util takes 0.5 to 0.6 s of CPU, and the pre-924a185aa all-pairs path 4.4 to 6.9 s. Each side keeps about 2.5x, so a CPU 1.5x faster or slower still tells them apart.

Two-sided, on the 8-core box under load: 24 copies at once all PASS (CPU mean 464 ms, max 625 ms, while their wall time reached 4,076 ms); the previous Date.now() check under the same load: 18 of 24 FAIL. Against the pre-924a185aa MSTClusteringUtil it FAILS: 4,418 to 6,921 ms of CPU across four runs.
2026-09-29 07:31:17 +00:00
Doxa Maps
e34fac45d4 Cloud container: DEV_SERVER=off runs a tenant without the Plugin Studio dev server
No Vite, no pull loop and no Rudder: /cdn, PocketBase, the admin router and the sign-in stay, and / redirects to
/cdn/. The default stays on, so running containers are unchanged. Off without OIDC_ISSUER refuses to start,
because /pb and /admin would then have nothing in front of them.

Replayed onto development after the public Studio mode landed. Off together with ACCESS=public is refused
too, because the public Studio is the dev server itself. PULL_INTERVAL=0 still means no pulls, whatever
DEV_SERVER says. The setting is in the cloud README's table.
2026-09-29 07:31:17 +00:00
Doxa Maps
3268105643 The served capitals-and-cities table is integrity-pinned too, and a forged one is not drawn
Following the generator's pin (studio's follow-up): the Natural Earth table the sheets fetch at print
time (boundaries/ne-places.json) is data, not code, but pinned the same way — its sha384 sits beside the
generator's in poster-render.integrity.js (bun run pin:poster-render writes both), and posterSheets hashes
the fetched text before parsing it. A table that is not the pinned one is not used: the sheet draws no
city dots and says so in the console, rather than plotting places it cannot vouch for.

check:poster-render-pinned, two-sided for the table as for the generator: the pin matches the source
and the served copy; a FORGED table (every name replaced) yields exactly the sheet drawn with no places;
the genuine table IS used (its sheet differs) — the probe's pins now span Chad, because a single pin
framed too small an area for any place to fall inside it and the check could see nothing.
check:poster-browser-equiv still byte-identical; gates:pre green.
2026-09-29 07:31:17 +00:00
Doxa Maps
155c774b22 Share the legend-map bridge between the two 811-line twins
countries-map.vue and research-map.vue share 1,756 lines across 60
contiguous blocks, and 78% of that is one concern: the wiring between a
semantic-tree legend and the map beside it. The two files already import
the same 41 library modules and the same five legend-data composables, so
the data layer was never the duplication. The glue was, and glue is where
a fix lands in one copy and not the other: the packet kind-split, the
poster attribution reading and the packet page-1 map hand-off each shipped
fixed in one twin and broken in the other.

Only the four functions that are BYTE-IDENTICAL in both files move:
clearCountryHighlight, _flatItemsToStlNodes, onLegendRowClick and
_bridgeSelection. Measured, not assumed — applyDimFilter (185 lines against
170) and switchTab (49 against 50) genuinely differ and stay where they
are. Sharing code that merely looks similar is how a refactor picks one
plugin's behaviour for both.

A composable rather than a component: the legend markup already lives in
SemanticTreeLegend and its two shells, and a component would have forced
both plugins to give up the template where their real differences live.
Nothing is injected or reached for, so a second dashboard can adopt it
without inheriting research-map's provide tree.

Both calls sit after countryOutline and pplrInstance. The declarations they
replace were hoisted and ran above those consts; a composable call is not
hoisted, and placed too early it would read undefined at the first call
rather than at import — the legend would stop highlighting with no error.
The gate asserts both the dependency list and that ordering, for every
caller.

Rebuild-and-compare on both bundles: research-map 1,237,385 -> 1,237,709
bytes, country-posters 1,550,988 -> 1,551,317, and every behaviour-bearing
token identical in both — regions-fill, regions-border, fill-opacity,
fill-outline-color, line-color, line-width, visibility,
doxa:legend-row-click, _flat_filter, iso_3166_1_alpha_3. Counted rather
than set-compared, because minified output renames freely.

104 lines out of the plugins, shared lines 1,756 -> 1,702.
2026-09-29 07:31:17 +00:00
Doxa Maps
ae6d60780b Send the research map's live map to packet page 1 through the mapImage seam
Page 1 keeps the live map rather than a fetched sheet, and it was handed
across as a bare <img> in mapSvg — raw markup the renderer interpolates.
The page-1 redesign no longer draws that full page, so the map arrived
shrunk inside a layout built for a stored sheet. It reads as a styling
quirk rather than a wiring mistake, which is why it survived here after
country-posters was fixed. The URL now goes through mapImage, which places
it as the whole page.

This is the third defect of the same shape between these two files: a fix
lands in one twin and not the other, and nothing reports it. So the gate
asserts the seam for every host that rasterises a live map, rather than for
one file.

Two fixes to the check itself, both found by poisoning it rather than by
reading it. `[^)]*` cannot cross the arrow function's own parens, so
`useMapPoster(() => map.value).render(` never matched and the new section
skipped both files — it passed while testing nothing. And an older
assertion pinned the exact literal `{ mapSvg, variant }`, so moving to the
seam failed a check that was still describing the shape it replaced; it now
asserts that the variant travels, not the argument list it travelled in. A
gate that names one spelling of a call turns every refactor into a false
alarm, and a false alarm is how a real one gets waved through.

Both twins now use the seam. The render is skipped for a card deck, which
has no map page; this map's legend menu only asks for full or lightweight,
so that arm guards a future caller rather than a path taken today.
2026-09-29 07:31:17 +00:00