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.
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.
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.
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.
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.
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.
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.').
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).
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.
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).
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
useDashboardAggregations re-exported lbl, aggregate, OTHER_KEY, otherLabel
and NEUTRAL from @map/composables/useFacetAggregation.js, so that callers
could migrate after kit landing 4c-1 moved the generic half to the library.
Five files were still importing through it. They now import from the library
directly and the re-export line is gone; what stays in this module — colorFor
and its facet branches, the religion palettes, the donut and bar builders —
is research-specific.
DashboardChartsRail keeps its three builders from here and takes the other
four symbols from the library, so the split is by what each symbol is, not
by which file is nearer.
NEUTRAL is still imported: colorFor and buildCountryRegionColors read it five
times as the "no palette for this key" colour. Kit landing 4c-1 updated the
re-export line and not the import beside it, and every read threw
ReferenceError until that was fixed — deleting a re-export is not the same as
deleting the import next to it, so the import stays and the now-unused lbl
and aggregate come out of it.
Rebuild-and-compare on dashboard-plus-grid: 821.50 kB before and after,
899,237 bytes both, and every string literal appears the same number of times
in each. The builds differ only in which short names the minifier assigned,
which is what a change of module order looks like.
(cherry picked from commit f2cdcd0b62)
A locale can arrive two ways. ?lang and <html lang> are read at script
evaluation, and the preload in i18n/index.js covers them. profile-config —
the channel a host page uses — reaches ProfileLoader's watchEffect and
nowhere else, which is the channel's whole purpose; the comment there
already records that an earlier document-level fix never saw it. So with
the build shipping one locale, a host-configured embed set its locale,
mirrored its direction, and then rendered English with no error anywhere.
One line beside the setRuntimeLocale that marks that path.
The RTL hold is the second half. When the catalogue is fetched rather than
inlined, nine locales get a re-render — the words change, nothing moves —
and Arabic gets a layout flip: English left to right, then the whole chrome
mirrors. So the text waits and the map does not.
Not a v-if on the profile: <component :is="ProfileComponent"> is the entire
plugin, map included, and gating it would hold the one thing that must
never wait. A data attribute on the existing root plus a scoped rule over
named chrome classes leaves every component mounted, re-enters nothing, and
lets the map paint on its own schedule. The classes are named rather than
matched by a blanket descendant rule because two of the text-bearing
components also carry map objects. visibility rather than display, so the
chrome keeps its box and nothing reflows when the text appears — a reflow at
swap time is the same defect as the flip, only quieter.
The gate asserts the seam. "ProfileLoader mentions loadLocale" would pass on
an import nothing calls, so the watchEffect's own body is read by brace
depth and the call has to sit inside it. It also refuses a hold that names
anything map-shaped, a hold applied to the whole subtree, and a fetch that
could run without a pin. Poisoned both halves: removing the call and
pointing the hold at a map container each fail with the message that names
the defect.
(cherry picked from commit 5c54750d58)
The build writes a locale's sha384 only for a file it actually emitted, so
the pin's presence is the one reliable statement that there is something to
fetch. loadLocale keyed on messages[c] instead, which is not the same
question: a bundle that includes no areas inlines nothing, so messages is
empty with the flag OFF too and the preload requested locales/<lc>.json on
every non-English page — a 404 per reader, fetched with no integrity
because there was no hash to pass.
Keying on the pin makes the flag-off build inert and the flag-on build fail
closed to English. The integrity argument is now unconditional: reaching the
fetch at all means a hash exists.
Found by studio in review. Verified both ways: flag off is byte-identical
at 284.61 kB gzip with no locale pin map in the output at all, flag on is
unchanged at 165.23 kB with all ten hashes present.
(cherry picked from commit 89ab0d2a7b)
Every leaf inlines all eleven catalogues, so a reader who opens the map in
one language downloads the other ten. Measured on doxa-simple-map: the ten
unused locales are 113 KB gzipped, 40% of what a reader actually fetches.
With the flag on, the build inlines en — it is the fallbackLocale, so it
has to stay — and emits the other ten as <bundle>/locales/<lc>.json holding
exactly the areas that bundle uses. Same areasForBundle() answer the area
narrowing already uses, so the two dimensions cannot drift apart, and
emitted from the same plugin so composite-freshness, fix:sri and the
.gated/ split cover them with no new step. Off by default: the build is
byte-identical until someone sets it.
Not Vite's dynamic import. That was tried in April and GREW the bundle
~22 KB, because IIFE output needs inlineDynamicImports, which pulls every
chunk back in and adds a wrapper closure each. This is a plain fetch.
The base URL is captured as the script evaluates. Rollup shims
import.meta.url to document.currentScript, which is null once the script
has run, so a base read at mount would resolve against document.baseURI —
right on our own pages, wrong on every host that serves the bundle from
another path. deriveTreeBase() carries that same warning but returns the
tree root; these assets sit one level deeper, in the bundle's own
directory.
Each emitted file's sha384 is baked into the bundle and passed to
fetch({ integrity }), hashed from the same bytes the emit writes, so a
swapped catalogue fails closed to English instead of injecting text.
The fetch starts at module evaluation from the detected locale, so it
overlaps the bundle's own startup, and localeChromeReady() lets an RTL
locale hold its text chrome for up to 1.5s. For ar the flash is a layout
flip, not a text swap, which is worth a short hold; the map never waits,
and the timeout means a failed fetch degrades to English rather than to a
blank panel. loadLocale() runs after the instance exists, never inside
createI18n, because setLocaleMessage touches no effect scope while
createGlobal is the call that throws inside a stopped one. It also installs
into each instance that asks, since vue-i18n copies messages at
construction and a catalogue arriving later would otherwise reach only the
instances built after it.
Measured, doxa-simple-map: 284.6 KB gzip -> 165.2 KB, with ar as a 13 KB
asset. Ten assets emitted, every hash matching, ar no longer inlined, en
inlined.
The treeshake locale arm reads the narrowed build correctly (reports "10
asset") and fails when an asset is deleted. Its length floor is now per
script: a 25-character Chinese string is a whole sentence, so a
Latin-calibrated floor found no zh in a composite carrying short labels and
called it absent.
(cherry picked from commit 41d33d9a40)
A map says how it behaves on a narrow screen once, in its <profile>.meta.json:
"mobile": "desktop-only" (blocked on a phone) or "larger-screens" (opens,
under a dismissible notice). Absent means phone-ready. Until now the rule
lived only in the Research Gallery, as two hard-coded id sets, so a map
opened standalone on its own leaf page on a phone got neither the block nor
the notice.
- profile-meta.mjs knows the vocabulary; generate-tree and the dev manifest
carry the flag into each profile's manifest entry, and generate-tree into
the leaf page's BASE_CONFIG (with the map's name for the desktop-only
sentence).
- ProfileLoader mounts library/components/LargerScreenNotice.vue on a screen
under 640px: for desktop-only the profile is never mounted and a centred
message says why; for larger-screens a slim in-flow bar sits above the map
(it cannot cover the map's controls), dismissible per profile for the
session, in the element's own locale and direction. Inside the Gallery it
stays quiet: every Gallery pane now carries data-doxa-gallery.
- One text: the Gallery's two sentences moved into the library catalogue as
messages.largerScreenNotice / desktopOnlyNotice (+ noticeDismiss), all 11
locales moved as they were, and the Gallery reads them from there.
- The Gallery derives its rules from the manifest's flags. On a phone it
mounts nothing until the manifest has landed: its baked first tab (Big
Picture) would otherwise mount there before the flags say it is
desktop-only.
- dashboard-plus-grid is desktop-only and gospel-internet/board is
larger-screens, the two sets the Gallery held; nothing changes for either.
- check:mobile-gating now asserts the vocabulary, that each flagged leaf page
carries its flag, the Gallery's derivation and its closed-before-manifest
rule, that Big Picture stays desktop-only, the 11 locales, and the
notice's mounting. Its self-test plants one failure per rule.
Measured on the live list (2026-09-29): after loading, the store tested every photo by loading it, three at a time with a second between batches: 932 photos, 132 MB by their Content-Length (the 77 IMB photos alone are 115 MB), for as long as the page stayed open, whether or not anyone zoomed in. All 939 photo URLs answered 200, and the canvas already loads only the photos in view and draws its grey box for any that fail, so the test is removed. A headless run of the real store with a counting Image stub: 12 photo requests in the first 3.5 s before, 0 after.
A group with no photo URL now gets none, instead of the stock "no image" photo, which answers 404. Today every group has a URL (a regional photo when it has none of its own), so nothing changes on screen.
The source picker only showed with two sources or more, and there has been one since the WordPress API and the CSV were dropped. It goes, with the two loaders, the CSV parser, their CSS, and the card component nothing imports. The bundle is 9.6 kB smaller (2.5 kB gzip).
rega at 390 px: the Mobilize list, a bottom sheet over 54% of the map, had no way to put it down. A handle at the top of the sheet (both views, one shared choice) lowers it to a 56 px bar and raises it again, and the map frames the selection above whichever height it has.
RegionCountryMap is library source with two consumers, so both are rebuilt: the
people-groups failure message and the empty-codes guard are country-map's, and
the conditional pins fit reaches the regions dashboard as well. tree then
fix:sri, 127 agreed and 0 stale.
A country page frames by a fixed center and zoom, and a fixed zoom shows less on
a narrow screen. Measured on the drop-in: /regions/niger at 390 wide put the
camera over southern Niger and northern Nigeria with the Agadez outline cut off
at the top and 0 of the page's 2 pins in view, while the same page at 1440
showed both.
Inherited rather than introduced: their CountryDetail passes only center and
zoom to CountryMap and never a box, so the live site frames the same way.
chooserZoom cannot save it either -- its first line returns props.zoom unchanged
unless colorMode is 'region', and a country page is 'prayer', so the
width-and-height-aware solver below it never runs.
The fit is CONDITIONAL, which is the part that keeps this a clone. fitTheirWay
ends in easeTo({center, zoom}), so it moves the centre as well as the zoom;
fitting unconditionally would re-frame every page that is already correct. So
the configured camera is applied and kept, and the pins' box is fitted only when
a pin actually falls outside it. Asked on moveend, because getBounds reads the
current camera and flyTo is animated, so asking sooner would test the camera
being left behind.
The cap is a ceiling: maxZoom = props.zoom lets the fit zoom OUT, which is what
the narrow case needs, while forbidding it from zooming in past the camera the
page asked for. Capping it the other way would have made this a no-op on the one
case it exists for.
The pins' box is the honest input available here. The country outline comes from
the countries tile source, so its extent is only as good as the tiles that
happen to be loaded; reading a box off it would measure the viewport, not the
country. A box supplied by the page would be better still, since pins can
cluster in one corner.
Probe in _qa-cycle lifts pinsBox and fitIfPinsAreOutOfView out of this file by
source and tests both sides: a well-framed page takes zero fits, and the narrow
case fits the pins' box capped at props.zoom. Mutation-tested both ways --
removing the already-framed early return, and capping with minZoom instead of
maxZoom, each make it fail.
country-map filtered its fetched groups to the countries it was given, and fell
through to the UNFILTERED list when it was given none. So a host that omits the
attribute, misspells it, or passes a value that is neither an array nor a
comma-string got the entire global list drawn on an element whose whole contract
is one country.
Measured against the shipped country data: 2,106 groups across 175 countries,
where a typical country has 1 and the largest has 855. And the child's fill
expression matches nothing on an empty list, so those pins arrive with no
country outline at all -- a wall of worldwide dots that looks like data rather
than like the misconfiguration it is.
Empty now draws nothing, and says why in the console, addressed to whoever
embedded the element: this is a missing attribute rather than a data state, so
it does not belong in a translated sentence on the page. It sits beside the
fetch error that was already logged there.
Verified in the built bundle, not the source: the minified output carries
`E.size||console.error(...)` and the empty branch compiles to [].
Probe in _qa-cycle reads the filter expression out of the component and
exercises it. Mutation-tested: restoring the fall-through makes it report 3 pins
where it wants 0, and it passes only with the fix.
country-map reused the regions dashboard's load-failure sentence. That wording
is right on both regions surfaces and wrong here: this element has no region, it
has one or more countries, and what it fetches is people groups. A reader whose
country map failed to load was told the region data failed.
It was wrong in all eleven locales, not just English -- ar reads "بيانات
المناطق", ru "данные по регионам", de "Regionsdaten", all of them regions.
The people-groups sentence was already in the catalogue and already translated
in all eleven, so this adds no string and moves no parity: it is the same
sentence the grid and the other consumers already show when this fetch fails.
Verified in the built bundle rather than the source, because the minifier
rewrites the call: the component's own render now calls the people-groups key,
and the remaining "Region data" occurrences are catalogue entries, which ship
for every key regardless of which one is called. check:i18n-keys-exist passes,
so the key resolves. check:hardcoded-english is unchanged at 257 with
country-map contributing 0, measured before and after.
check:composite-freshness found stale on main: none. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.