regions-mfe built each pane by creating the child element and writing its
profile-config once, inside onMounted, and nothing rewrote it. The pane is handed
a JSON STRING rather than a reactive object, so a later change to the OUTER
element's profile-config updated the composite's own cfg and reached nothing
below it. regions-dashboard is kept alive by v-show rather than remounted, so the
one code path that would have rewritten the attribute never runs again and the
panes stay in the old language for the whole session.
The leaf was never at fault: ProfileLoader's config is a computed over its prop,
lang is a computed over config, and a watchEffect drives appI18n on every change.
Every link is reactive -- it simply never receives a new prop.
library/composables/usePaneConfig.js owns the missing half: a composite registers
each pane element with a getter for its config, and a watchEffect re-forwards
whenever any reactive source that getter reads changes. It writes only when the
serialised config actually differs, because setting profile-config re-parses it
in the child and can remount what it drives -- an unconditional write per tick
would be a rerender loop. Stale elements (a rebuilt pane column) are skipped.
It is a library helper rather than three copies because regions-mfe, upg-100-mfe
and country-posters-mfe all assemble a config and set the attribute once; they
differ only in which keys they inherit, so each keeps its own
INHERITED_PAGE_KEYS and hands the result to the helper. This commit adopts it in
regions-mfe only; the other two are upg100's to adopt.
Two-sided, headlessly: a check that fails when a composite writes profile-config
on a child without re-forwarding names all THREE composites on development, and
on this branch regions-dashboard reports "re-forwarding=usePaneConfig" while the
other two still fail.
NOT VERIFIED IN A BROWSER YET. rega's LIVEDIAG is the falsifier: the nested
[profile-config] should show the OLD locale on development and the NEW one here.
country-map.vue's poster-wall wiring put the poster table, peopleGroupRecords
and the sheet code into the bundle of EVERY DX Life country page, for a wall
that is hidden by default. Measured: country-map.js 391,475 bytes on
development against 683,401 here -- +291,926, a 74% increase -- and with it
removed the bundle is 391,625, back within 150 bytes of development.
Making the three imports lazy does not help, and I measured that rather than
assuming it: with all three behind await import() and the table fetched at
mount, the bundle grew to 684,700. vite.config.js:282 sets
inlineDynamicImports, so a dynamic import is flattened straight back in and only
the wrapper is added. Removing the weight needs the pinned-served-asset pattern
that poster-render.js already uses -- card
PERF-LOAD-THE-PRINTING-SUBSYSTEM-ON-DEMAND.
library/packet/countrySheet.js stays: it is unreached at runtime (0 hits in the
built bundle) so it adds nothing, and it is the library half this element will
use once the printing code loads on demand.
ProfileLoader's comment now names both populations -- 20 of the 25 bundles are
single-profile, 10 of the 17 that actually mount this loader, which are the only
ones the default affects.
Resolving package.json conflicts by unioning unique lines is right for a file
that holds one entry per line. Applied to ordinary source it duplicates
declarations, because both sides of a hunk keep their copy and nothing
de-duplicates across the join.
Eleven plugin entry points ended with the same import twice, and the poster
renderer with two identical `const sheet`. Each is a SyntaxError: every one of
those bundles would have failed to build, and the poster export would not run.
Found by parse-checking every changed .js against the merge base, not by the
gates -- gates:pre passed 101 of 102 with all twelve files broken, because its
parse gates do not cover plugin entry points.
One measurement trap worth recording. `node --check` on a .js file parses it as
CommonJS, so a bare `import` is a syntax error and ALL 28 entry points appear
broken, on development too. That reading says the tree is on fire and is simply
wrong. Copying each file to a .mjs and checking that parses it as a module,
which separates the eleven that are genuinely broken here from the rest that
are fine.
The handoff merges brought in three references that name the Driver in shipped
text: two comments in CenteredSetTarget.vue and one _why in the people-groups-grid
instances. Reworded to state the engineering reason without the speaker -- an
open decision now reads as pending a product decision, which is what the rule
asks for. The instances file still parses as JSON.
Three lines brought in with the list-pattern doc cited the Driver; the
reference gate refuses them. The rule, the scope and the open exception
stay as stated, without the attribution.
Merging gospel/a5-cpu and gospel/panel-followup brought back two comments that
quote the Driver directly, and check:no-new-driver-references failed on them —
correctly. Development had already swept these; the merge reintroduced them from
an older base.
Both keep their reasoning and lose the attribution: a recording pill that
changes width on every word reads as instability, and on a phone the header can
take most of the panel, leaving the draft unscrollable and its last lines
unreachable. Those are the facts the rules encode; who said them is not.
The consolidation brought back two Driver citations from older branches that
the reference sweep had already cleared from development. Both keep their
engineering reason and lose the speaker: a gate's rationale for existing, and
the mechanism of the print collapse.
Caught by check:no-new-driver-references, which is the point of running the
full chain rather than the seven gates the merged package.json had left.
Each of these shadow roots is position:absolute; inset:0, while its :host had
display:block with no position. An absolutely positioned element resolves against
its nearest POSITIONED ancestor, and an unpositioned host is not one -- so the
root fell through to whatever the partner page happened to have, or to the
viewport. It only looked right standalone, where the page fills the viewport
anyway, and inside a composite's .pane, which is itself positioned. A partner
embedding the element directly gets it painted outside its box.
All three are the shadow root's own root element, with only comments between
<template> and them, so none of them has a positioned ancestor to fall back on:
regions-list.vue:408 .rl-root at :409
region-dashboards.vue:733 .rd-root at :734
regions-mfe.vue:194 .rmfe at :199
dx-shepherd's census named the first as confirmed and the other two as
candidates to check. Checked: both are roots, not children of something already
positioned, so all three are real.
The fix is the house pattern -- position:relative on :host -- matching the
already-fixed cities-internet/connectivity-map byte for byte in shape, and
prayer-walk-planner.vue:628.
One line changed per file. Verified in the ARTIFACT rather than the source: the
rebuilt regions-dashboard.js carries all three corrected host rules (two at
min-height 320px, one at 420px). check:sfc-parse, check:synced-css and
check:one-scroll-container pass; the manifest diff is integrity hashes only,
0 other lines.
country-map.vue fetched /api/people-groups/list and mapped the posts straight to
points, without applyPeopleGroupList(). Every other consumer of that endpoint
calls it -- DataSourceManager, useDashboardData, useCsvExport, peopleGroupRecords
and the people-groups grid. So a host that asked for one curated list got the
WHOLE endpoint drawn on its country map, and nothing said so: the pins looked
like data rather than like the wrong list.
The list is applied to the raw posts, before they become points, which is where
every other consumer applies it. That is what keeps this element and a dashboard
showing the same country from disagreeing about which groups are in it -- the
same reason the country filter beside it is client-side.
The default list returns the rows untouched and makes no fetch, so an
unconfigured host pays nothing. A list whose membership cannot be loaded yields
NO groups rather than the wrong ones: applyPeopleGroupList's own contract, and
the safer failure for a prayer surface.
Measured three ways, with a temporary copy of check:universal-props that adds
country-map to its BUNDLES list (the reason the gate never saw this: 19 bundles
listed, this one absent):
- RED on the base: BYPASS country-map LIST-FETCH country-map.vue:98, exit 1;
- GREEN here: 20 bundles read engine, token, locale and list through the
library, exit 0, the gate's 9 planted-bypass self-tests still caught;
- a real list value changes the drawn count, through the shipped
applyPeopleGroupList and the served membership files: Nepal draws 34 points
on the default list, 29 on "frontier", 0 on "frontier-500k". That last zero
is data, not a failed load: the same membership matches 2 India rows.
Rebuilt on this base rather than carrying the earlier bundle, because
development rebuilt the same files in between and generated output is
regenerated, never merged. The list-membership URL is present in the built
bundle; the manifest diff is integrity hashes only, 0 other lines.
Two defects, one cause: on a short panel the engine-offline notice took the room
the draft needed.
FIRST, A PHONE RULE THAT NEVER RAN. bac2431d0 (19:59) put notices below the
draft "so the draft stays in view". 4e94e9216 (21:58) added the corner widget
with `.compact .notice { order: 0 }`. A media query adds NO specificity, so
`.compact .notice` (0,2,0) beat `@media { .notice }` (0,1,0) at every width and
the phone rule has been dead since the day it landed. Ordering the compact rule
to 1 fixes the widget at every width, not only under 480px, because the corner
panel is short regardless of the viewport.
SECOND, ORDER ALONE WAS NOT ENOUGH, which I only found by doing the arithmetic
rather than trusting the first fix. .notice is flex:0 1 auto and .compact
.compose is flex:1 1 0, so the draft yields whatever the notice's content asks
for no matter where it sits. The offline notice is ~132px at 375 and ~179px at
390 — reference text a reader needs once, holding a third of a short panel
forever. Capping it at 96px with its own scroll gives the draft 58px at 375x450
and 124px at 390x560, against rega's measured 22px and 41px.
The panel is 60vh (plugin-studio.vue:466), NOT the viewport. Sizing off the
viewport is what made my earlier estimate wrong by 58px; the model that uses the
real panel reproduces rega's 22px exactly.
No floor anywhere: a floor is what clipped the draft to begin with.
(cherry picked from commit 79cbb41bc3)
Same defect as cities-internet's list and table: no :host rule at all, so the
element falls back to display:inline with no height while .gt is
position:absolute;inset:0 — which then resolves against whatever the partner
page has positioned.
TWO MOUNTS, ONE RULE, and they do not collide:
- STANDALONE (data-tables.meta.json) this is its own custom element, so :host
applies and .gt is finally contained.
- COMPOSED, gospel-dashboard renders <DataTables/> as an inline Vue component
with no shadow host of its own, so :host matches nothing there. Its
'.gd-tables .gt{position:static;…}' override (gospel-dashboard.vue:81) keeps
working, which is what lets the stacked section take its natural height and the
page scroll. That override records a MEASURED failure — docScrollHeight 900
against clientHeight 900, a wheel gesture moving 0px — and must not return.
min-height, not height alone: with no height, .gt's inset:0 would give the table
0px in an auto-height box, so it would stop escaping by becoming invisible. A
floor gives an auto host something to grow from; a definite-height slot still
decides through height:100%.
NOT the ProfileLoader 100dvh floor (ProfileLoader.vue:410), which is the separate
upstream cause of the connectivity-map overflow and is studio's card. .gt is not
that wrapper, so this fix stands on its own.
(cherry picked from commit 10f2a5c13f8d08c0086ada8eafa4ad819863bd0a)
Neither element had a :host rule at all, so each fell back to the custom-element
default — display:inline, no height of its own — while its root div carried
position:absolute;inset:0. Absolute positioning resolves against the nearest
POSITIONED ancestor, so on a partner page both resolved against the PARTNER's
layout rather than against this element.
rega measured it on a partner page with a 420px host box: connectivity-list
painted to y=16,799 and data-tables to y=1,698 — 16,355px and 1,254px below the
box, over whatever the partner had underneath.
The same fix as connectivity-map's (60cb4ff18), for the same reason:
:host{display:block;position:relative;width:100%;height:100%;min-height:320px}
display:block gives the element a box; position:relative makes that box the
containing block; height:100% lets a definite-height slot decide; min-height is
the floor so an auto-height host grows to fit instead of collapsing.
NO NEW SCROLL CONTAINER WAS NEEDED. .ci and .ct already carry overflow-y:auto —
the scroller existed, it simply had no host to be bounded by. Once contained,
the list and the table scroll inside the box, which is what an embed should do
in a definite-height slot.
min-height is 320px here, not the map's 420px: regions-list.vue:408 is the house
pattern for list profiles, and a list is legible shorter than a map is.
(cherry picked from commit 08ad95fa86e64dfe043befb156e358c91319e511)
country-map.vue fetched /api/people-groups/list and mapped the posts straight to
points, without applyPeopleGroupList(). Every other consumer of that endpoint
calls it -- DataSourceManager, useDashboardData, useCsvExport, peopleGroupRecords
and the people-groups grid. So a host that asked for one curated list got the
WHOLE endpoint drawn on its country map, and nothing said so: the pins looked
like data rather than like the wrong list.
The list is applied to the raw posts, before they become points, which is where
every other consumer applies it. That is what keeps this element and a dashboard
showing the same country from disagreeing about which groups are in it -- the
same reason the country filter beside it is client-side.
The default list returns the rows untouched and makes no fetch, so an
unconfigured host pays nothing. A list whose membership cannot be loaded yields
NO groups rather than the wrong ones: applyPeopleGroupList's own contract, and
the safer failure for a prayer surface.
Measured three ways, with a temporary copy of check:universal-props that adds
country-map to its BUNDLES list (the reason the gate never saw this: 19 bundles
listed, this one absent):
- RED on development: BYPASS country-map LIST-FETCH country-map.vue:98, exit 1;
- GREEN here: 20 bundles read engine, token, locale and list through the
library, exit 0, the gate's 9 planted-bypass self-tests still caught;
- a real list value changes the drawn count, through the shipped
applyPeopleGroupList and the served membership files: Nepal draws 34 points
on the default list, 29 on "frontier", 0 on "frontier-500k".
Rebuilt, tree regenerated, SRI refreshed. The list-membership URL is present in
the built bundle; the manifest diff is integrity hashes only, 0 other lines.
The row meta joined its first three languages with English commas and cut the rest silently, so a truncated list read as complete. It now goes through formatList (Intl.ListFormat) as a conjunction whose LAST item states the cut, from keyCities.list.moreLanguages ("{n} more", landed in R29): "Hindi, Urdu, Bengali, and 2 more" / "Hindi وUrdu وBengali و2 أخرى" / "Hindi、Urdu、Bengali和另外2种". Three or fewer languages show as they are. Not 'unit': in zh it joins names with no separator, and in ar and hi it still adds a conjunction (studio, measured).
join(', ') is English list grammar. The two user-facing sites now go through the library's formatList (Intl.ListFormat, R29), as conjunctions because each is the FULL list:
- PPLR's Rosetta popup, "Not yet cross-mapped in PPLR: {ids}";
- UPG Neighbor's bridge-language cards, "Believers in: {list}".
Key Cities' shortened language list follows once its "{n} more" key (keyCities.list.moreLanguages) is on development. The joins that stay are not reader lists: a thrown error's details, the bridge-languages CSV columns, and a gate's console line.
Two reader-visible lists were joined with a hardcoded ', ': the ADM1 names
in the selection error ('no people-group data for {names}') and the
selected territories in the batch HUD. Both use formatList now (R29), so
de reads 'Gujarat, Rajasthan und Odisha', zh 'Gujarat、Rajasthan和Odisha',
ar 'Gujarat وRajasthan وOdisha'. The locale is passed from the i18n ref
(packetLang), not left to getRuntimeLocale, so the HUD's list re-renders
on a runtime language switch. formatClipboard's join stays: its only
caller is a test handle.
Three places joined names with ", ", which is a list only in English. Arabic
separates with a word, Chinese uses a different mark entirely and no comma at
any position, and German and French put a word before the last item. The
separator and the final connector are the grammar, so no catalogue key can fix
them from outside.
Two of the three carried a second, worse error. A multi-value filter holds
ALTERNATIVES -- the bus matches a row if ANY of its values matches, in
useSelectionBus with some() and in the data table with includes() -- so the
chip means "Islam or Hinduism". Joined with a comma it reads as "and" in every
language with an explicit connector, which states the opposite of what the
filter does. Those two become a disjunction.
The third is the screen-reader live region, which names the facets currently
applied. Those hold together and narrow the set in turn, so it is a
conjunction. It is also the one string here that some readers rely on entirely,
having no chips to glance at.
The composable is the single owner: every surface that turns a filter into
words comes through it, so the chip bar, the research-filter chips and the CSV
provenance line now say the same thing in the same language.
Left alone deliberately: five joins in this plugin's gate scripts. Those are
failure messages read in a terminal by whoever runs the gate, not text shown to
a reader, and formatting them per locale would help nobody.
Source only. This needs the bundle rebuilt before it ships.
formatList (R29) replaces join(', ') at the two sites that a reader sees. The
separator was never a missing key — no key can fix punctuation between
interpolated values — which is why both survived every i18n gate.
connectivity-map.vue:365, CONJUNCTION. The Indian states with no boundary are
ALL missing one. The names land inside a translated sentence at :595, and the ar
message reads "لا توجد بعد حدود ولاية لـ {names}: …", so English commas were
being printed inside Arabic prose.
GiCombos.vue:49, DISJUNCTION, decided from the matcher and not from what reads
better: useSelectionBus.js:299 tests cur.includes(value) and toggleFilterValue
adds to the list, so two values on one facet WIDEN the result set. "Africa and
Asia" would state the opposite of what the filter does. This changes the English
too — "Africa, Asia" becomes "Africa or Asia" — which is a gain, because a comma
silently reads as ALL.
Measured, the same three names and two facet values through each type:
en "Ladakh, Puducherry, and Chandigarh" "Africa or Asia"
ar "Ladakh وPuducherry وChandigarh" "Africa أو Asia"
zh "Ladakh、Puducherry和Chandigarh" "Africa或Asia"
was "Ladakh, Puducherry, Chandigarh" "Africa, Asia"
ar joins with و and no comma at all; zh uses 、. Neither is reachable by keying.
Not converted, and deliberately: useBoard.js:126 is a console.warn for
developers, and rudder-voice.vue:971 is in a component with zero i18n imports and
no catalogue area — English by construction, so it converts with the rest of
Rudder if Rudder is ever localized.
Source only. The rebuilds are regenerated after this rebases onto landed R29.
73bc270bc gave :host a height and stopped the ELEMENT overflowing. It did not
stop the MAP, because it never made the host a containing block.
.gm is position:absolute;inset:0, which resolves against the nearest POSITIONED
ancestor. :host had display:block and a height but no position, so on a partner
page .gm resolved against whatever the PARTNER had positioned and the map sized
itself to that. rega measured it in the hostbox run on ca07fc3c8: the element
correct at 420px inside a 420px box, the canvas inside it still 900px, and the
"Tap a coloured dot" pill and the attribution painting 478px below the box —
538px in a 360px box — over the partner's own text, whose top sits at y=464/404.
position:relative on :host is the whole fix, and it is the house pattern:
prayer-walk-planner.vue:628 carries the identical shape with an absolute child.
Worth recording, because the first fix passed every check: a host box that is
the right size proves nothing about what is drawn inside it. Only a partner-page
shot with content BELOW the box can see this, which is why rega's hostbox run
found it and my source reading did not.
With no resolver configured, the kit chip bar joined a multi-value filter with ', ', so "Africa,
Asia" read as ALL to most people and carried English commas in every locale. The selection bus
matches several values on one facet with includes() (useSelectionBus isFilterActive), so adding a
value WIDENS the result: they are alternatives. The chip now uses formatList(value, { type:
'disjunction' }): "Africa or Asia", "Africa أو Asia", "Africa或Asia". gospel traced the matcher
and made the call as the only consumer's owner (gospel-internet's GiCombos). The English on this
chip changes from a comma to "or".
check:legend-mirrors lifts this exact expression and drives it with no resolver. It now lifts
formatList from library/i18n the same way (the shipped grammar, not a copy) and expects
"Islam or Hinduism". Poisoned with the old join it fails; restored it passes. formatList's doc now
says to choose the type by what the list MEANS, and never 'unit' for names.
useLegendMapBridge.js now exports regionIdle (sr, pair 1). The local
function is deleted and the name imported beside the bridge's other
exports; all 15 calls keep their values, the dynamic regionIdle(v)
included. Built bundle: the idle expression occurs exactly once before
and after (the local copy before, the library's after), 1 byte apart;
the country-posters composite still mounts. check:region-paint-single-
source PASS. Also drops a comment left behind when clearCountryHighlight
moved to the same library module.
useLegendMapBridge took the idle paint helper as an injected dependency and
called it five times, while each consuming map defined its own copy of the
one-line body and handed it over. The library owned the paint logic and not the
expression it painted with, so the copies could drift apart silently and the
two maps would render the same idle state differently.
Export it from the composable instead, and take it from there in this map. The
injected parameter is deliberately KEPT for one round: inside the composable
the parameter shadows the module export, so what the bridge paints with is
still exactly what its callers pass, and this change cannot move behaviour. It
is a deletion in the plugin and an addition in the library, nothing more. The
parameter goes when the second map has adopted the export too.
This map held a THIRD copy under a different name -- a function-scoped helper
in the paint prewarm, byte-identical body, used once. It now refers to the
library helper.
Verified rather than assumed: the library version and both original plugin
versions produce identical output for every value the maps actually pass,
including the dynamic one. check:region-paint-single-source is green for this
map and stays red for the other until it adopts the export, which is the state
the gate is meant to report.
The second map is the other plugin's file and is not touched here; its owner
takes that half.
Until now these ran from _qa-cycle, so they proved a landing once and guarded nothing after it. Each now lives with its plugin, locates the repo from its own path, and fails on its known-bad:
- wall-of-people-groups/scripts/check-wall-wysiwyg.mjs: the screen poster and the print are one layout on every preset (sheets, 2,107 card rects to 0.002 in, name size and wrap, reference size, chrome blocks). Planted: a card moved 0.01 in, a name a point larger, the footer missing on screen. About 0.6 s.
- wall-of-people-groups/scripts/check-wall-print-watch.mjs: the HD print counts its photos while they load, counts the ones that could not load, finishes at load or the 10-minute ceiling, and ends when its frame is gone. Planted copies of printWatch.js now go to the OS temp folder, never into the tree. About 0.3 s.
- wall-of-people-groups/scripts/check-wall-no-bg-photos.mjs + fixtures/pray-list-sample.json: the real store (through Vite) never requests a photo on its own, a group with no photo URL gets none, and one with a URL keeps it. Planted: the old background test, added through the store's $onAction. The store from before L12 (d9e9aa448) fails both arms: 12 photo requests in 3.5 s and 3 groups on the 404 placeholder. The fixture is a trimmed sample of the live list: 200 rows (120 with has_photo, 77 without, 3 synthetic with no image_url), only the fields the check reads, 66 KB. About 10 s.
- template-bundle/scripts/check-template-i18n.mjs: profile B renders from template.json in en, ar and zh, one sentence in each language's own order with both <code> islands, and no raw key. Planted: the English catalogue served as Arabic. About 4 s.
All four are headless: no browser seat.
This corrects my own "Other" fix (9d12e59b6), which could never fire. gospel's cities-internet analysis (87a168195) showed the same shape here: key-cities.json has no row with an empty region (1,646 rows), "Other" (2) and "N/A" (19) are region VALUES, and every section heading printed its raw English data string in every locale. The fallback I translated also put a label into the grouping key.
Now, as in cities-internet: the raw region string stays the grouping key (sections, :key, collapse state), with 'Other' as the data guard; the heading is projected at render by regionLabel.js from the keys the data's strings already are the English values of (dashboard.wagfRegion.*, dashboard.other, wall.na), written out in full so the build ships those areas. The poster wall, whose PosterWall prints its section name as given, groups by that projected heading, as country-posters' wall does. keyCities.posters.otherRegion is no longer used.
Headless (_qa-cycle/exp-shepherd/check-keycities-regions.mjs): every one of the nine region values maps to its catalogue heading in ar, zh, es and ru, and an unknown value passes through as data. key-cities is gated, so nothing in public/ changes.
The chips a reader sees on a group's sheet — "Scripture,", "Bible video",
"Training", "Gospel presentation" — were plain template text. All four already
had keys with real ar and zh translations, so this routes them through
gospel.res.* and adds nothing: scripture, bibleVideo, training,
gospelPresentation.
WHAT IS DELIBERATELY LEFT ALONE, because it is not a label:
- g.audio.m keeps its raw value and its 'audio' default. That is the table's own
media descriptor, data rather than UI text.
- gospel-resource-rows.js is already correct. add(title, type, …) translates
title through tx() and keeps type as the category identifier the rows are
grouped by; the gate flags those type strings, but translating them would
break the grouping — the same value/label trap as the region headings.
- d.s.replace('Open Bible Stories', 'Bible Stories') at :2754 rewrites a DATA
value, not a label.
- data-tables.vue:122 already carries labelKey; its `en:` is the fallback beside
it, by design.
- The caveat prose (Driver row 80) and the title= tooltips (row 120) are
untouched and stay that way until those rows are answered.
check:hardcoded-english on gospel-resources: 91 -> 87, which is these four.
rega's L12 photo probe read host.__wallLod().cardPx as 0 after 46 wheel ticks: the field was set once when the cards were first laid out and never again, since the canvas renderer replaced the LOD component that used to update it. The canvas now records one card's on-screen width at every frame it draws (stats.cardPx, both the grid and the poster), and the hook returns that. The container's header comment no longer claims Font Awesome is loaded: the canvas draws its own icons, and d3 is pinned and kept off window.d3 (src/wallD3.js).
rega saw "Other" in ar and zh. The reported cause — `c.region || 'Other'` at
:65 — is not one: of 175 rows in connectivity.json NONE has a falsy region, so
that fallback never fires. The "Other" section is one row (United States) whose
region IS the string "Other". Patching :65 would have changed nothing on screen.
The render site is `{{ sec.name }}`, and it printed the raw data value for ALL
NINE regions, so this was nine English headings rather than one.
All nine already had translated keys — dashboard.wagfRegion.* for the seven WAGF
regions, dashboard.other, wall.na — in all 11 locales. None needed adding.
THE LABEL IS PROJECTED AT RENDER, NEVER AT GROUPING. sec.name keys the Map, the
:key, and the collapse state, so translating it where the rows are grouped would
reset every open section on a locale switch and filter on a value no row carries.
That is the value/label trap useFacetAggregation.js documents at OTHER_KEY; this
follows the same shape.
The name->key map is built by asking the ENGLISH catalogue what each key says,
rather than writing the English phrases here: the data's region strings are the
catalogue's own English values, so English is the join and never has to be
duplicated. Written the other way it added two check:hardcoded-english findings
to a file that had none — an English phrase used as a lookup key reads exactly
like a user-visible string.
tStatic(path, 'en'), not t(). t()'s signature is (key, params, plural), so
t(k, 'en') passes 'en' as params and returns the RUNTIME locale's value: the map
would be built from Arabic values and match no region at all.
The keys are written out in full because internal/i18n-areas.mjs bundles an area
only when its name appears literally in a reached file. cities-internet shipped
14 areas and neither dashboard nor wall; assembling these at runtime would have
left all nine unresolved in the shipped bundle while working in the dev tree.
Measured both ways — written out, the bundle takes 16.
`|| 'Other'` stays as a data guard so a future row with no region cannot key the
Map on undefined, and 'Other' is the sentinel dashboard.other projects.
9 of 9 headings translate in ar and zh, verified against the real catalogues.
rega's L15 (e7f343d4c, ar + zh): every PPLR map view's fullscreen button read "Toggle fullscreen" in English, title and aria-label. It is PPLR's own control (FullscreenToggleControl in src/utils/pplrMap.js, not MapLibre's), and it wrote the English itself. It now takes the shared buttons.toggleFullscreen, already translated in all 11 locales, when the map is built.
cities-list.vue grouped region-less rows under a hardcoded 'Other' section heading, a single English word, which is the hardcoded-english gate's blind spot. It now reuses keyCities.posters.otherRegion (all 11 locales, landed in R25). The same literal in cities-internet's connectivity-list, where rega's R25 look found it in ar and zh, is gospel's and goes to gospel. The SSR check (check-keycities-i18n.mjs) passes; key-cities is gated, so nothing in public/ changes.
boardEngine.js was createWhiteboardMap under another name: the bodies are
identical line for line, and every import it made already came from library.
It is deleted and the four components import @map/maps/whiteboard-map.js.
The copies were NOT interchangeable, which is the part worth recording. Their
DEFAULTS are opposites, because the library file was extracted for the
whiteboard case and boardEngine served both map kinds:
boardEngine whiteboard:false boundaries:true centre [60,20] zoom 1.4
library whiteboard:true boundaries:false centre [0,0] zoom 1
Three of the four callers pass all four options explicitly, so they were never
at risk. GiMap — the geographic map — passed only a token and took every
default. Swapping its import alone would have set whiteboard:true, hiding every
basemap layer, dropping boundary attribution and moving the camera: a blank map
in production, from a change that reads like pure de-duplication.
So GiMap now states its four options. Every effective option is unchanged from
today's values, verified field by field, and the call reads the same against
either module.
Twenty-six references in capitals, which the earlier case-sensitive pass
could not see. Every requirement, date and measurement is kept: the 2026-09-23
instruction that removed the engagement and status filters, the MapLibre ask
with its Mapbox-compatibility condition, the blue-water-black-galaxy style,
the red ramp and the bin that collided with the heartbeat zone, the white
rings around the blue dots, the zoom that did not work inside the bounded map.
Two that are not quotes and were handled differently. The reduced-motion gate
opens with a medical reason — a reader of this map is photosensitive, and a
camera that sweeps the globe on every filter is a strobe — which is the whole
justification for the gate, so it stays as a fact about a reader rather than
about a person. And BpResearchHeader's "CAT-1 DRIVER CONTRACT" was a code
term; it is now the CAT-1 HEADER CONTRACT, checked for other references first.
Eleven lines broke and were found by re-reading every changed line, not by the
count. Removing an attribution left seven continuation lines holding the
closing half of a quotation that no longer opened, and four sentences carried
a pronoun for the speaker who had just been removed. The reference count was
correct at every point while the prose was wrong.
grep -wi driver across the three plugins: 0. The widened gate reports 40
swept, 14 still baselined. All six SFCs parse.
Chrome only, as scoped: the controls, status lines and navigation, not the data labels or the research notes. 192 English strings → 155; the 155 left are data and notes by that decision:
- the Semantic Trees reference (85: sample tree rows, API field names, taxonomy levels);
- the data-source panel entries (layer names, FeatureServer paths);
- the language family names and the tree level definitions;
- the index tiles' descriptions and source line;
- the cross-system ID names;
- five CSS/SVG values the scanner reads as prose.
What changed:
- The ten view names were written twice, English beside their tKey. The dead English goes, and the bar's brand is pplr.title.
- The theme and projection buttons on the three map views said "Theme: Dark (click to toggle)" / "Switch to Globe". They now say what they do, from the shared keys buttons.switchTo{Light,Dark}Mode and mapui.{enable,disable}Globe, as both title and aria-label.
- The sunburst's status is a key in state (pplr.status.d3Failed / readyD3), translated where it shows, so it follows a language change. Its count uses formatCount like the other views.
- The name-locales view's pill (pplr.pill.translations) and its data-sources button and header (pplr.ui.dataSources) are keys.
- The coverage scale's "0 locales" stop reads 0, like its other stops; the legend title names the unit.
- The index's title and help line are keys, and its tiles are named by their views' own names. Its old help line pointed at an Overview button that no longer exists.
22 English strings outside the catalogue. 10 were dead duplicates: the filter, tab and band labels were already translated from keyCities.* in the templates, so the English copies go. The rest now come from keys:
- the list's caveat is three translated lines, and its long sentence is ONE key rendered with useSentence, with the named-centre count and "Nominatim/OpenStreetMap" passed in as values (the name as its own left-to-right island, never translated);
- a row's group count reuses keyCities.list.peopleGroups, and the unknown internet pill reuses keyCities.map.unknown;
- a load failure reads "The data could not be loaded: {detail}" in the reader's language, with the server's own words as the detail (list and tables);
- the poster wall's intro and its "Other" region come from keys.
Headless, SSR with the real catalogue (_qa-cycle/exp-shepherd/check-keycities-i18n.mjs): the caveats render from keyCities in en, ar and zh, the gazetteer's name stays a <bdi dir="ltr"> island, no raw key or placeholder. The experimental English scan: key-cities 22 → 0. key-cities stays gated (dev only); it builds.
The starter every new plugin copies had 7 English strings outside the catalogue: profile A's "map engine not loaded" and all of profile B. Now profile A keeps its error's KEY in state (errorKey) and translates it in the template, with the engine's own message passed in as a value; profile B's heading, its note line and its sentence are keys, and "Served from {file} in the {bundle} bundle." is ONE key rendered with useSentence, the file and bundle names passed in as their own <code dir="ltr"> islands so each language orders the sentence itself. t() is called in the template throughout, never frozen into a constant at setup. The README's pieces table and "Your own text" section say the same three habits.
Headless, SSR with the real catalogue (_qa-cycle/exp-shepherd/check-template-i18n.mjs): profile B renders in en, ar and zh from template.json, with the sentence in each language's own order (zh puts the bundle first), both islands intact, and no raw key or placeholder. The experimental English scan: template-bundle 7 → 0.
check:no-new-driver-references matched "Driver" case-sensitively, so lines in
capitals passed unseen -- and my own greps were blind the same way, which is why
I reported this plugin clean twice. A case-insensitive sweep finds four, and one
of them carries a verbatim quote.
All four keep their engineering reason and lose the speaker:
· the reuse check states the negotiating position rather than whose it is;
· the substituted button font is blocked on a licensing decision, not a person;
· the code, not just the screenshot, is required to match;
· the table's ring and gridlines live in a global stylesheet, which is why
reading RegionDetail.vue alone could not show them -- the misspelt verbatim
quote is gone, the finding it pointed at is not.
Verified with grep -wi rather than the grep that missed them: 0 remain across
regions-dashboard and country-map. The four reasons are each still present by
their own text. sfc-parse, mjs-parse and no-new-driver-references all pass.
The gate matched "Driver" only, so "THE DRIVER'S ASK" and "HELD FOR THE DRIVER"
passed unseen — and so did my own verification, which used the same
case-sensitive pattern and reported zero. I checked the thing the gate checked
rather than the thing that mattered.
Seven lines across six files: five banner headings, one quoted requirement, and
the row-130 marker. Same rule as the first sweep — keep the reason, drop the
speaker — with the verbatim quote in build-doxa-gospel-access.mjs restated as
the question the map answers, since its content is the requirement and its
framing was attribution.
Comment-only: 0 non-comment lines changed, tree regenerates identically.
Case-insensitive grep across all three plugins now returns nothing.
check:no-new-driver-references matched 'Driver' only, so lines in capitals
passed unseen (and so did my own grep -w Driver). In my-upg-100-list:
'BATCH-UNION DRIVER' and 'gate drivers' are test handles, so they say so;
'in its driver' is generate.mjs's main routine; the zoning declarations'
'PENDING DRIVER' and 'In DRIVER-DECISIONS-DIGEST' become a pending product
decision and the product-decisions digest. grep -rni driver across both
plugins: 0.
Since the regional grouping, buildLegendNodes assembles the tree from the
LEAVES under the poster page's nine region sections. The old India, China,
"Asia (admin-1)" and "Frontier Asia (uncovered)" parents were still built
and never returned: dead code whose English labels the call-site triage
read as reader text. They are deleted, with sumCount, which only they used.
The legend's output is byte-identical for all four configurations
(with/without records, with/without the localizing options; 2,107 records).
The L15 shape assertion now composes its text inside the throw: the
offending leaves are collected as data and formatted in new Error(...), so
the message is visibly developer text. Same message, checked on bad input.
pop-proof.mjs had been failing on every run, because section 3 looked up
those same dead parents, and section 1 expected their count (32). It now
rolls up the nine region parents the tree actually has (each pop = the sum
of its children, and there must be at least one) and expects 28 leaves + 9
regions = 37. ALL PASS, identically against the previous legend-tree.js.
Fourteen references, each rewritten to state the requirement directly. Every
date, measurement and pixel coordinate is kept — the y=1827/y=1865 redundancy
that prompted the count line, the 2026-09-22 interoperability report behind
the Language and Status props, the 220x130 letterbox the square image
replaced, the dev-server path that showed "view more information" reaching
the wrong page, and the India click that left the grid unchanged.
Thirteen were found by a case-sensitive search and a fourteenth was not: a
line in capitals, carrying a verbatim quote, which `grep -w Driver` cannot
see. The check that catches both is `grep -wi driver`, and it now reports 0.
Two sentences broke in the first pass and were found by re-reading every
changed line rather than by the reference count: one left "the control the"
with its object removed, and one left a quotation with an unclosed quote when
the surrounding attribution went. The count was correct while the prose was
wrong, which is why the count is not the check.
check:no-new-driver-references passes. research-map-plus-grid and
dashboard-plus-grid both rebuild byte-identical.
rega at 390 (b07fc59e0, en + ar, both views): the sheet lowers and raises as it should, but
- the map's attribution control sat under the sheet, lowered or raised, in all four cases. On a phone the map's bottom control corners now sit above the sheet: 55% + 6 px when it is up, 62 px when it is down;
- a pick with the list lowered moved the map but showed no detail, because the detail is in the sheet. A pick (a group, a neighbour dot, a bridge language, a group opened from Campaigns) now raises the sheet FIRST, so the frame is computed for the sheet that shows the detail;
- the hover tip was cut off at the right edge in ar ("Ko…"): it opened to the right of the point wherever the point was, and after a tap it stayed where the tap had been while the map flew to the pick. It now opens toward the side with more room, is at most 45% of the map wide, and clears when the map moves.
gospel-resource-rows.js builds each resource row's display text, and its
fallbacks were English literals: a record with no name of its own showed
"Scripture in this language", "Audio Scripture", "Listen in a browser" to an
Arabic or Chinese reader — precisely the rows whose source data was least
complete. check:hardcoded-english does not read .js files, which is why the
bundle's sweep never saw them.
Thirteen keys in eleven locales, and the translator is PASSED IN rather than
imported: the module is a pure function with no component context, and it stays
optional, so a caller without one degrades to the English written at the call
site instead of throwing.
Three publisher names stay verbatim — JESUS Film, Scripture Earth, Zume — but
where a name sits inside an ordinary phrase the phrase is keyed and the name is
INTERPOLATED, so "Zume discipleship training" translates around a brand a
translator never retypes. SE_KIND's values are displayed and take keys; its keys
are the upstream feed's codes and must not be touched.
Verified by running the module both ways: without a translator every row reads as
it does today; with an Arabic one the labels and scopes are Arabic while
"Scripture Earth" and "Zume" are unchanged.
ROUTED TO STUDIO, not self-landed: the eleven locale files make this a library
change. The route test says 11, and the last time I judged a landing by which
plugin the work was for rather than which directories it touched, the scan ran
against most of the fleet and timed out twice.
The shell's comments carried 23 of the reference baseline's lines, one more written in capitals that
the baseline's pattern never saw, and four internal card ids. Each is reworded to keep the engineering
reason: the 404s, the grey pane, the scroll trap, the device rule and the curated order all stay
explained. Quotes became the rule they stated.
Comments only. The script (esbuild, whitespace and syntax only, no renaming), the template (compiled
without comments) and the styles (comments removed) are identical before and after. The baseline
shrinks by the 23 lines that are gone.
<doxa-country-map> sent center: null when the host set none, and null is not
absent. Vue applies a prop default only for undefined, so an explicit null
REPLACED RegionCountryMap's [20, 10] default rather than falling back to it. The
null reached the Map constructor, and the engine's LngLat.convert threw before a
style or a tile was ever requested.
So any host that gave countryCodes without a centre got a dead map -- and the
page told them it was "a map-token or connection problem", which blamed the
wrong thing entirely and sent two rounds of investigation after providers and
tokens. Both center and bounds are documented OPTIONAL in this element's
contract, and the manifest's own embed snippet sends neither, so a partner
following our published snippet got nothing.
MEASURED on a host page across twelve cases: MapLibre selected correctly in all
of them, 0 requests to mapbox.com, 0 responses >= 400, 0 failed, 0 unanswered --
and no request to the tile host at all, because the map never got as far as
asking. The console carried the real cause the whole time.
The element now sends undefined, which is what "the host set none" means. That
alone restores the default and starts the map; the defensive guards inside
RegionCountryMap go separately as a library change, because they protect a
different caller rather than this one.
Probe reads the shipping source and states Vue's own rule independently, since
that rule is what makes this a bug. It reports whether the library guards are
present rather than requiring them, so it stays honest on either tree.
Two untranslated strings on a production print surface, both reported by the
Country Posters plugin after it fixed the same pair on its own wall.
THE CARD NAMES. postersFor returned the country-posters.json record unchanged,
and that record carries its label in English only -- so every card with a poster
was named in English in every locale. label is not decoration: it is the card's
accessible name, the print HUD's title, and the title on the printed list, deck
and packet. It now goes through countryName(iso3, locale) for a non-English
reader. English keeps the data's own names, because Intl's English differs from
them for 32 of these 175 countries and that swap is a recorded product decision.
The sheet generator still receives the original record; the poster itself is
English by the same decision. The locale REF is read, not a snapshot, so a
runtime language switch renames the cards.
INDIA'S REASON. posterUnsupported returned the record's own English note, so one
untranslated sentence sat on the wall in every locale. The record still decides
WHETHER a country is unsupported; only the wording now comes from the catalogue.
The key needs no number in it because the card shows the count directly above.
★ AND IT NEEDED A SECOND TRANSLATOR, which is the part that would have shipped
broken. This page's t() is useRegionsI18n's: it resolves DX-life's English
sentences as keys inside the `regions` namespace, because that is what the page
is cloned from. messages.countryPosterTooMany lives in another area, so t()
would have missed and rendered the literal string "messages.countryPosterTooMany"
on the wall. tKey is vue-i18n's own lookup, added for keys outside this page's
namespace.
Verified in the built bundle rather than the source: DisplayNames present,
countryPosterTooMany present. A probe over the real alpha-2 table confirms the
localisation across ar/ru/zh/de/fr and that an unknown iso3 falls back instead
of throwing. gates:pre 75 PASS / 0 FAIL / 1 NOT MEASURED of 76; i18n-parity and
i18n-keys-exist both pass. Cost +244 B.
FIX-RUDDER-PANEL-DRAFT-LAST-LINE-UNREACHABLE-WITH-TALL-HEADER.
.compact .compose carried min-height:120px, so the column refused to shrink and
whatever the header took was pushed past the panel's clip. The draft's own
scrollbar cannot help: the overflow is OUTSIDE the scrollable area, which is why
a wheel over it reaches the end of its content while the last line stays hidden.
Measured in the corner widget with the engine offline, header 237px:
390x560 — panel leaves 99px, the floor forces 120, overflow 21px.
375x450 — panel leaves 33px, overflow 87px.
Both reproduced independently, and both are exactly 120 minus the room available,
which is what makes the floor the mechanism rather than a coincidence.
Pre-existing: present on plain development as well as on my earlier branch, so
this is its own defect and not something a previous round introduced.
The floor is removed unconditionally. A viewport-height guard would not work —
what consumes the room is the HEADER, and a notice can make it 237px at any
viewport; an earlier version of this fix guarded above 520px and would have left
the 560px case still broken.
Cut clean onto development rather than carried on the branch it was written on:
that branch also held two hunks that were deliberately withdrawn after an A/B
showed they changed nothing measurable, and landing it would have quietly
reinstated them.
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.
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.
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.
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.
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.
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/.
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.
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.
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.
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.
<doxa-country-map> gains the poster, prayer list, card deck and packet that the
regions wall has, for the one country it draws. The surface is opt-in through
profile-config (showPosters, read only as literal true or 'true'), off by
default, behind v-if so an embed that never asks for it emits no markup, and
loaded through retryableAsyncComponent rather than a static import.
NOT LANDABLE YET, and the measurement is why. Built element, default OFF:
baseline 528,901 B
with the surface 930,692 B
delta +401,791 B
The lazy load does not help. This repo builds each bundle as a single IIFE with
inlineDynamicImports, which flattens an async component exactly as it flattens a
plain import(): PosterWall, packet-render and createPosterGenerator all appear in
the built element with the flag off. my-upg-100-list's lazy detail panel shows
the same pattern, so this is the build shape rather than this wiring.
So a country page that never turns the surface on would still carry ~400 KB of
printing code. This element exists precisely so such a page need not load the
regions dashboard, so that trade is the wrong way round. It waits for the print
subsystem to be served on demand, the same change that took the regions wiring
from +208 KB to +17 KB once the sheet generator moved to a pinned asset.
countrySheet.js moves to library/packet/ HERE rather than in the wiring commit:
its second consumer is this parked surface, so until this lands the file has one
live consumer and stays plugin-local. The wiring lands plugin-only.
Holds either way: records and sheet come from the shared providers, never a copy;
the single poster item resolves from country-posters.json, with an
unsupported-reason row for a code that has none; poster-action is print
throughout; no host is switched on.
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 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.
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.
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.
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.
Generating map sheets in the browser inlined Natural Earth's capitals and cities (170 KB) into every
bundle that can print one: single-file builds inline lazy imports, so the whole page paid for a table
only a reader who prints needs. The tree now serves it beside the boundaries (boundaries/
ne-places.json, one line in generate-tree's copy list; public domain, no credit to carry) and
posterSheets fetches it at the first print, with the boundaries. No asset base, or a failed fetch,
draws no city dots — the generator's own rule for absent data. The Node generator reads the same file
from its source.
Measured: my-upg-100-list 482 -> 432 KB gzipped, country-posters 547 -> 496 KB. check:poster-browser-
equiv still passes byte-identical through makeZoneSheet and generateCountrySheet, which now fetch the
served table — so the fetch path draws the same dots Node does. gates:pre 73/73.
Ten agents read today's branch (five finders, one skeptic per area who had to confirm each finding
against the current code); 21 distinct defects survived. All are fixed here.
Generator and its proof
- generateCountrySheet fed only ADM0, so a country beside India or China printed without their
provinces (Nepal: 251 KB against Node's 921 KB). It loads the full boundary set, as the driver does.
- check:poster-browser-equiv rebuilt its own feed, so it could not see that. It now calls what the
browser calls (makeZoneSheet, generateCountrySheet — Nepal), deletes the target before the Node run,
runs Node with a clean environment, and FAILS (not "not measured") when the Node generator breaks.
Proven two-sided: it fails with the ADM1 fix reverted.
- The facts table's country list sorted in the reader's browser locale; pinned to en-US (Node's
default, so Node's output still hashes to the baseline).
- zone-sheet-print.js imports the library relatively, so bun, Node and the bundler resolve it alike.
Print seams and wiring
- The wall's Poster Map button used its own stored-file fetch and never the generated sheet, so every
country poster 404'd on the published tree; it now prints the generated sheet first.
- Country Posters wall: card stocks passed (its Card Deck could never print), iso3-names passed (zh/ar
page 1 kept English names), the stored fallback reads previews/ (where the tree serves them).
- Card-stock labels render at render time, so a locale change relabels them.
- The records provider caches by language AND list, and does not keep a failed membership load.
- The UPG-100 legend's Card Deck returns quietly on a parent row instead of an evaluator error; the
selection fetch uses PRINT_FIELDS.
Packet
- The centered-set caption counts what is DRAWN (a ring too full to separate reports its remainder).
- Each ring's number lane is sized to its own radius (ring 1 put a dot under its badge).
- Per-box line keeps a decimal below 10 people ("≈ 0" for small lists); worksheet rows name the country;
the localized poster kicker is fitted against the poster's real 19px, not 22.
Plurals and gates
- regions.countryCount / regionCount were authored in English zero|one|other order in de, es, fr, hi, it
and pt, so CLDR picked "27 Land", "1 países". Rewritten in each locale's own order; check:plural-forms
now fails a string in English order (proven on the old German string).
- check:optional-t-equivalence (gates:post) crashed on the new plural fallback's imports; it injects the
real cldrChoiceIndex and asserts the plural path (ru 21/3/5, en 0/1/7).
- check:packets-all and check-packet-render counted people as cards only, so every lightweight packet
read as "a packet of nobody"; worksheet rows count, and the browser gate checks the grid's top fill
(the renamed .lc-frame) instead of the retired scatter. check:packet-variants now reads the grid itself
— 5,200 boxes, Jesus' 1,716 from the top, the % shading from the top — proven: it fails on a renderer
that shades the last boxes.
gates:pre 68/68, check:optional-t-equivalence PASS, both bundles build.
The Driver's print standard: every export is generated when it is asked for, and only previews are
stored. The zone and country map sheets — the poster, and the map-only sheet on a packet's page 1 —
existed only if library/scripts/posters/generate.mjs (Node) had written them ahead of time; no country
had a print-resolution sheet at all.
- library/packet/poster-render.js is generate.mjs's drawing layer (2,645 lines) as a pure factory,
createPosterGenerator({ env, SERIES_DEF, ISO3_NAME, adm0, adm1Ind, adm1Chn, nePlaces, series }). Moved
VERBATIM (template literals carry their whitespace into the output), `process.env` read through a
local shadow; its inputs are its free variables, found by a scope-aware parse, not a grep (a grep
missed adm0, and the proof caught it). generate.mjs is now its Node driver.
- PROVEN BYTE-IDENTICAL: all 118 sheets of a pinned offline matrix — the 28 zones in 3x4, 3x3, letter
and packet, and a country sample at both sheet sizes — hash the same before and after.
- data/zone-sheet.js is which group pins in which zone (first claim wins), now ONE function both the
Node driver and the browser call, so a group cannot pin in one zone on paper and another on screen.
- library/packet/posterSheets.js feeds the factory in the browser: the served full-resolution
boundaries, the shared series and places tables, a generator per call (caching by format pinned the
first caller's data on every later sheet). generateCountrySheet does a country end to end;
data/zone-sheet-print.js a UPG-100 zone. ISO3_NAME moves to library/data (territory-counts re-exports).
- usePosterPrints gains opts.sheet: the poster print is the generated letter sheet as a VECTOR page,
and the packet's page 1 the generated map-only sheet (item.mapId honoured); stored images remain the
fallback when generation fails. PosterWall forwards it; the Country Posters wall, the UPG-100 wall and
the UPG-100 map pass it. The UPG-100 wall and map drop their own record fetches for the shared
provider, and the map's legend and selection HUD offer the card deck (production).
- check:poster-browser-equiv: the browser feed's sheet is byte-identical to the Node generator's for an
India zone (packet) and a country — proven two-sided (it fails when the feed drops the city dots).
check:selection-kinds reads the handler's branches instead of a fixed poster/packet pair, anchored on
`if (` (proven: it fails with the cards branch deleted). library/packet/README.md: the standard.
gates:pre 68/68; both bundles build.
check:optional-t-equivalence evaluates useOptionalT.js on its own, with its
imports stripped and tStatic and useI18n handed in. The composable now also
imports getRuntimeLocale and cldrChoiceIndex, so every section died with a
ReferenceError. The gate now hands in a bare host's getRuntimeLocale and the
shipped cldrChoiceIndex, lifted from library/i18n/index.js's own text; its
import control accepts tStatic among several names, but still only from
'@map/i18n/index.js' (a catalogue import of the module's own still fails).
check:frozen-labels caught the research filters' new search placeholder:
tStatic inside a computed that reads no locale never re-runs, so the
placeholder stayed in the boot language. It now reads and passes
locale.value, like the search chip in the same file.
- The legend's packet submenu emits 'packet:full' | 'packet:lightweight', and the country map compared
against a bare 'packet', so its legend Packet export did nothing at all. It splits the kind first, as
the UPG-100 map's handler does (the research map carries the same three lines; reported to its owner).
- The selection's rows come from the print standard's records provider by slug (full print fields,
the reader's language); the rows mapped by hand put the country's NAME in country_code and carried no
language, so every profile description ran short. They remain as the fallback. The live map's raster
goes in through the mapImage seam, so page 1 is the map full page.
- The legend gains an OPT-IN Card Deck export (exportCards; provide('legendExportCards') for the mobile
sheet), so a map that does not handle it never shows a dead item.
- library/print/experimentalExports.js is the ONE switch for rolling the standard's exports out on
maps that are not production print surfaces (production: the UPG 100 list, the Regions dashboard's
country posters, the Country Posters wall). This map's card deck sits behind it; off — per page, per
visit (?exports=off), per site or everywhere — restores the menu it had before.
- The records provider returns coordinates (a generated map's pins), exposes the unselected set
(recordsFor.all) and sorts in the reader's collation.
Eighteen keys across eleven locales for text that was written in English in
the components: the rail and filter toggles, the selection count, the CSV
export toast, the drawer's prayer and information links, the dashboard
title, the map's failure message, and the four search placeholders that name
the tiers of whichever tab is open.
The export toast is interpolated rather than concatenated. Its counts keep
going through fmt(), which groups digits the way each locale does, and only
the words around them come from the catalogue — the order of "rows",
"fields" and the outcome is not the same in every language, so a sentence
built by joining fragments cannot be translated at all. It uses the shared
fillParams, which leaves a placeholder visible when a value is missing
instead of rendering the word "null".
The map's failure message no longer names the token and the container: that
is for the console line, which stays English for whoever is debugging. What
the reader sees says what to do.
Three more shapes the scan could not see, each of them correct code: tStatic
called directly rather than through a wrapper; `config || t(key) || English`,
where the translator is not the immediate left operand because || is
left-associative; and the licence credits, media queries and product name
that must not be translated at all.
Own leaks 91 -> 1, the last being a tab label behind a key that resolves in
all eleven locales. Fleet 249 -> 159. check:i18n-parity,
never-translated, treeshake and stopped-scope all pass.
The legend emits 'packet:full' or 'packet:lightweight' — it widened its
existing event rather than adding a second one — and this router still
tested the whole string for 'packet'. Neither submenu item matched any
branch, so the Packet export opened nothing, logged nothing, and looked
like a menu item nobody had wired. Found by upg100, who had already hit
the same thing in its own copy.
The kind is split before it is compared, the way upg-100-list does it, and
a bare 'packet' still means the full packet so an older emitter keeps
working. The variant then has to survive the call as well: openPacket
ignored it and printPacket defaults to full, so lightweight would have
printed the full packet even once the menu fired.
The gate asserts the seam rather than one file — it reads the kinds the
legend actually emits and requires every consumer to split before
comparing, because this bug cannot be seen by reading either side alone.
It currently fails on country-posters/countries-map.vue, which has the
identical dead menu and is not mine to change.
Two fixes to the check itself while writing it: a fixed-size window read
only part of a long router and reported correct code as broken, so the
function's extent is found by brace depth; and a trailing \b after a
quoted literal never matches, which made the assertion fire on every
consumer including the working ones.
The legend gives each tab a small "i" button explaining what that tier
means — what an Affinity Block is, how a people group differs from a people
group in a country. SemanticTreeLegend already read that text from
legend.tabInfo.<id> behind a te() guard, and not one of the ten keys the
research map needs had ever been written. The tab chips were translated in
all eleven locales and the explanations beside them were not, so the reader
least able to guess what "PGIC" means was the one reader who got it in
English.
The keys are written for all eleven locales, following the terminology each
catalogue already uses for these same tiers — People Group as Volksgruppe,
Peuple, Pueblo, 民族, Народ, जनसमूह — with WAGF and UPG left untransliterated
as the corpus leaves them. The reader-facing text drops the source field
names and row counts that the English carried; those are notes for whoever
maintains the tables, so they stay in the source as a comment.
With the keys in place the English in the tab tables can never render, and
a second copy of a string drifts from the catalogue without anyone noticing,
so it is removed. `info: true` stays because the legend gates the "i" button
on that field being set — dropping it would have removed the affordance
instead of translating it — and tabInfo now treats only a string as a
fallback, so the flag cannot reach the popover as the word "true".
Verified every changed tab renders a real label and a real explanation in
all eleven locales. check:i18n-parity, check:i18n-never-translated and
check:i18n-treeshake pass; hardcoded-english 205 -> 190.
The country wall passed no records, so each of its 175 cards offered only the poster and said the
prayer list was "coming soon", while the people groups were one request away. library/lists/
peopleGroupRecords.js is the records half of the print standard: one people-groups fetch per language
(normalised the way the prints read rows, list membership applied), then a SELECTOR picks the set — a
country by ISO3, explicit slugs, any field, or a host's own selector language. A selector nobody
understands selects nothing rather than every group on earth, and a failed fetch is not cached.
The country wall uses it by ISO3, so every country now prints the list, the deck (all five stocks) and
the full or lightweight packet. The packet's page 1 is, for now, the country's stored poster when it is
really there; the in-browser map generator will replace that.
check:people-group-records asserts the selection two-sided with an injected fetch; registered in
gates:pre.
Rudder was measured slowing after about 1,400 words while a Tauri build
ran. The word count is incidental — it is a proxy for how long the build had
been going. The speech model runs on the CPU (backend/rudder-engine starts it
with --device cpu), and a Rust build saturates every core, so at equal priority
it starves the one process whose latency a person can hear.
Three changes, and the direction of the first is forced rather than chosen:
1. internal/desktop-build.mjs nices the whole build tree to 10, through the one
run() helper every step goes through, so cargo, rustc and the linker inherit
it. We nice the COMPETITOR because an unprivileged process cannot raise its
own priority on Linux — a negative nice needs CAP_SYS_NICE — so "promote the
ASR" would fail with EPERM. If nice(1) is missing the build still runs and
says so, because a silent fallback would look exactly like the fix working.
2. cargo is capped at cores-1. nice decides who wins a CONTESTED core; it does
not stop the build taking all of them, and a fully subscribed machine still
adds scheduling latency to the audio path. An explicit CARGO_BUILD_JOBS is
honoured rather than overridden.
3. yieldCPU reports its error instead of discarding it. It was
`_ = syscall.Setpriority(...)`, which cannot fail visibly — a call that
silently does nothing is indistinguishable from one that worked, and this one
guards something audible.
Also, one real O(n) in the widget, found while checking studio's answer that the
collapsed pill already shows the last words: sendActivity split the ENTIRE draft
on every activity tick to take five words. Measured at 3,000 words: 1.61ms per
tick against 0.051ms for a bounded tail, 31x, with byte-identical output.
Verify the priority work with `ps -o ni=` on the running cargo processes, never
by any of these calls returning.
The list kept its own page-1 map lift (packetMapSvg + packetLabelFor) beside the one in
usePosterPrints, and the two drifted: the list's relabelled country names, the wall's did not, which
is the zh finding rega made in the app. With the composable now taking iso3Names, the list passes it
and deletes its copy, so the list, the wall and any other map print page 1 through one path.
The selection packet borrowed one list's sheet when it held exactly one list. That is now a general
seam: item.mapId names the sheet when it is not the item's own id, and mapId: null says the item has
none, so a multi-zone selection fetches nothing and page 1 says the map is missing. Measured two-sided
through the real printPacket. gates:pre 66/66.
The i18n + RTL audit of Country Posters (the hardcoded-English gate only reads production plugins,
so it had never read this one; its scanner was run on the plugin directly and every hit triaged for
reachability, since the country map mounts one tab):
- 9 strings a reader sees were English in every locale: the search placeholder, the packet title's
"— 50 of N", the map-failed message, the wall's heading, tag, intro, soon message and item nouns,
and the view switcher's legend and label. All read from the catalogue now, reusing the existing
keys where they exist (tabs.selectView/views, the UPG-100 wall's tag and emphasis).
- The wall's section headings are the WAGF region labels in the data, English everywhere; they now
resolve through the Regions dashboard's own WAGF table, which gains its two missing regions.
- The three WAGF tier tooltips had no keys in any locale (legend.tabInfo.region/block/country).
- 0 physical-CSS findings.
PosterWall counted "N countries" as a number plus a 2-form noun pair and defaulted the plural to
noun + "s" — wrong for Russian and Arabic whatever strings it was given. It takes itemCountKey, a
plural catalogue key rendered with the count; useOptionalT forwards a plural count to vue-i18n (the
CLDR map picks ru 4 forms, ar 6, zh 1), and a host without i18n picks the English form by
vue-i18n's own rule. The people-group counts on the section heads and placeholder cards use
wall.peopleGroupCount the same way ("people groups" was hardcoded English on the cards).
The UPG-100 wall: its Americas and Europe section headings had no key (single words, invisible to
the gate), and it counts its lists with wall.listCount.
Checked: plural-forms, i18n-parity, keys-exist, never-translated, and i18n-treeshake against both
bundles built (26 bundles carry every area they ask for); gates:pre 66/66.
The list's own print relabelled the map sheet (mapLabels.js); the wall's print, through
usePosterPrints, lifted the sheet with no relabeller, so a zh packet printed from the wall kept
INDIA, CHINA … on page 1. The generator's ISO3 -> name table is plugin data the library cannot
import, so the host now supplies it: PosterWall iso3Names -> usePosterPrints iso3Names, and the
UPG-100 wall passes its territory-counts table lazily. A host that passes nothing prints exactly as
before. Measured two-sided through the real printPacket on a tracked sheet: without the table the
page carries CHINA and no 中国; with it, 中国 and no CHINA.
rega at 390 px: wrapped, the view tabs took five rows (160 px of 844), and the shared 'Tap a coloured dot, someone still needs prayer' hint sat on top of the view's own bottom bar and attribution. On a phone the views are one select in one row (the tabs stay on a wide screen); pinHint is off, as for UPG Neighbor and the Wall.
Measured on the live list: 77 photos are 1024 x 1024 (4 MB each decoded) and drawImage downscaled each of them on every frame it was visible. A loaded photo is now kept at 320 px on its long side (createImageBitmap with resize, or one drawImage into a small canvas where that is missing), and the original is released; the print frame loads the originals itself. Below text zoom no card is measured (the photo box uses an estimate), and at text zoom at most 150 new cards are measured per frame. The overlay's sheets use content-visibility:auto. The host hook reports the counts (photos loaded, thumbnails, fallbacks, cards measured) for the performance probes.
The brief's three sizes. At 3 x 4 and 2 x 3 ft a verse under each of ~2,100 cards would print at 3 pt and 2 pt, so those sizes use the strip layout: cards of name and photo, and one blessing verse per row on a strip under it at 9 pt (33 and 34 verses). 5 x 8 ft keeps a verse under every card at 6 pt. The footer's height now comes from its text (the whole passages), with the journey never below 6 pt; the screen shows the strips as it shows the rest of the poster.
The priestly blessing is Numbers 6:22-27 in full: the Lord's word to Moses and Aaron in small type, the three blessing lines large, and the promise that closes it. Each station of the journey quotes the passage the brief lists: Genesis 12:1-4, Genesis 15:5-6, Galatians 3:13-14, 1 Peter 2:4-8 (the cornerstone, at the centre), Galatians 3:5-7 and 28-29, 1 Peter 2:9-10, Luke 19:9-10. The chart's New Testament verses name their passages (Matthew 21:21, from 21:18-22; Matthew 8:26, from 8:23-27). All WEB, verbatim, checked by script against ebible.org.
sr's census found item-noun="country", item-noun-plural="countries" and
unit="UPG" passed as literals. All three reach the DOM: PosterWall renders the
noun pair at its section count (n === 1 ? itemNoun : plural) and the unit beside
each row's count, so in Arabic the chrome translated and these stayed English.
The noun pair now goes through t(). Country and Countries were already in the
catalogue in all eleven locales -- Land/Länder, الدولة/الدول, Страна/Страны --
so this adds no strings, and PosterWall keeps making its own singular/plural
choice with the caller supplying both forms, which is what its props are for.
unit stays "UPG" on purpose. It is DX-life's term of art, and their own Arabic
common.json carries the literal "UPG" rather than translating it; matching them
is the point of the port. It is the one entry the census still reports here.
A fourth literal the census did not flag went with them: the prayer-list
"Coming soon" sentence, which reaches exportState.message and so renders too.
That one had no key, so it lands as an English-source key -- the convention
useRegionsI18n exists to serve -- translated in all eleven locales, 77 keys
each, parity green.
MEASURED, this plugin's entries in check:hardcoded-english: 3 before, 1 after.
check:no-new-driver-references went red on development with my landing: the
comment I wrote when removing the SVG download named who set the rule. The gate's
own advice is the fix — the engineering reason is what a reader needs, and it
stands without attribution.
Comment-only: 0 non-comment lines changed, and the minifier strips comments, so
the shipped bundle is byte-unaffected and needs no rebuild. Verified rather than
assumed: the phrase greps 0 in the built artefact either way.
Found by regions, who proved it on bare ced1eb73c before their own cherry-pick
touched anything, and who held their push rather than landing on a red.
regions' fleet audit found the one poster path in the tree that downloaded a
file. The Driver's rule is that a poster action opens the print dialog and never
downloads, and there is no case for an exception here: printPoster builds the
same poster from the same makePoster(), so the download was a second route to one
artefact rather than a capability only it could offer. A reader who needs a file
prints to PDF — the system's own dialog, not a silent write.
The button, the function and the now-unreferenced tree.posterSvg key in all
eleven locales are gone. The comment left in its place names check:poster-action-
print, which regions is extending with this file as its poison case, so a
download added here again fails a gate rather than shipping quietly.
The two CSV export paths in GiGrid and GiResources are deliberately untouched: a
table has no print equivalent, the export is a documented feature that writes a
header line of sources with the data, and the rule — and that gate's own
description — is about POSTER buttons. Flagged to the shepherd rather than left
for the extended gate to trip over.
A4.3. GiMap and GiTree already default their legends closed and read the host's
`legend: 'open'` knob; the centered set hardcoded ref(true), so it was the one
view whose decoder sat over the drawing from the first frame — on the chart where
seeing the target matters most. That was an inconsistency, not a considered
difference.
Changing the ref alone would have shipped NOTHING: applyWidth ran on mount and
set legendOpen back to `!m.matches`, so at any desktop width it reopened
immediately. The bug was in the width rule, not the initial value. Width now only
ever CLOSES the legend — a wide window is not a request to cover the chart, it is
just the absence of the reason to force it shut — and a choice the reader has
made still wins outright.
Checked as a state machine over the cases that matter: desktop with no host knob
closed, desktop with legend:'open' open, phone closed, phone→desktop stays
closed, and a reader who opens it keeps it through a rotation and a resize.
The Driver: "it shows some words … but it keeps RESIZING, kind of distracting …
it should stay the same width and stop resizing".
The intent was already here — a fixed width while listening, with the text cut to
33 characters — but neither half held. The bubble is inline-flex, so it sizes to
its content wherever `width` loses, and it carries a max-width that the <=480px
query overrides, which is exactly the phone case he was looking at. Truncating by
CHARACTER cannot stabilise it either: 33 characters is about 133px of "illi" and
about 344px of "MMM WOW", so the same count is a 200px swing.
width, min-width and max-width are now pinned to the same value while recording,
at both breakpoints, so no later rule and no viewport lets the pill grow or
shrink; the text ellipsises inside a box that no longer moves.
studio's file: sent as a hunk for them to land, not landed here.
The Driver, expanded on a phone: "a lot of header space … fills the entire page …
I can't scroll down in the text area and see everything".
The panel is capped at 60vh here and .draft carried min-height:180px (240px
elsewhere). A MINIMUM is the wrong tool for that: it makes the box refuse to
shrink, so whatever the header takes is pushed off the bottom and the draft's own
scrollbar never appears — the text is there and unreachable. An earlier attempt
had already lowered 240 to 180, which only moved the threshold rather than
removing it, and is why the symptom survived.
The column now owns the height and the draft takes what is left. min-height:0 is
the load-bearing part: without it a flex child will not shrink below its content,
which is the same reason the old minimum failed. The draft scrolls inside its
share, and the header gives back space — tighter row gap, smaller name and status
pill — since "a lot of header space" was the other half of what he saw.
The Driver measured Rudder slowing "after about 1,400 words while a Tauri build
ran". The word count is incidental — it is a proxy for how long the build had
been going. The speech model runs on the CPU (backend/rudder-engine starts it
with --device cpu), and a Rust build saturates every core, so at equal priority
it starves the one process whose latency a person can hear.
Three changes, and the direction of the first is forced rather than chosen:
1. internal/desktop-build.mjs nices the whole build tree to 10, through the one
run() helper every step goes through, so cargo, rustc and the linker inherit
it. We nice the COMPETITOR because an unprivileged process cannot raise its
own priority on Linux — a negative nice needs CAP_SYS_NICE — so "promote the
ASR" would fail with EPERM. If nice(1) is missing the build still runs and
says so, because a silent fallback would look exactly like the fix working.
2. cargo is capped at cores-1. nice decides who wins a CONTESTED core; it does
not stop the build taking all of them, and a fully subscribed machine still
adds scheduling latency to the audio path. An explicit CARGO_BUILD_JOBS is
honoured rather than overridden.
3. yieldCPU reports its error instead of discarding it. It was
`_ = syscall.Setpriority(...)`, which cannot fail visibly — a call that
silently does nothing is indistinguishable from one that worked, and this one
guards something audible.
Also, one real O(n) in the widget, found while checking studio's answer that the
collapsed pill already shows the last words: sendActivity split the ENTIRE draft
on every activity tick to take five words. Measured at 3,000 words: 1.61ms per
tick against 0.051ms for a bounded tail, 31x, with byte-identical output.
Verify the priority work with `ps -o ni=` on the running cargo processes, never
by any of these calls returning.
One hundred and forty-three comments across the research dashboard, the research
map, Big Picture and the two composites attributed a decision to a person: by
name, by quotation, by date, or as "per Driver". Shipped code should carry the
reason a thing is the way it is, not who said so. A reader of the giveaway repo
has no way to ask that person anything, and the name tells them nothing the
reason does not.
So a quotation becomes the requirement it was quoting, a ruling becomes the
ruling, "the Driver reported" becomes what was reported, and a decision still
waiting on an answer now reads as provisional and pending a product decision
rather than as somebody's pending word. Where a comment already stated the
reason -- most of them did -- only the attribution went.
Two things were removed rather than rewritten. A personal medical fact appeared
twice ("the Driver is photosensitive"), once in a comment and once in a gate's
console output where it printed on every run; the motion-safety requirement
stands on its own and now reads that readers may be photosensitive. Internal card
ids that named nobody stayed, since they point at this repo's own history.
The bulk went in three batched passes of meaning-preserving substitutions, and
that is where the risk was: a blind replacement produces sentences that are
grammatically wrong but still look plausible in a diff. Re-reading every changed
line found thirteen -- "the the report describes", "including the two that were
had just complained about", "because was asked for WHITE rings" -- each repaired
individually. The count of remaining references caught the tail three separate
times, when a file type, an escaped apostrophe, and a scripts/ path each fell
outside a filter I had assumed was total.
Comment bodies and two console strings only. All twenty-one files parse, both
meta.json files are still valid JSON, and no declaration moved.
Five comments in the shipped research dashboard cited _qa-cycle documents by
path: three RESEARCH write-ups, one REFERENCE standard, and one scratch probe.
Those files do not ship. A reader of the built plugin gets a path they cannot
open, and the reasoning it was standing in for is lost to them.
In every case the durable finding was already written out directly above the
citation, which was only ever a trailing "and here is where that came from". So
each now ends on the finding itself and the path is gone: that a comfort-tuned
palette anaesthetises the response the map exists to provoke; that a casing which
scales with zoom is what stops dense low-zoom rings swallowing their own fill;
that the real fix for pin contrast is the lightness-conditional stroke, which
holds whichever ramp is selected; and that the palette values come from the live
site rather than being re-invented, which is what stops an embedded dashboard
reading cooler than the page hosting it.
The scratch probe became the METHOD rather than a filename, since a probe is
worth citing only for what it does: contrast every ramp stop against all three
pin targets under both CVD simulations, and re-solve composited over the real
basemap. Compositing over pure white is what made two handed-over candidates
look like they passed, and that is the part a future reader needs.
Comment-only: no declaration moved, and all three files still parse.
The rule: never quote or cite the Driver in shipped code. Every one of these
comments had a real engineering reason attached, and the reason is what a future
reader needs — so the reason stays and the attribution goes.
COUNT IS THE CHECK: 31 before, 0 after, across 8 files.
I was pointed at 18 in region-dashboards. That was one file. Sweeping my own area
as instructed found 31 in eight files: region-dashboards 7, RegionCountryMap 13,
regions-list 5, UupgsList 2, plus regions-mfe.vue, regions-mfe.meta.json,
index.js and check-regions-uses-library.mjs with one each. Reporting the wider
number rather than the one I was given.
HOW EACH WAS REWRITTEN, by kind:
· a REPORTED SYMPTOM keeps the symptom, loses the reporter — "the pins were
reported too small at rest", "the list page was reported slow, with the
posters suspected", "instead of flying to the region, which is exactly the
reported symptom".
· a REQUIREMENT becomes a requirement — the region key is "a map that shows the
regions and countries AND lets a reader select one" rather than a quotation;
the missing fullscreen and zoom controls are "part of the cloned surface"
rather than someone's complaint about a full clone.
· an OPEN DECISION becomes provisional. Two of these: the stat-card divergence
from RegionDetail.vue:176 is now "carried back as a product question", and
the numeral-grouping parity reads "PROVISIONAL, pending a product decision"
instead of waiting on a named person's ruling.
· a CARD ID goes with the name: BUILD-REGIONS-MAP-RESTORE-EDGE-SCROLL-STRIPS
and the round-2 test numbering are internal handles that mean nothing to a
reader of a published bundle.
· a SCOPE RULING is stated as scope: "improve the map's LOOK and add the
all-regions map, which must be intuitive" — the constraint survives, the
quotation does not.
Nothing lost that a reader needs: every measurement, pixel value, file:line
citation into DX-life's source and engine-specific caveat is untouched. What went
is a name, four dates and two internal ids.
SAME PROOF AS THE CITATION COMMIT, because a prose sweep across eight files is
exactly where a stray edit hides:
· the only non-comment changes are an HTML comment in a template and the "note"
field of regions-mfe.meta.json — and `note` is read by nothing: generate-tree
never touches it, and the string does not appear in the built manifest.
· meta.json still parses.
· rebuilding regions-dashboard and country-map reproduces the committed bytes
EXACTLY, and a full `bun run tree` leaves public/ clean — so NO bundle needs
rebuilding for this.
· gates:pre 66/66.
gospel's citation rule, applied to mine: a comment citing a PROBE or MEASUREMENT
(_probe-*, _chk-*) states the FINDING and drops the path. A reader cannot open
_qa-cycle from a published bundle, and the path outlives the file — so the number
has to be in the comment or it is lost.
FOUR CITATIONS, all of the probe kind, so all get the same treatment. Every one
already carried its finding; only the dead path is removed:
RegionCountryMap:36 three engineFor implementations "decide identically in
every engine combination" — now says WHICH combinations
were checked (mapbox-only, maplibre-only, both globals
present) rather than naming the script that checked them.
RegionCountryMap:462 the globe-fill measurement keeps its numbers (zoom 1.229
-> 390.61px = 50.1% of the frame, half the frame dead
space) and loses the probe path.
RegionCountryMap:552 the fit-at-construction comparison keeps both readings
(bounds-at-construction zoom 3.210 / mercator at t=0 vs
fitBounds zoom 2.777 / globe) and the engine version.
region-dashboards:522 "4 of 10 app properties OK", with the five failures that
were one missing thing already spelled out below it.
I did NOT only fix the one line I was pointed at: I swept my own plugin files,
the six library files I own or moved this session, and the seven gates I wrote.
Four citations total, all in these two files; the gates and the other library
files carry none.
COMMENTS ONLY, and proved rather than asserted: the diff filtered to non-comment
lines is empty; check:ported-globals-identical still reports 4/4 byte-identical
emitted CSS; and rebuilding regions-dashboard and country-map reproduces the
committed bytes exactly — comments are stripped at build, so NO bundle needs
rebuilding for this and public/ stays untouched. gates:pre 66/66.
scripts/strip-code.mjs:5 is deliberately left alone: per the same ruling it
explains why a committed gate cannot import from the scratch area, which is a
fact about the boundary rather than a citation across it.
Seven comments in the library and the app pointed at probes, checks, a report or research notes that
live outside the repository, where a reader of the code cannot follow them. Each now states what was
found and drops the path: the engine-predicate equivalence, the chooser's globe-fill measurement, the
fit-at-construction probe, the worldview clause, the web-boundaries sizes (1,866 KB to 383 KB, every
feature and part kept). The QA corner and the packet generator lose a dead pointer. The app's nav
comment states the three sections and the third dashboard directly instead of quoting a transcript.
rega at 390 px: the tab strip ran past the right edge with no scroll cue, and the flat legend's collapse button measured 19×15 with an English title in every locale. On a phone the tabs wrap (the brand steps aside); the button is 24 px and named by the shared aria keys.
rega measured the header at 390 px: one row, so the title wrapped to five lines and the poster-size select (x=393) and the Bless/Social toggle (x=553) ran off the edge, unreachable. On a phone the title takes one short line and the controls wrap onto rows.
The README described fit and fill-all qualitatively but never said what choosing
one costs, which is the number a reader deciding between them needs: on the
default list, fit draws 931 of 2,074 groups and reports the other 1,143, while
fill-all draws all 2,074 with none outside its band and none under a label.
It also records the evidence for WHY this is a mode rather than a setting: 931 at
separation 7, 1,369 at 5, and 1,369 again at 4. That plateau is what shows the
band's depth is binding and not the spacing, and without it the next reader will
reasonably try to tune the number that cannot help.
Every figure here was re-measured against the shipped tables through the
component's own join before being written down, because a stale number in a
README is worse than no number: it reads as fact and nobody re-checks it.
Seven comments across six files pointed at SPEC-GOSPEL-INTERNET-DASHBOARD, which
lives only in _qa-cycle: never cloned, never published. A reader of the public
source met a citation they could not follow, which is worse than no citation —
it implies the reasoning is written down somewhere they may look for.
The durable part is now plugin-designs/gospel-internet/README.md, written as
BUILT rather than as specced: the views and their shared URL state, the list
model and its fail-closed rule, the two set charts (rings, bands, the label
keep-out, fit vs fill-all and why band depth is the binding constraint), why the
charts fit their box, the data joins, the columns, and the gates that guard each
of those. Section 13's honesty rules are carried over close to verbatim, because
they are constraints on the product and not notes: pins are approximate, never
"reached", never who works where, internet figures are the country's, public data
only.
Two citations did not become README links, deliberately. The facet rail's quoted
a design INSTRUCTION ("comes from the kit; do not re-implement"), so the
instruction is kept and only the unreachable document name dropped — inventing a
README section to host it would be citing myself. board.meta.json's and the
instances file's now point at the README section that actually describes the
URL-state behaviour they depend on.
The instances file is edited through the JSON parser: my first attempt did string
surgery and put unescaped double quotes inside a string value, which JSON.parse
caught before it could ship.
STAGED, NOT FLIPPED. SET_FILL_MODE in GiCenteredSetMap is 'fit' — today's
behaviour, unchanged — with the alternative named beside it so the ruling is one
line and a rebuild. 'fit' is not a placeholder: it is the honest mode for a page
that cannot zoom, so the POSTER should keep it whatever the screen chooses.
The binding constraint is recorded there because it is not obvious from the
numbers: separation can never fall below twice the pin floor (5.2), and an
18.5-unit band therefore holds at most three rows, so no minSep reaches ring 2's
658 groups. minSep 7 places 931 of 2,074; minSep 5 places 1,369; minSep 4 places
1,369 — identical, which is what proves depth rather than separation is binding.
'fill-all' places all 2,074 with none under a label and ring 2 using 18 rows.
THE CAPTION states the TRUE total — the groups that have a ring — not the number
of pins currently drawn. In the dense mode the same groups read as an arc of
colour zoomed out and as separate pins zoomed in, so a caption counting what is
visible would change as the reader moved: true at every instant and misleading
overall. It joins the paragraph that already accounts for the centre, the
no-relation groups and the unmatched ones, so those four numbers reconcile
instead of competing.
centered.shown added in all eleven locales; parity and keys-exist pass, and
hardcoded-english did not rise (257, and that fall is studio's, not mine).
Bless mode is the printable poster (a verse under every group, sizes, Print HD poster) and Social mode the shared prayer wall; the texts use each locale's own button names. UPG Neighbor's entry is removed: it has no hosted "?" (its own How it works is its explainer), so the entry showed nowhere and could only drift.
Its header cited research notes in a folder that is not part of the repository, named an internal
directive, and quoted a reader's complaint. It keeps the reasoning and the list of what was copied.
On a narrow screen the list used to be display:none, so a walker could not see, select, rename or delete a zone there. It is now a drawer over the map from the reading-end edge (right in English, left in Arabic), behind a ☰ Zones (n) button that leads the toolbar; a tap outside, × or Escape closes it and focus returns to the button. On a wide screen it is the sidebar as before, its border now on the reading side. Two words in the planner's own 11-locale strings: zones, closeZones.
The feature-state approach left an invisible twin per DOXA group (2,616 circles drawn at alpha 0, and counted by queryRenderedFeatures). When the DOXA set arrives the base is re-sent once, slim (1.25 MB, not the old 3.7 MB), without the DOXA rows; the black pins stay on their own top layer. Popup fields and counts unchanged.
check:descriptions failed on development: country-map.vue had a rich prose header
and no @description TAG, which extracts to an empty string. My header explained
at length why the bundle exists and never said, in one line, what it IS — so the
plugin-studio, the gallery and the SPA's rail had nothing to show a reader.
The tag matters more than the prose: generate-tree matches
/^\s*\*\s*@([\w-]+)\s+(.+?)\s*$/gm, so a paragraph with no asterisked tag is
invisible to it. Verified the text reaches the BUILT manifest, not just the
source, since the manifest is what the gate and every consumer read.
37 of 37 profiles now described. country-map rebuilt, tree and SRI refreshed.
One sha instead of two, per the shepherd: both branches fixed the SAME
regions-list rule, because a gate is useless on a tree where its poison case is
still live. Landing them separately would have applied that fix twice.
★ I HAD CLAIMED THE TWO COPIES WERE IDENTICAL. They were not — I checked before
folding rather than after, and the hashes differed. The CODE line is the same in
both (`cyrillicHeading('.rl-title')`, one occurrence each); only the explanatory
comment differed. I kept the css-dangling wording because it names the arm that
now catches the bug, which is what the next reader needs. The emitted CSS is
unchanged either way — comments are not CSS — and the byte-compare baseline still
passes, which is the assertion that would have caught me if I were wrong.
WHAT THIS SHA CARRIES, both halves verified after combining rather than assumed:
THE MIGRATION (was 4a4bc96fc). The harness stub's `.value` is now the empty
string, so an injected-config read and a locale interpolation both behave as
they do in the browser. Measured components 2 -> 4, no NOT MEASURED left.
region-dashboards is byte-identical (a pure de-duplication); regions-list
changed because its old bytes were the bug, so its baseline was re-taken.
strongMedium() deliberately NOT adopted — the helper emits bare `strong{...}`
where region-dashboards scopes `.rd-root strong{...}`, and swapping would widen
the rule.
THE GATE (was 023ca2d08). check:shadow-css-parses now fails on a selector that
never receives a block, gated on `endsLiteral` — the LAST fragment — rather than
on `complete`, which is false for nearly every sheet here and therefore
suppressed the very bug the arm was written for. Four self-tests live in the
file: the exact regions-list string is caught, a last rule WITH a block is not,
a trailing comment is not, whitespace is not.
Re-verified on the combined tree: ported-globals 4/4 byte-identical, shadow-css
80/80, the poison case still produces exactly 1 dangling hit when reintroduced,
gates:pre 56/56, and the broken form is absent from the shipped minified bundle.
gates:post had gone red on development in three places that no landing runs, because landings run
gates:pre. The library index and the capability-exemplars table are derived documents: regenerated,
they now list the country-map bundle, BackToTopButton and useHostedHelp. check:no-free-identifiers
flagged the Wall's new Path2D (a canvas built-in every browser has) as never bound; it knows the
canvas built-ins now. No code changes.
upg100's harness recorded my two files as NOT MEASURED because its stubs threw on
them. Both were FIDELITY gaps — the stub modelled states that cannot occur — so
they are fixed by making the stub more like the browser, never by relaxing a
comparison:
· `.value` was undefined, so a component reading an INJECTED ref threw
"Cannot read properties of undefined" (region-dashboards.vue:199,
cfg.value.regionSlug — ProfileLoader always provides that object).
· the stub threw on ANY string coercion, because a Proxy over a function has no
primitive conversion. That alone produced regions-list's "Cannot convert
object to primitive value".
`.value` is now the EMPTY STRING, which is the one shape that serves both real
cases honestly: a locale ref's .value IS a string, so `${locale.value || 'en'}`
correctly yields 'en'; and reading a property off '' returns undefined instead of
throwing, so an injected-config read falls through its own `|| ''` exactly as in
the browser. Symbol.toPrimitive makes any remaining stub render empty.
Measured components: 2 -> 4, with no NOT MEASURED left.
★ AND THE MIGRATION FOUND A REAL BUG, which is the part worth reading.
regions-list's Cyrillic heading rule ended with a trailing comma and NO
declaration block, so the concatenation emitted:
[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,regions-list
A selector list with nothing to apply — the Russian heading font was silently
never applied — AND it swallowed useShadowStyles' style-id argument as CSS,
leaving that sheet unlabelled. Braces stayed balanced, so
check:shadow-css-parses could not see it, and it passed 80/80 both before and
after. cyrillicHeading('.rl-title') replaces it and emits a real rule.
SO THE BYTE-COMPARE SPLITS, deliberately, and I did not paper over it:
region-dashboards.vue IDENTICAL — cyrillicHeading('.rd-title','.rd-h2')
emits exactly the literal it replaced, verified
character-for-character before the swap. A pure
de-duplication.
regions-list.vue CHANGED, and the change IS the fix. The old bytes were
the bug, so its baseline is re-taken rather than
preserved. Preserving it would have pinned a broken
rule forever, which is the one thing a byte-identical
gate must not be used for.
NOT MIGRATED, on purpose: strongMedium(). The helper emits bare `strong{...}`
while region-dashboards emits `.rd-root strong{...}` — a narrower selector.
Swapping it would widen the rule to every <strong> in the shadow root, a
behaviour change disguised as a de-duplication. scrollbarGutter() is likewise
left inline: both files carry it inside a larger .rd-scroll/.rl-scroll rule, not
as a standalone declaration the helper could replace.
Both files now gated (owned:true) instead of recorded-only, so a future edit that
changes their emitted CSS fails rather than being noted.
Two reported mobile bugs (DRV1650 #3 and #4), both settled by rega's
measurements at 390x844 on dx-life /research rather than by reading source.
★ #3 — THERE WAS NO BACK-TO-TOP AT ALL, and I had reported otherwise. I said
VerticalTabsLayout already rendered one targeting the active .vtl-host. rega:
ZERO .vtl-host elements in the live build, button.btt ABSENT from the DOM. The
chain: VTL renders .vtl-host only for `mountedElementRows`, which filters rows
carrying an `element` key — and this SPA passes :groups whose rows carry none,
mounting its own .dms-pane panes imperatively. So activeHostEl is null, and the
layout's button correctly renders nothing. That button is for layouts hosting
their own element rows; it is inert for the composition the gallery uses. I had
read the source and not the configuration.
The SPA now renders its own, bound to the ACTIVE .dms-pane — the scroller rega
measured (clientHeight 712, scrollHeight 12,373, overflow-y:auto) while the
document does not scroll at all. The gallery owns both the circle and the
scroller, so no shadow-root walking is needed. Placement reads --map-fab-beside,
published by the layout only while its circle renders, so the arrow sits BESIDE
the circle and falls back to the plain inset where there is none.
BackToTopButton already refuses to appear when its target does not scroll.
The label goes in all ELEVEN of the SPA's locales. Its translate() falls back to
the KEY, so a missing entry would have rendered the literal string "backToTop"
to every reader — en/ar/zh/ru/hi verified, and the Arabic and Chinese strings
are present in the built bundle.
★ #4 — THE REGIONS ATTRIBUTION SITS UNDER THE CIRCLE, and the geometry was not
what I assumed. rega: at 390 the attribution is details.maplibregl-ctrl-attrib in
the BOTTOM-RIGHT container, expanded to 347x44 at (22,791) — nearly the full map
width — so its LEFT END runs under the 44px fab at (12,788), 1,394 px² overlap.
The bottom-left container is EMPTY (0x0), so a bottom-left-only fix would have
changed nothing.
Both bottom containers are lifted off the published --map-fab-clearance under a
767px query, so a standalone leaf or a <doxa-country-map> with no gallery around
it reads the 0px fallback and nothing moves. The existing z-index:3 cannot help:
the circle lives in the layout, outside this shadow root, so the two are
different stacking contexts. Attribution is a LICENCE TERM of the basemap, which
is why this is a fix and not a polish item.
REBASED ONTO 0428f279c BY REPLAYING SOURCE, not by resolving a conflict: the
rebase collided only in generated bundles, where a conflict is never resolvable
by hand. Three files applied clean; RegionCountryMap.vue had MOVED to
library/components/maps in that same round (my own country-map work), so its rule
was re-applied at the new path by hand — a straight patch would have failed with
"No such file or directory", which is exactly what studio would have hit.
country-map, regions-dashboard and doxa-maps-spa rebuilt; both fixes verified in
the shipped minified bundles, including the new country-map bundle that now
inherits the lift.
NOT PROVEN HERE: the headless DOM proof needs a browser (rega's seat). What I
verified is that the button, its scoped CSS and its ar/zh labels are in the
SHIPPED doxa-maps-spa.js, and the fab lift in both map bundles.
countries-map's print path set the poster's legend from legend.rows, but useLegendData returns items,
never rows, so the renderer got an empty list and page 1's live map printed with no legend. It now
takes the items, with their child tiers flattened in, as research-map's poster does since A1.
check:legend-data-contract FAILED on this file and PASSES now; it goes into gates:pre with this round.
doxa-maps-spa marks the pane of every tab that has explainer text with data-doxa-help, listens for
doxa:help, and opens the same what/how dialog for the active tab. Closing returns focus to the
exact "?" that opened it (composedPath reaches into the map's shadow root). The top-centre button
and its CSS are gone; a comment for the device-rule toast that sat above the old button's rule
is back on its own rule.
check:help-reachable (gates:pre) keeps it reachable: every map with text has a hosted "?" or a
named help control of its own; the app listens, marks its panes and has no top-centre button; and
the composable, executed on a small fake DOM, dispatches doxa:help bubbling and composed and finds
the host across a shadow root. Six planted defects, each caught.
Map controls: prayer-walk-planner (under fullscreen), gospel-internet (the map and the language
tree, the board's default view), country-posters (the country map, its entry profile) and
all-people-groups get the library HelpButton with hosted. Own bars, where there is no control
column: the Wall (after the Bless/Social toggle), the PPLR hub (the end of its view bar) and the
vision card (its top corner, at the end of the reading direction so Arabic mirrors it) use
useHostedHelp. The vision card's label is the library's own "About this map" in each of the
eleven locales, copied into vision.json rather than translated again. UPG Neighbor is left as it
is: its bar already has an About button that opens the full explainer.
Each "?" stays hidden until a host marks its pane, so nothing changes until the app answers.
Each of the 12,379 pins carries only its row's index and one category number (1.25 MB of GeoJSON, was 3.7 MB); the popup reads the row by id, and shows the same fields (checked on 18 pins, 10 of them DOXA, and the four counts). The DOXA recolour no longer rebuilds and re-tiles all 12,379 pins: DOXA pins are a small source of their own on the top layer, and their base twins are hidden by feature-state.
The map sheets are generated once, in English, so in an Arabic or
Chinese packet page 1's map labelled every country in Latin capitals,
and its privacy line was English.
The labels are now replaced as the sheet is lifted into the packet.
They are recognised by the generator's own rule rather than a list:
generate.mjs draws each label as esc(ISO3_NAME[iso3].toUpperCase())
from the zone data's ISO3_NAME table, so inverting that table under
that transform names the country behind every label it can draw. The
inverse is unambiguous (162 labels, no two countries sharing one), so a
label is known exactly or left as drawn. The map's privacy line is word
for word the packet's privacy_note, so it takes this language's.
A width guard: the generator placed every label and then dropped any
that would collide, measuring the English name, so a translation is
fitted into that same box - estimated wider, it shrinks in proportion
and cannot reach a neighbour the English name did not. On the Sudan
poster, six Arabic names shrink (the longest, Saudi Arabia, from 15px
to 10px); none of the narrower Chinese ones do. Capitals' tracking is
dropped for scripts without capitals, where it breaks Arabic joining.
English is untouched: its relabeller is null, and the lifted sheet is
byte-identical with and without it. No sheet or poster is regenerated -
the change is in the lift, so the tracked 3x4 posters page 1 now uses
are relabelled as they are.
Both print paths supply the relabeller: the UPG 100 profile from its
own ISO3_NAME and the library's country-name resolver, the offline
generator from the same table and the injected ISO-3 to ISO-2 table.
check-map-labels (gates:pre), on the tracked Sudan poster: English
byte-identical; in ar and zh no known English name left, halo matching
fill, privacy localized; the guard fires for Arabic, not Chinese, and
grows nothing; Arabic tracking off, Spanish kept; an unknown label left
alone. Mutation-tested five ways, each caught.
The Deaf zone printed "Deaf (affinity bloc A017)" as its title - on
page 1, at the head of every profile page and in the document's own
title. That is the legend's internal label: the unified Deaf row is an
affinity bloc spanning every region, so there is no country to name it
and zone-title.js declined it, leaving the label to print.
An affinity-bloc zone has a name of its own even though it has no
country. zone-title.js now reads it from the packet strings, keyed by
bloc (zone_affinity_A017: "Deaf people groups", in all eleven
languages), so another affinity bloc a list adds is named with one
string and no code. With no strings it still declines and the label
stays - it never invents a name. Both print paths now pass the
language's strings, English per key, the same fallback the renderer
applies.
check-zone-title-tier-a covers the unified Deaf zone, which it never
saw (it enumerates clusters.json entries, where Deaf is two older
lists): the translated name in en/es/ar, never internal text, and a
decline without strings. It now tests with the real packet strings,
which carry the multi-country joiner, so a broken one-country guard
would generate a title and be caught.
Every packet was titled with its zone's hand-written English label, so
a Spanish Sudan packet said "Sudan" and an Arabic one printed Latin
script on page 1, on every profile page's header and in the document's
own title.
A one-country zone's title is that country's name, and Intl supplies it
in every language: the packets now carry it - Sudán, السودان, सूडान,
苏丹 - through zone-title.js's tier A, on both print paths. Every other
zone keeps its hand-written label, since a region's name is not a list
of its countries (tier B and C are a pending product decision). The
selection packet names its cover the same way.
In the browser the title uses the library's country-name resolver; the
offline generator cannot load that module under Node, so it reads the
ISO-3 to ISO-2 table and injects it, the path zone-title.js was built
for. A data set without zone-title.js keeps its labels.
check:zone-title-tier-a was committed unregistered and so never ran; it
is now in gates:pre. Its comments, and zone-title.js's, no longer cite
people or dates.
Measured all 29 zones in en/es/ar with a real country-name supplier
before wiring anything, and the result split cleanly.
A one-country zone generates a correct title in every locale at zero
translation cost: Sudan / Sudán / السودان.
A multi-country zone generates a title WORSE than the hand-written
label it would replace, in English too, so this was never a
localization trade-off:
"Middle East Core" would become "Iran + Yemen + 11 more"
"Scattered / Diaspora" would become "Andorra + Cocos (Keeling)
Islands + 2 more"
and no maxParts rescues it -- 2, 3, 4, 13 just walks toward a full
13-country enumeration. A multi-country zone has a name that is not a
list of its members, so no assembly of country names can produce it.
The generator is correct; the approach does not fit that tier.
The guard is in zone-title.js, not at the caller, because six product
call sites supply this across three surfaces (the legend three times,
posters, packets). A caller-side test would be repeated six times and
would drift the first time one was missed, silently regressing one
surface while the others stayed right.
Tier B now keeps its label through the same null-means-keep-yours path
tier C already used, so this widens an existing contract rather than
adding a new one.
check-zone-title-tier-a.mjs is two-sided on purpose: a gate that only
checked "the 3 generate" would pass a build that also regressed the
other 26. It asserts the 3 produce non-code titles that differ across
locales, AND that every other entry returns null so its label survives
byte-identical -- by value, not by count, since "26 declined" would
pass even if the wrong 26 declined.
Shown capable of failing four ways: removing the guard (the forbidden
regression), declining tier A too, a supplier returning raw codes, and
a supplier ignoring locale. The third exits 75 NOT MEASURED rather than
failing, because a broken supplier makes every zone decline and would
otherwise look like a clean pass.
FULL DETAIL is four front pages, the profiles, four back pages:
map, triangle, life calendar, % evangelical, profiles,
bounded set, centered set, prayer wheel, 12 prayer points.
LIGHTWEIGHT is two front, the profiles, two back:
map, triangle, profiles, prayer wheel, 12 prayer points.
Both are generated from data, in every packet language, from the
browser and from the offline generator.
PAGE 1 - the map, on a vertical page. One lift for every caller
(packet-render.js liftMap): the browser composable, the UPG 100
profile and the offline generator each carried an identical copy of
the old wrapper (250px down, scale 0.62), and three copies of a layout
decision is how page 1 drifts between print paths. The lift prefers
the map-only vertical sheet (mapSheetFiles: <zone>.packet.html, then
the poster) and places it so its names land on the same 60px margin
every other page keeps; a poster falls back fitted under the title at
0.81 rather than 0.62. The title block is drawn after the map, since a
map-only sheet begins with a full-page background. Anything that is not
a map sheet - an icon-sized <svg> from a host that answers a missing
file with a 200 page - is refused, and page 1 says the map is missing.
Page 1 now states which set it belongs to ("One of 28 UPG 100 prayer
lists"), only when the caller supplies the count.
PAGE 2 - one inverted triangle in the Vision Card's look. The card's
steps are the top (every people group, the unreached, the frontier,
the network's list), narrowing to the UPG 100 packets and, at the tip,
this packet. The card's greens, darkening as it narrows, walked
through its own colour stops; every numeral picks white or ink by the
WCAG ratio (all at least 3.4:1, large bold type). Tier widths are
geometric, not proportional - 2,107, 28 and ~100 against 12,379 would
be hairlines - so where two sets truly nest the caption states the
step removed, and the network's list, which does not sit inside
frontier, gets the card's "N of them are frontier" note instead of a
false subtraction. The packets tier and its count appear only when the
caller supplies one.
BACK PAGES - the bounded and centered sets get a page each at twice the
width, under the one page key, so lightweight drops the pair together.
THE WHEEL - the Metacamp Module 1 prayer wheel: its twelve colours,
sampled from the fill operators in the source PDF's content stream; its
orientation, the opening PRAISE at nine o'clock and READ THE WORD at
twelve; white labels with a shadow and a faint outline, so a print
path that drops SVG filters still leaves them legible on the palest
segments; "5 minutes" badges; the attribution with the website. The
labels stay the packet's own rhythm names, translated in every
language and matching the 12 points page.
The offline generator makes both packets (PACKET_VARIANT=lightweight;
the full packet keeps its names, lightweight is <zone>.lightweight and
stamped with its variant).
STRINGS: four new keys, in all eleven blocks - each block's own words
for "packet" and "prayer list", the frontier note aligned to the word
the frontier tier uses on the same page.
GATES: check-packet-a1-structure (registered in gates:pre) reads every
sheet's CONTENT for the order of both variants, page 1's lift in all
four cases, the triangle's tiers and steps, the wheel's colours and
orientation, and all 28 zones. Mutation-tested eleven ways, each caught
by its own message. check-packet-variants now asserts both sets pages
and a four-sheet difference. The browser gate check-packet-render
counted funnel rows and bars by inline-style substrings, which an SVG
triangle has none of - both would have read zero and passed - so it
now counts .funnel-n against .funnel-tier, its sheet arithmetic counts
the sets pages, and its Latin allowlist carries the renamed product
name. check-packet-browser used a copy of the old wrapper; it now uses
the product's lift.
The legend offers 28 zones; every place that turned a picked id back
into a zone spread clusters.json's three geographic buckets, which is
27. The missing one is Deaf, whose two deaf lists the legend collapses
into a single affinity-bloc row. So in the browser, silently:
- the Prayer packet for Deaf returned on "if (!zone) return";
- the Deaf poster titled itself by its slug;
- a selection containing Deaf dropped it in .filter(Boolean);
- the packet count read 27 while 28 packets exist, and that count
prints on page 1 and at the tip of page 2's triangle.
The packet fetch also lacked rop1, the field the Deaf selector reads,
so even a found Deaf zone would have resolved to nobody.
The fix is one enumeration. legend-tree.js now exports DEAF_SELECTOR
(the unified row's selector, which exists nowhere in clusters.json) and
zonesWithSelectors(clusters): the legend's leaves with their selectors
attached. The plugin's printableZones() keeps the 27 catalogue objects
exactly as they were and adds only what the catalogue lacks, so the
packet, poster, selection and count all see the same 28. rop1 joins
both packet fetches.
The three hand-typed copies of the Deaf literal (the map, the poster
generator, the offline packet generator) now import the one
definition; the offline generator's own id-to-selector table is
replaced by zonesWithSelectors. The copies left are frozen snapshots and
proof scripts, which keep independent literals on purpose.
The offline sweep gate's coverage had the same 27: it never required a
Deaf packet. It now counts through the legend.
Verified: the live API carries exactly 123 A017 rows, matching the
legend's Deaf count; the default 3x4 posters for sudan, kerala, deaf
and west-africa are byte-identical before and after.
One landing because it is one bundle: the ring band layout wiring (already on
this branch) plus the three set-fit commits, replayed as SOURCE only. The
generated tree is rebuilt below rather than merged — a previous cherry-pick of
mine swept a conflicted generated file into a commit, and hand-resolving build
output is how that happens.
From set-fit: extentFraction 0.17 -> 0.095 on both charts, so the drawing fits
the height the engine's zoom floor leaves it; crossFeature restored from history
and called by BOTH maps through one shared addCrossLayer; and the ResizeObserver
fit restored, which rega found missing because zoomAtOpen read 1.00 in all eight
chart/layout combinations — too clean to be a fit result.
Two things this merge had to get right, and neither was automatic:
- Taking GiCenteredSetMap.vue wholesale from set-fit silently REMOVED the ring
wiring — bandFill, curvedRingLabels and openAndFrame all went to zero while
every gate still passed. Both feature sets are now in the file together.
- Taking package.json wholesale would have deleted studio's check:ring-bands
registration, including its gates:pre entry, landing a gate nothing runs. It is
rebuilt from development's, with only check:gospel-set-fit added — in all three
places this time: script, gates:pre, and //scripts.
The same interaction as the centered set and as GiTree, so a reader who learns
one chart has learned all three: click a group, its drawer opens and the camera
frames it; reduced motion jumps instead of easing. The centre dish is not a
people group, so it is excluded by tier rather than by id — an id test would
have to know the dish's private name and would break the day it changed.
This chart takes no band layout: its groups are inside or outside one boundary,
not on rings, so bandFill has nothing to spread. Only the interaction is shared.
Wires ringBands into GiCenteredSetMap. layoutCenteredSet still decides WHICH
ring a group belongs on — that model is unchanged and still the source of the
ring numbers — and bandFill then spreads the groups THROUGH each ring's band
instead of placing them all on its circle. Measured on a 90-group sample: ring 5
went from 1 distinct radius (the single line of dots the Driver reported) to 9,
with every group placed and every ring drawing.
The ring labels and the band fill come from one source: curvedRingLabels returns
each label's keep-out arc and bandFill refuses to place a pin inside it, so pins
cannot overlap a label by construction rather than by tuning.
A click on a GROUP now opens that people group and frames it; a click elsewhere
on a ring still selects the ring. The Driver asked for "exactly like the Language
tree", so this is GiTree's path rather than a second version of it: the same
board.openGroup() call, and a camera that mirrors GiTree.frame()'s degenerate-
point branch — a single dot has no extent, so fitBounds would ask for an infinite
zoom; easeTo at the zoom the group tier becomes readable at, and jumpTo under
reduced motion. Both the drawer call and the camera are wrapped: the drawer is
what the reader asked for, and a camera that throws must not take the board down
after it has already opened.
check:ring-bands, gospel-set-charts, gospel-whiteboard, shadow-css-parses and
i18n-keys-exist all pass; both SFCs parse with 0 errors.
A copy of the starter built but never appeared in the Studio, which lists a plugin only when it has a meta.json or an instances.json. The template itself stays off the Studio by its folder NAME (generate-tree SCAFFOLD_BUNDLES, discover.mjs), not by a flag a copy would inherit. The README's copy steps now name the meta.json beside each .vue.
From a green group's brief: the same list as the map, the CSV and the letter (this E-level, closest first, the closest 60, as the cover says), printed by library/composables/usePosterPrints.printPacket with the renderer UPG-100 uses, not a copy. The group set is upg100's pg_slugs shape. Agreed with upg100: the lightweight variant (the %-evangelical page is DOXA-wide today), a triangle of its own (all, unreached, these N; not DOXA's 2,107, which these are not a subset of), and one explicit record adapter (ISO3 country code, the language as a plain label, IMB's GSEC for the how-reached sentence, photos joined from the doxa list by slug when an API is configured).
country-map (the previous commit) moves RegionCountryMap.vue from regions-dashboard/src to
library/components/maps so a page can mount the country map alone. check:region-fill-repaint read the
old path and crashed on the move; it reads the library file now and still PASSes, with the repaint
after setData intact (the move carried the hardening that landed in 608e8ec29). check:universal-props
held the file's fixed en-US population format by its old per-bundle path; the same held decision now
sits on the library file for every bundle that imports it (regions-dashboard and country-map), which
the allowlist permits only for library/. One comment still pointed at src/useRegionsI18n.js.
DX-life's country pages render nothing where a map belongs. The map they need
has been in this repo since the regions clone landed — it was just unreachable
without loading the whole regions dashboard, a bundle that fetches the people
group list, builds four stat cards, a region chooser and a table, none of which
a country page wants. So this is not a new map. It is the same component, given
its own element and its own bundle.
<doxa-country-map profile-config='{"countryCodes":["TCD"],"colorByPrayer":true}'>
RegionCountryMap and useRegionsI18n move to library/ because they now have a
second consumer, which is the only thing that earns a place there. Six import
sites updated in this commit; nothing else moved.
THE CONTRACT arrives through profile-config, the channel every element here
uses, so a generated page and a hand-written host page are configured the same
way: countryCodes, center, zoom, bounds, colorByPrayer, researchUrl,
prayBaseUrl. Two translations happen at the edge rather than by widening the
component — colorByPrayer is a boolean here and colorMode a string inside, and
countryCodes accepts an array OR "TCD,NER,MLI", because an HTML attribute is a
string and JSON-inside-an-attribute is the shape people get wrong.
THE ELEMENT FETCHES ITS OWN PINS, and that is what makes prayBaseUrl part of the
contract rather than decoration. An element handed a country code, a base URL
and no way to fetch would draw a country outline with nothing inside it — the
blank page it exists to fix, in a new disguise. Same endpoint, same fields, same
mapping as the dashboard; it asks for a country instead of the world.
THE FIELD LIST TRAVELS WITH THE MAPPING, in peopleGroupPoints.js. The obvious
version of this commit asks for PEOPLE_GROUP_LIST_FIELDS — the list the stats
need — and every pin still draws, every popup comes up empty, and nothing logs
anything. The popup half is what makes it a map you can read, so the two are
one export and neither can be requested without the other. The dashboard's own
request is unchanged: the concatenation produces the identical string.
countryUrl is additive, not in the confirmed contract. The component's country
link needs a base and DX-life's country pages live under /regions/; a host that
sets nothing gets that documented default.
window.__doxaStudioWidgets keeps the corner hosts across a hot update so a live widget keeps its
state, but a real unmount never released it, so the hosts and every widget element in them stayed
on the page. It is now released in beforeUnmount, except during a hot update.
The desktop legend gets :export-enabled and @export from this file's template, so
a reader at a desk can print a poster or a packet. LegendMobile does not read
props from here. It reaches for an injected handler and decides whether to offer
the control at all by whether one exists -- mobileExportEnabled is
`typeof onLegendExport === 'function'`. Nothing here provided it, so the phone
showed no export while the same map on a desktop showed both.
That is why it went unreported for so long: it is an ABSENCE, not a broken
button. Nobody clicks a control that was never drawn, and the desktop, where this
kind of thing gets checked, was always correct.
The provide carries EXPORT_FEATURE_ENABLED rather than the bare function. The
desktop already passes that flag, so providing unconditionally would hand a phone
an affordance the desktop deliberately hides when the flag is off; and because
"is there a handler" IS the mobile switch, passing null when it is off is exactly
what makes the sheet hide the control. countries-map fixed this first and its
comment records the same two causes on my-upg-100-list, so this is the third
instance of one pattern rather than a new bug.
Measured before building: the sibling item in the same diff, the RTL-safe CSS the
research map was said to owe the country map, is already done -- zero physical
declarations remain once comments are excluded, and the one apparent hit is prose
describing why a padding exists.
closes#15 of COUNTRY-RESEARCH-MAP-DIFF
"Not yet" still listed the desktop build with the engine as a Tauri sidecar.
On Linux it builds and runs; what is still not done is macOS and Windows
(the engine cross-compiles for both and has never run there).
From the selected green group's brief: the unreached peoples that count it among their nearest believers, at the chosen E-level, closest first (the same list as the CSV), the closest 30 by name with a pray.doxa.life link where the group has one. The letter is editable, then copied or downloaded as a .txt (UTF-8, readable in any editor). It opens in a dialog that takes focus, keeps it and closes on Escape.
usePosterLayout has always computed a real layout: the map narrowed by the legend
column and inset by the margins, the title strip and the footer strip. Nothing in
the exporter ever read it. The map was rastered at full page size -- edge to edge,
across the margins and both bands -- and the title, legend and footer were then
composited on top of that image. The legend lay over the map it describes, and
there were no bands at all.
What kept it hidden is that PosterPreview.vue DOES honour layout.map, so the
on-screen preview has always shown the intended design and only the exported file
was wrong. A preview is not evidence for an export.
The obvious fix breaks a different product. country-posters embeds this renderer's
output as a full-sheet <img> inside its own packet page, beneath the packet's own
title, so chrome that is on by default prints a second header and footer within
their page. UPG-100's packet does not call this renderer at all and was never at
risk -- checked rather than assumed. So the composed sheet is requested with
spec.layout === 'poster', and every existing caller, which sets none, keeps what
it had.
That guarantee is stronger than "equivalent": with no layout the renderer passes
the SAME px OBJECT it always did to the rasteriser and composites no sheet, so the
untouched path is unchanged by identity, not merely by value. The check asserts
exactly that, by executing the decision with four real caller shapes -- including
country-posters' A4 at 150 DPI -- rather than reading the source for a promise.
Its second negative test is the ruling's own failure mode: make defaultPosterSpec
set the layout and country-posters is named as silently opted in.
A sheet with no room left for a map after its margins, bands and legend column
falls back to full-bleed and says so, because silence there is the bug above.
Simple Research's export asks for the layout explicitly.
The Driver, on Simple Research: clicking print "breaks the CSS" -- the map
controls and the legend jump upward.
It is not the Export->poster path. That prints an off-screen iframe whose whole
body is a single <img> carrying its own @page, and no CSS in this plugin can
reach inside it. This is the browser's own print of the live page, for which this
plugin had no print rules at all.
The mechanism comes out of the cascade rather than a screenshot. .rm-root is
position:absolute with inset:0 and no positioned ancestor, so its containing
block is the initial one: the viewport on screen, the PAGE BOX in print.
.rm-map-area draws all of its height from that root through flex:1 1 0, and the
canvas, the legend, the badges and the scroll edges are each absolute;inset:0
inside it. In a paged medium the root stops resolving to a viewport height, the
flex child collapses toward zero, and everything pinned inside it stacks at the
top of the sheet. That is the jump the Driver saw.
So in print the root stops being absolute and flows in normal document order,
and the map area is given an explicit height, which restores a real box for its
absolutely-positioned children to sit in.
Overlays that describe INTERACTION rather than content are hidden: the scroll
affordances would print as grey bars down both edges, and a provider badge and a
loading veil say nothing on paper. The legend stays. It is the key to what the
map is showing, and a printed choropleth without its legend is an unreadable
picture.
Verified by reasoning over the cascade, not in a browser: this needs eyes on the
print preview before the card closes.
closes the print half of DRV1650-SR-POSTER-EXPORT
One bubble per bridge language, on its largest unreached group and sized by the unreached population a campaign in it reaches; it follows the search and the believers filter, names itself on hover and picks the language on a click. The map was empty until a language was chosen.
The panels, legend, map notice, list text and arrows use the reading direction (in Arabic the panel sits on the right and the map frames its selection in the part you can see); the P-level counts use the same number format as every other count. How it works takes focus when it opens, keeps it, closes on Escape and gives focus back to its button.
Share opens the phone's share sheet for the group's prayer page, or copies the link and says Copied!. The loading overlay was position:fixed and covered an embedding page; it now sits inside the wall like the offline notice. Landscape / portrait arranges the Social board only: in Bless mode the wall is the poster.
The screen shows the printed sheets themselves, stacked as they print (three 60 × 36 in pages for 5 × 8 ft, glue bands and all). Every card sits where it prints, sized by the same cardText() the print uses; the title, the prayer-themes chart and the footer are the print's own HTML in an overlay that follows the zoom and lets every click through to the wall.
The chart's rotated labels are sized from their own text and wrap; its theme headers fit their row. A long group name shrinks to fit (never below 6 pt) or takes two lines, and the photo gives way so the card's content never outgrows the card. The verse reference prints at 6 pt. The size labels are isolated left to right, so an RTL page no longer reorders '5 × 8 ft · 3×'.
HARDENING, NOT THE FIX for the grey Regions tab. That tab has been seen loading
grey on first load and coloured after a reload. This closes one ordering that
can reach a flat-grey fill. On built bundles that ordering did not reproduce the
grey tab (0 of 20 loads grey, the fill present with its expression at addLayer),
so the reported bug stays open; the lead is the dev/HMR re-mount path.
THE ORDERING. regionFillExpression builds a ['match'] from props.points; with
no points it returns a FLAT no-data grey. addLayers SNAPSHOTS that value into
the layer's paint when it creates the layer. applySubject does repaint the fill
correctly, but it guards every write on getLayer() — deliberately — so a change
arriving BEFORE the layer exists is silently dropped and never retried. And on
MapLibre, the DEFAULT engine, the country boundaries are added as an EMPTY
source and populated by a later fetch's setData(), which nothing repainted
after. In that ordering the fill keeps the colour it was born with.
THE CHANGE is one call: repaint after the boundaries setData, which is the first
moment the layer has any features on the MapLibre path and the only repaint
guaranteed to run with both inputs present. applySubject is idempotent and
getLayer-guarded, so it is harmless when the colour was already right — it only
removes the ordering's power to be the last word.
★ TWO THINGS I GOT WRONG FIRST, both caught by the gate rather than by review,
and both recorded because the wrong version was plausible:
1. MY MODEL COULD NOT REPRODUCE THE BUG. The first version's negative control
fired: no arrival order greyed even with no repaint, so it could not have
detected what it existed for. The greying order is not "data before the
layer" — by then the snapshot is already coloured. It is points arriving
AFTER both addLayers and its mount repaint, with the watch's repaint not
landing. Corrected, exactly 1 of 3 orders greys without the fix and none
with it.
2. I BLAMED THE EMPTY SOURCE, WHICH CANNOT CAUSE GREY. An empty source draws
NOTHING; fill-color is an expression over feature properties. A grey
landmass therefore means features ARE present and the expression came back
flat. The repaint after setData is still required, for the narrower reason
above. The gate carries that correction so the wrong reason cannot be
inherited from a comment.
THE GATE'S OWN INSTRUMENT WAS WRONG TWICE TOO. A 400-char window between
setData and the repaint failed on the repaint's own comment. Widening it to
1600 then PASSED when I deleted the real repaint and left an unrelated
applySubject() 1200 chars downstream — measured, not feared. A distance is the
wrong instrument, so the .then() block is now extracted by brace matching and
the repaint required INSIDE it. The first structural attempt anchored on the
nearest '=>' and landed inside the callback body, because `for (const f of ...)`
has one; it anchors on the enclosing '=> {' now. Four arms mutation-tested,
including that decoy, and every mutated file restored byte-identical.
251606460 dropped each component's window hooks on unmount, but deleted them
unconditionally, and that is wrong as soon as two of the same component share a
page -- which research-map-plus-grid and the Big Picture composite both arrange.
B mounts and overwrites A's hooks; A unmounts and deletes them; B is still on
screen with its verify hooks gone. Not a leak, and not visible to a reader: the
cost lands on rega, as a hook reported missing on a map that is perfectly
healthy, which is a more expensive thing to chase than this is to write.
people-groups-grid already compared identity before releasing. research-map (4
hooks) and the dashboard map panel (3) did not, so the commit was uneven with
itself; gospel's d3597f3d1 settled on the same identity-checked pattern, and one
pattern across the fleet is what lets the check enforce it later.
Each hook is now held in a local as it is installed -- window.__x = _hookX = (…)
-- declared outside the block that assigns it, because a const in there is
invisible to onBeforeUnmount. The release compares before deleting, one key per
line, so the check can read both halves.
The check reports how many releases are identity-checked and enforces it under
STRICT_IDENTITY=1, which is off only because other owners have not adopted the
pattern yet. Flipping it later is a smaller change than re-deriving this.
Two things it taught me while I wrote it. Enforcement first flagged the grid,
which WAS identity-checked -- the comparison simply sat on the line above the
delete, and looking only at the delete's own line dictated formatting instead of
recognising correct code; the window now covers the lines leading up to it. And
the pattern is tested for BEHAVIOUR, not presence: two hooks installed in turn,
the first unmount must leave the second working, the second must leave nothing.
rega's heap snapshots (leak-722ac7789): Simple Research and All People Groups
mounted twice went 54.2 MB to 65.1 MB, with live Vue apps 6 to 8. Every unmounted
application was still there, one copy per mount, and the retainers were all the
same shape -- a window key set during setup and never cleared. The closure keeps
its component, the component keeps its shadow root, and a whole app survives its
own unmount. An element property dies with the element; a window key does not.
The named one was window.__doxaLogoOwner -> Map -> div.rm-map-canvas -> ShadowRoot
-> app, released only when the map's 'remove' event fires. That listener cannot be
the guarantee the global is cleared, because it only runs if remove() is reached;
it is a consequence of the guarantee, not the guarantee itself. useMapInstance now
drops the claim inside destroy(), before removing, so it goes even if remove()
throws part-way.
As for why remove() was never reached: research-map's unmount hook ran ten
teardown steps and called destroy() last, and two of those steps were unguarded
calls into other composables. A single throw anywhere above stranded the map, and
with it the app. I could not reproduce which step threw without a browser, so the
fix does not depend on knowing: each step is isolated and destroy() runs from a
finally, where nothing above it can skip it. Ordering teardown so the most
important step is last, behind nine chances to fail, was the real defect.
The globals themselves are released by the components that set them -- four verify
hooks in research-map, three in the dashboard map panel, the vocabulary hook in
the grid, which had no unmount hook at all. Released as SETS: __rdashPinAt was the
one the snapshot named, but __rdashMap holds the map and retains strictly more, so
clearing only the reported one would have looked fixed and leaked nearly as much.
The deletes are written one per line instead of looped over an array of names, so
that the check can read them. A release a checker cannot see is worth no more than
no release, the day someone removes three of the four.
The check flags only values that actually retain. A warn-once boolean latch and a
page-wide cache keyed off itself are deliberate and must not be reported, or it
becomes noise and gets switched off. It is NOT in gates:pre: 19 sites remain in
other owners' plugins, and this is a fleet pattern rather than one plugin's bug.
useLegendData returns { items, columns, title, activeFilter, setFilter,
totalCount, totalPopulation, progress, fmtPop, fmtCount }. There is no `rows`.
research-map read legend.rows in three places, and because JavaScript answers
undefined rather than complaining, each one failed in a different costume:
- provide('legendRows', legend.rows) provided undefined, and nothing in the repo
injects that key -- the comment above it described a contract no component
implements
- openPoster's fallback, legend?.rows?.value || [], could only ever be [], so on
any tab where the flat legend is empty the poster had no legend rows
- the packet's page-1 slot was rows: legend?.rows?.value || [] with NO second
source, so that poster's legend was empty unconditionally
and useMapPoster skips the legend slot entirely when the row list is empty. So the
missing legend on the exported poster had two independent causes: the renderer
painting it over the map, fixed separately, and this -- an empty array arriving
through a fallback that could never be anything else.
Both poster paths now share one builder, so they cannot drift apart again, and it
flattens children because a prayer legend is a parent plus two child tiers and
printing only the parents describes bands the map does not draw.
Two further calls went the same way: legend.refresh?.() and
legend.build?.(normalizedPeopleGroups), against methods the composable has never
defined. The optional call swallowed both, so two lines that read like steps did
nothing. `items` is a computed and re-derives on its own, so they are gone rather
than repointed.
exp-shepherd found the same misspelling in countries-map, where it threw instead
(NE-17). The check therefore guards the class rather than these lines: it reads
the returned key set from the composable and reports any consumer reading outside
it. It is NOT in gates:pre yet, because it still reports countries-map, which
belongs to another owner.
Writing it turned up two traps worth keeping. Stripping block comments with one
global regex DELETED REAL CODE -- a slash-star inside an ordinary string opens a
comment nobody wrote and the replace runs to the next real terminator -- and the
check confidently reported "no consumers at all" while both sat in the files. And
matching property reads by name flagged every t('legend.colLabel.religion'): an
i18n key spelled like a property access is not a read.
The projection toggle and the zoom toolbar share the top-right corner. The toolbar
was pinned at a literal top:34px, a number that was correct when it was chosen:
the toggle then measured 74x23 and ended at y=31, which this panel's own comment
records as a live measurement. A later accessibility fix gave the toggle
min-height:24px for WCAG 2.5.8. Nothing in this shadow root sets box-sizing
globally -- it appears exactly once, on the legend -- so 24 was a CONTENT height
and the rendered box became 24 plus 8 padding plus 2 border = 34px. Sitting at
top:8 it now ended at y=42 and ran 8px into the toolbar, which has z-index 10
against the toggle's 3 and so drew straight over it.
Nobody did anything careless. A correct fix to one control broke another because
the agreement between them was a magic number living in the other rule, where the
person changing the first had no reason to look.
So the toggle gets box-sizing:border-box, which makes 24 mean twenty-four and
keeps it a legitimate 24px touch target, and the toolbar's offset is now
calc(--rd-proj-top + --rd-proj-h + --rd-ctl-gap). The relationship is stated once,
on the element that owns it, and both the desktop rule and its mobile restatement
read it. Changing the toggle's size now moves the toolbar by construction.
The check computes the toggle's rendered height from its own declarations the way
a browser does, honouring box-sizing, rather than asserting either number --
restating 34 here would have rebuilt the same trap one file further away. Run
against the code as it stood before this commit it reports the overlap and its
exact size. It also requires the toolbar to DERIVE its offset, because a literal
that is correct today is the precise shape of this bug.
Two faults rega measured in the /embed-test re-shoot at 1440, both on main.
THE MAP SIZED TO THE VIEWPORT, NOT ITS HOST. This element had no :host rule at
all, so it defaulted to display:inline with no height of its own and .gm's
absolute inset:0 resolved against whatever ancestor happened to be positioned:
a 1198x900 canvas inside a 1198x560 embed card, with ~340px including the
attribution drawn below the card and clipped. The attribution is a licence
obligation, so clipping it is not cosmetic. Now :host{height:100%} so a
height-bounded slot decides, with min-height:420px as a floor so an auto-height
host still gets a usable map rather than a zero-height one.
NOTHING SAID IT WAS WORKING. First data paint takes 7-30s under SwiftShader —
161 countries, 33 Indian states, 2,105 dots — and for that whole window the
reader saw an empty basemap, indistinguishable from a broken embed. Adds a
notice that starts visible on the first frame and clears after the LAST paint
(paintAdm1), not on 'load': the style fires 'load' early, so clearing there
would retire the notice during exactly the wait it explains. An error replaces
it rather than stacking, and the error path clears it too, so a failure cannot
leave it spinning forever. role="status", since this is progress and not an
interruption.
connectivity.loading added in all eleven locales; parity, keys-exist,
shadow-css and legend-logical-insets all pass. The notice uses inset-inline, not
left/right: it is translated text and mirrors under ar.
Four rules in the resources list and tables are physical where the thing they
position follows READING ORDER, so they do not mirror under dir="rtl":
- .r-code sat at margin-left of the language name, landing before the name in Arabic
- .res a + a used margin-left for the gap that exists so "Holy Bible" and
"JESUS Film" do not read as one string; mirrored, the gap moves outside the
pair and the two links run together again
- .r-pop aligned right, which is the START of an Arabic row, not the end
- .gt-filter's accent bar marked the left edge rather than the leading edge
Now inline properties. The three border-left-color rules in resources-map.vue
are deliberately untouched: each sits on an element carrying dir="ltr", so its
edge is physical in every locale by design.
Project 7000, 4700, 2000 and 700 each need their own clusters of about
100 groups, and the next one should be built by following a pattern
rather than by copying this plugin.
The document separates two halves that turn out to be independent:
which GROUPS are in the list (library/lists/, shared, already built and
gated by check-list-prop-unit) and which ZONES they are cut into (this
plugin's data/clusters.json). A new list needs the second half only if
it wants its own territories.
Measured rather than described:
28 leaf zones from 29 clusters.json entries -- the two deaf buckets
collapse into one leaf. Never count zones from the buckets; ask
leafZones(buildLegendNodes(...)), which is the one answer poster,
packet and legend all agree on. Counting by hand produced 27 once
(forgetting deaf) and a delete of 30 files another time.
5 registry lists, all present, with the membership counts they
actually have (unreached 2082, unengaged 1929, frontier 1745,
frontier-500k 71). doxa has no membership file, correctly: it is the
unfiltered default.
7 selector types, and which type a zone uses IS the statement of how
it was zoned -- from wagf_block (pure geography) through to an
explicit pg_slugs roster. The 12 pg_slugs zones are the honest case,
not the lazy one: below country level there is no ADM1 field to select
by, so a rule would be a fiction that looks principled.
The deaf poster stays recorded as the known exception. It is global and
non-geographic by construction, the Driver has not solved it and said
so, and the pattern says that rather than inventing a zoning.
check-list-pattern-doc.mjs re-derives every number from clusters.json,
lists.json and the membership files and compares them to the document's
own text. Neither side is hardcoded twice, so it fails in both
directions: a doc that drifts, and data that moves while the doc sits
still.
Shown capable of failing, five ways: leaf count 28->27, a membership
count, a selector count, and a real new zone (which trips three claims
at once). The fifth is the useful one -- malformed data now exits 75,
NOT MEASURED, because an uncaught throw exits 1 and would read exactly
like the document being wrong. Found by mutation-testing; the first
draft crashed with a stack trace I would have mistaken for a pass at
catching drift.
Also fixed in the harness: the registry-id parser matched any backticked
lowercase table cell and read the selector table's `union` as an unknown
list -- a false failure against a correct doc. Each table is now parsed
from its own section.
It derived the tree root from the page path. Embedded on a host page (/embed-test, a partner
site) the page is the host's, so the manifest fetch missed and it rendered an empty checklist.
It now uses bundleTreeRoot() (the bundle's own script URL, captured when the script starts),
and falls back to the page path only when that is unavailable, as on the dev server.
vue-i18n v9 pluralizes by an English-shaped INDEX and applies it to every
locale. Measured on a real instance, authoring 'a | b | c':
ru n=0 -> the ONE form, n=2 -> the MANY form CLDR: 0=many, 2=few
ar n=3, 11, 100 -> ALL the dual form CLDR: few, many, other
That is confidently wrong grammar that looks translated, which is worse than
the single fixed form the regions summary shipped until now. upg100 proposed a
plural key and was correctly refused on this exact ground
(upg-100-list.vue:3006) — "real plural support needs a pluralRules map at
createI18n". The refusal's premise was the missing map, so this adds it.
THE PRECONDITION SWEEP (dx-shepherd), before installing anything: every
catalogue value containing " | " across all 11 locales, and every call site
passing a plural choice.
pipe keys: wall.prayedCount, wall.havePrayed — and ONLY in en. Every other
locale ships a pipe-free label form ("Помолились: {n}").
call sites: exactly two, both in wall-of-people-groups
(CommentsDialog.vue:36, wallCanvas.js:198).
BEFORE/AFTER, 2 keys x 11 locales x n = 0,1,2,3,11,100:
132 renders compared, 0 CHANGED.
Zero changed because the map is SCOPED TO EXCLUDE en, which is the one locale
holding pipe strings today. en is also the case dx-shepherd's assertion (3) is
about: vue-i18n's 3-form default is zero|one|many while CLDR for en is
one|other, so an en string written "none | one | {n}" would change meaning at
n=0. Leaving en out means it cannot.
★ THE BUG I SHIPPED INTO MY OWN FIRST DRAFT, caught by reading the rendered
Russian rather than by any assertion: the index was taken against the fixed
zero|one|two|few|many|other list, which is only correct for a locale that HAS
all six. ru has {one, few, many, other} — no zero, no two — so 'few' sat at
global index 3 while the authored string holds it at 1, and n=2/n=3 rendered
"2 стран" instead of "2 страны". ARABIC HID IT COMPLETELY, being the one locale
that does have all six, so its two indexes coincide. The index is now computed
against each locale's OWN category subset, and ru/ro are authored in that order.
ar 0 لا دول · 1 دولة واحدة · 2 دولتان · 3 دول · 11 دولة · 100 دولة
ru 0 стран · 1 страна · 2 страны · 3 страны · 11 стран · 100 стран
(regionCount likewise; all six categories distinct in ar, all three in ru)
check:plural-forms carries studio's three assertions, each mutation-tested to
fail: FORM COUNT (a 2-form ru string fails against its 4 categories), the
tStatic PATH (reading a pipe key through tStatic fails — tStatic does no
selection at all, so the page would show the literal "a | b | c"), and ZERO (a
5-form ar string fails because ar has an explicit zero category). Every mutated
file restored byte-identical afterwards.
The regions shim gains tc(key, n). t() resolves through tm()+rt(), and rt()
takes a message only — there is nowhere to pass the choice — so a pipe string
would come back whole. tc goes through vue-i18n's own t(key, named, choice).
Registered in all three places and VERIFIED by re-reading the parsed
package.json, not by trusting the replace: my last gate registration silently
no-opped on two of three anchors and studio caught it at the landing.
Diffing what the lightweight packet ACTUALLY prints on each path against
its card (map, triangle, profiles, wheel, points) found the wall path
missing two pages and half of a third -- while the legend path matched.
Same class as the sets page: the renderer could draw every page, and
only one of the two hosts supplied the arguments.
Three gaps, three suppliers:
The map. printPacketAs passed no mapSvg and there was no default, so a
wall-printed packet opened on a title-only cover. printPacket now lifts
the bundle's own poster (posters/<id>.html, the same file printPoster
already reads) when the host passes nothing -- via a liftPosterSvg
helper shaped for unit-testing against a real generated poster, no
fetch. Verified on laos: the svg is found and lifted. And PosterWall now
actually passes assetsBase into its print opts; without that one line
the lift's guard would skip silently and the wall would STILL print a
mapless packet -- the seam-without-supplier failure one layer down,
caught in the same pass.
The triangle. No funnel was supplied and none defaulted. The default
states ONLY the steps: listCount and unassigned stay absent, so
funnelPage omits the share line and the tip rather than printing a
denominator that is wrong under a non-default list. Proved two-sided:
steps-only renders the triangle with no share claims; adding listCount
renders both.
The profiles. The wall's fetch carried primary_language but not
imb_gsec, so every wall-printed description ran at half length -- the
third fetch site with the same missing-field defect, found the same way.
setsPage landed as a renderer capability with no data supplier: it
returns '' without data -- correctly -- which meant the FULL-DETAIL
packet had never once printed its distinguishing back page in real use.
The full-vs-lightweight split existed in code while the full packet was
missing the page that makes it full.
packetSets.js is the supplier: a pure decode+join with zero imports, so
the identical function runs under Node (the generator reads the table
from disk) and under Vite (the browser fetches it) and the join cannot
fork between the two. The table stays with the caller -- the same seam
as strings, christian and describe.
The join is measured, not assumed: 2,091 of 2,107 groups match by slug,
rings distribute 1-10 with real shape, and the 16 unmatched are inked as
UNKNOWN on the bounded figure rather than guessed onto a side --
an un-inked category reads as zero on paper.
Wired at every path that prints a packet: the offline generator (all 28
zones verified carrying the page, e.g. laos: 0 inside, 94 outside, 1
unknown; 94 placed with "25 same dialect, same people"), the legend
Export menu, the poster wall, and the selection packet -- the last three
through one opts.sets loader fetching the SAME served asset-pool copy
the generator reads, so an embedded bundle prints the same page as the
dev tree.
The decode is a third reading of near-neighbors.json's cols/dict format
(nearRows.js documents being a copy of upg-neighbor's, pending promotion
to library/). Flagged in the header rather than hidden: when the loader
is promoted, the decode here collapses into it and only the join
remains.
The generator could already serve a second group set -- series.json's
country series proves it -- but only one wired into series.json, reading
upg100's bucketed clusters, upg100's legend tree for enumeration, and the
shared records cache. A fork had no way in.
The contract is now one folder. DATA_DIR points at it; clusters.json with
a flat lists[] is the group set; records.json in the same folder means no
API and no cache; selector-evaluator.js resolves from there, as it always
did. normalize.js and legend-tree.js become optional -- absent, records
are used as-is and the flat file IS the enumeration -- and the absence is
logged rather than silent, because a silent fallback would let a typo'd
normalize.js skip normalization on the real series. PACKET_OUT keeps a
fork's output away from the real artifact directory, where toy packets
would be counted as coverage.
unlisted: true carries upg100's reachability idea across as data: the
list is defined and selectable with ONLY=, and not generated by default.
The bucketed shape keeps its legend-tree enumeration untouched, so the
deaf collapse and the 28-zone arithmetic are byte-for-byte unchanged.
The smoke test was written first and failed first, on all four counts.
Its fixture is synthetic -- every slug and number invented -- and it
asserts the unlisted list does NOT generate, which is as load-bearing as
the two that do: an unlisted entry that generates anyway is the
reachability bug inverted.
One bug found in my own work while proving it: allZones became async, and
the funnel's listCount read it from inside a try/catch that returns null
on error -- so a use-before-init did not crash, it silently deleted the
triangle from all 28 packets. Caught because a probe for the tip's "1 of
28" found nothing, not because anything failed. Resolved once, before
the funnel is built; regenerated and verified -- funnel present, tip
reads 1 of 28, 95 photos, 11 sheets.
An RTL audit of research-map, research-dashboard and its four dashboard
components. Translation was already sound -- 101 keys, all present in all eleven
locales, no hardcoded English anywhere user-visible -- so what was left was
direction: rules that pin a physical side while the reader's text flows the other
way.
Three an Arabic reader would notice immediately. Both search icons sit at
left:8px and left:10px while the input around them mirrors, so the icon ends up
on the far side of the field it belongs to. The detail drawer's border-left
stays on the geometric left, which in Arabic is the drawer's trailing edge, so
the panel appears unattached on the side it hinges from.
The rest are quieter but the same mistake: a badge, a provider chip, the two
edge affordances, a table's numeric columns and its indicator, a result row's
spacer, the projection toggle.
Every change is LTR-NEUTRAL, which is what makes this safe to land on its own.
In left-to-right, inset-inline-start resolves to left, text-align:end resolves
to right, margin-inline-start resolves to margin-left. The English view cannot
move; only the Arabic one can. Fourteen insertions against fourteen deletions,
one property each, nothing else touched.
The pattern is not new here -- .rd-legend-map already uses inset-inline-end,
with a comment recording that a physical right pinned it to the wrong edge in
Arabic. These sixteen are the rules that were missed when that lesson was
applied.
What this does NOT establish: that Arabic renders correctly. Every RTL gate in
the repo needs a browser, so this was read rather than run. The three visible
ones want eyes on ar before anyone calls RTL done.
Every scripture, name and reference, on the printed sheet and on the canvas, is set in its own direction, so an English verse on a right-to-left page keeps its full stop at the end.
useDashboardData read `lang` straight out of sources.json, where it is frozen at
"en", and nothing ever overrode it. So every row the dashboard fetched came back
English regardless of the reader's locale.
The result was not a uniformly English page, which would at least be honest. It
was worse. DashboardChartsRail localizes its country labels through
countryName(), so at ?lang=ar the top-countries chart read الصين while the table
row directly beneath it read "People's Republic of China" — and a page that is
half translated looks like a bug in the half that works. Three surfaces rendered
the raw English label: the data table's country column
(dashboard.config.js:299), the detail drawer (DashboardDetailDrawer.vue:171) and
the search-result subtitle (DashboardTopBar.vue:327).
Fixing it at the SOURCE rather than at the three render sites is what makes them
agree: ask the API in the reader's language and the labels arrive correct, with
no countryName() call and no third spelling of a country's name to reconcile.
DataSourceManager.js:261 has done exactly this for its own callers all along —
`if (this.locale) extraParams.lang = this.locale` — and doxa-research-map,
doxa-simple-map and country-posters all pass a locale in. This composable simply
never got the same treatment.
BOTH LOCALE CHANNELS, because it genuinely arrives by either: `lang` is provided
by ProfileLoader for an element configured through profile-config, and
window[PAGE_LOCALE_KEY] is what a generated leaf sets from ?lang= — the URL a
person actually shares. Reading only one left the regions dashboard's data
labels English on the shared-link path while its chrome translated, which is the
measurement this copies rather than rediscovers.
Resolved at REQUEST time, not captured at setup, so a locale switch cannot pin
the dashboard to whichever language happened to load first.
AND A LOCALE CHANGE NOW REFETCHES. load() ran only onMounted, so switching
language mid-session would have re-rendered chrome over data still in the old
language — the same defect one layer up. Same shape as region-dashboards.vue:680,
guarded on a real change so the mount fetch is not doubled.
Caught while writing it: my first version of that watch called inject() inside
the callback, where it does not work — it warns on every tick and returns null.
The ref is captured in setup and only read later, which is what this file already
does for profileConfig, for the same reason.
Proved headless: locale resolution across all four channels (leaf ?lang=,
injected profile-config, neither, and injected-beats-runtime), then that the
chart, the table column, the drawer and the search subtitle produce the SAME
string for CHN, LAO and COD at ar — the disagreement that started this. The
API's ?lang= behaviour itself is not my assumption: it was measured live at ar
and zh on region-dashboards (its comment at :366).
rega RQ20: the driven page asks for show-search:false and show-filters:false and
renders the search box and the filters bar anyway, with 0 console errors. There
were no errors because nothing failed. The key never arrived:
· the preset writes KEBAB into profile-config — "show-search": "false"
· ProfileLoader PROVIDES that object; it does not bind its keys as props
· the profile reads props.showSearch (CAMEL), which keeps its default of true
Three correct-looking layers and no join between them. A preset key that does
nothing looks exactly like a preset key that works, which is why the fix ships
with a gate rather than a comment.
The grid now reads these flags off the config as well as the prop, in the same
prop -> cfg ladder researchUrl beside them already used, and accepts BOTH
spellings: someone writing profile-config by hand reaches for the kebab spelling
because that is what the docs show on the tag, while the prop is camel.
Accepting one silently is what made this inert. An explicit prop still wins.
Proved on five cases including the exact preset, an explicit prop overriding the
config, and config:true keeping the control on.
check:instance-config-keys asserts every preset key reaches the profile that
renders it. Writing it was the interesting part, and it was WRONG TWICE:
1. First version matched only `cfg.` and reported NINE FALSE POSITIVES across
four plugins — big-picture reads config.value.accent, doxa-simple-map reads
profileConfig?.value?.tabs. The config object's variable name is each
profile's own business; matching on it measured my naming habit, not the
contract.
2. Widening it to "any accessor" then matched `props.showSearch` — the exact
line that does NOT work — and the gate went GREEN on the defect it exists
for. props is the one object that cannot carry a profile-config key, so it
is now masked out before the match.
Mutation-tested after that: reverting the fix to props-only makes it fail again,
and the grid file is restored byte-identical. A gate I have not watched fail is
not evidence, and this one passed for the wrong reason once already.
The four bundles that embed the grid are rebuilt, so the fix is in the shipped
.js and not only in the source rega cannot run.
The cross drew inverted. The projection computes Y = 0.5 - v*K, so a larger v is
a HIGHER latitude, and the parts were named on the screen convention where +y
goes down — which put the long arm at the top. That is a different symbol, not a
cosmetic slip. The parts are named by compass now, and the test asserts in
latitude after projection rather than in layout units, which is the only space
where the question means anything.
The fit was bit-identical through three attempts — more padding, a resize, a
refit on idle — and that was the evidence. None of them was wrong; none of them
was the moment the box changed size. These panels are v-show, so a map can be
built while its container is display:none and therefore zero-sized, and the
engine keeps the size it measured then. A fit computed against that lands at the
wrong zoom, which is why one layout opened at 0.64 while an almost identically
shaped one opened at 0.31.
A ResizeObserver is the only thing that knows when the box became real, so the
fit follows the container instead of guessing at a moment, and skips while the
box is still hidden. The failure is reported rather than swallowed, for the same
reason as the cross layer before it.
PinHint self-gated on any <canvas>, so the Wall's own renderer got a tap-a-dot coach-mark over its
dialogs; it now matches only MapLibre and Mapbox canvases. The "?" sat bottom-right, over the map
attribution on several plugins and the zoom-out button on one; map plugins use the corners, so it
moves to the top centre.
Two interop defects on production plugins, found by reading the links rather
than the code around them, and corrected against DX-life's own component once
dx-shepherd pointed me at it.
THE COUNTRY LINK POINTED AT THE PEOPLE GROUP. On the regions dashboard's card,
the country name under the title linked to that people group's own profile —
the same destination as the "Full Profile" button directly beneath it. Two
links on one card, one labelled with a country, both landing in the same place.
Theirs, in BOTH card variants (app/components/public/UupgsList.vue:610, :638):
localePath(`/regions/${countrySlug(country_code.value, country_code.label)}`)
A page on the HOST site — not a research profile, and not the ../countries/
path my first pass reached for. So the base is a PROP, not a constant: only the
host knows where its country pages live, and DX-life's are under /regions/.
Prop, then profile-config, then the site base, so an embedder can point country
names anywhere without touching the research base.
people-groups-grid is ported from that same component and had the same defect
in a different disguise: `${researchUrl}../countries/<slug>`, climbing out of
the research base to a section DX-life does not use for this. Both now build
the same URL from the same countrySlug.
Our countrySlug is theirs: identical body, identical slugify (every transform,
in order), and the tables behind it match exactly — 242 codes each, zero
missing, zero differing labels. My first comparison of those tables parsed
their file into ZERO entries and reported "no differences", which is the shape
of a check that cannot fail; the number above is from the run that actually
parsed 242.
FOUR LINKS DEPENDED ON A TRAILING SLASH NOBODY WAS TOLD ABOUT. researchUrl and
prayBaseUrl are set by whoever embeds the element, in someone else's HTML. The
joins were bare concatenation, and RegionCountryMap's own prop comment records
the contract without ever telling the embedder:
Pray -> prayBaseUrl/<slug>, Full Profile -> researchUrl<slug>
One needs a trailing slash, the other must not have one. An embedder writing
research-url="https://doxa.life/research" — the spelling a human would choose —
got "https://doxa.life/researchbantu-tswana" on every profile link the plugin
draws: a 404, with nothing logged anywhere.
We cannot test what an embedder passes, so joinBase() encodes the rule instead
of documenting it: exactly one slash, whatever we are handed. Eight shapes
checked, including a doubled slash, a leading slash on the path, an empty path
and a null base.
Driver test round 2, #2 — the half the @media print rules could not do. The
complaint was "no loading indicator on a click": the reader presses Print, the
dialog takes a moment to rise, and nothing says anything is happening. It is
worse on this page than most, because what the dialog waits for is a MAP —
press it a moment early and the sheet carries a grey rectangle.
printInPlace.js gains printWhenReady({ waitFor, onBusy, timeoutMs }). This
pattern already exists five times in the repo (upg-100-list c54a11d5a, four of
exp's 786a83461, PeopleGroupsWall.printWall); they agree on the easy half —
set a flag, print, clear it — and each invents the hard half, which is what
"ready" means and what happens when ready never comes. The hard half is the one
that strands a reader, so it is the half that graduates:
· THE TIMEOUT PRINTS. It does not reject and does not cancel. A reader who
clicked Print and waited has asked for paper; a slow tile server is a reason
to print a plainer map, not a reason to produce nothing.
· allSettled, not all — one waiter that rejects must not cost the print.
· onBusy clears in a finally, on every path: a rejected wait, a throwing
print, a host with no window.print. A busy state that outlives its own
operation makes the page look broken rather than slow.
RegionCountryMap answers when it is done, and map.loaded() is not that answer —
it reports true while tiles still stream, which is how gospel's charts printed
blank twice today. The condition is: style applied, camera stopped, tiles for
this camera in. It checks synchronously FIRST and only then subscribes to
'idle', because this component's own header already records that once('load')
measured unreliable in both directions here — an event that fired before you
listened never arrives. Always resolves, always removes its listener.
The button is quiet, sits under the title, and is hidden on paper. Busy state
carries aria-busy and :disabled, not just a changed label: the screen reader is
told, and disabled is what actually stops a second print. Both strings are in
all 11 locales; "Print" was already translated 23 times elsewhere in the tree
under other keys, so this adds the key the dashboard's own catalogue lacked.
Mutation-tested, since the failure this guards is the path nobody takes: a
waiter that never settles returns "timeout" after the ceiling and prints anyway
(201ms measured on a 200ms ceiling), a rejecting waiter still prints, and the
busy flag returns to false in all three.
Round 2 item 6. The control was already built in PosterWall -- state,
persistence, grid binding, markup -- and gated behind an opt-in prop that
only the regions dashboard passed. So most of this is enabling it rather
than writing it, which is what "reuse the pattern, don't re-derive" asked
for. I checked that before building anything, because the last time I
added a feature without checking which surface already had it, it landed
somewhere nobody looks.
Auto is genuinely new: the choices were 2 to 5, always forcing a count,
and the brief asks for Auto meaning today's CSS with overrides on top.
gridStyle already returned {} for a non-positive value -- the comment at
that line even says so -- so Auto needed no new layout branch. It needed
to become REACHABLE: a 0 in the choice list, a label, a read path and a
write path. It extends the earlier min-2/max-5/default-2 ruling rather
than contradicting it, since those bound the counts and Auto is the option
that declines to pick one.
Round-tripping the storage values, rather than reading the code, caught a
bug I had just written: getItem returns null when nothing is stored, and
Number(null) is 0 -- which is exactly Auto now that 0 means something. So
every first-time reader would have silently got Auto instead of the
default, and merely ADDING an option would have changed the layout for
everyone who never touched the control. A missing key now returns null and
falls back to the default; a stored 0 reads as Auto.
Auto is also persisted, which needed its own clause: the range check
rejects 0, so without it the one value that is not a count would be the
one value that never survived a reload. A preference that forgets only
some of its values is worse than one that forgets all of them, because the
reader cannot tell it is broken.
Raising the padding fixed seven layouts and left the eighth exactly as it was,
which was the clue: padding cannot rescue a fit aimed at a box of the wrong
shape. That layout is short and wide, and the fit ran against a stale
measurement, so it resolved to the width and clipped the cloud top and bottom.
The engine is now told to re-read its container before the fit, the fit runs on
the next frame so the flex layout can finish, and it runs once more when the map
settles for a container still resolving on that frame. None of it depends on a
timer.
The cross was drawing as a plus. The upper arm was two thirds of the lower, which
is a real difference and far too small to see at this size. Latin proportions
now: the lower arm is three times the upper and the bar is narrower than the
figure is tall, so the shape is legible as what it is at the size it is actually
drawn rather than only in the geometry.
The chart's theme names, its row labels and the closing line print in English (the blessPoster defaults) until studio's round B carries the theme keys; the title, the DOXA line and the glue label are already translated. lang and dir still follow the page, so right-to-left locales lay the chart out right to left.
The cross source existed and no layer used it. line-cap is a LAYOUT property and
it was passed in paint, so addLayer threw — and the catch beside it ate the
error. The source was added, the layer never was, and the result was a cross that
is simply absent with nothing anywhere saying why.
That is the third time today a try/catch turned a broken thing into a silent one,
and the second in this one feature. The mark still must not block the chart, so
the failure is still caught — but it now says so. A guard that protects the
caller should not also protect the bug from being found.
The fit takes more padding. It has to hold the labels and not only the points —
the outermost band label sat on the canvas edge at 1280 — and a short, wide
canvas fits to width and clips top and bottom unless the bounds are given room in
the dimension that is scarce, which is what cut the cloud on one narrow layout.
A window key holding a closure pins the whole app after unmount -- the
component, its shadow root, and everything they reference, measured at
roughly 10 MB per mount. The three test handles here each close over live
state (the search index, the map, the selection), so each one is a full
retention path.
SIX GLOBALS, NOT THREE. The check names the three it can see, but each
named handle has a paired per-instance registry --
__UPG_SEARCH__/__UPG_SEARCHES__, __UPG_MAP__/__UPG_MAPS__,
__UPG_SELECT__/__UPG_SELECTS__. Releasing only the named half leaves the
registry holding the same closure, so the leak survives in the copy nobody
was looking at.
The two halves need opposite handling, which is why this is not one loop.
A named handle is a single slot that the last mount overwrites, so
deleting it blindly would strip a LIVE sibling's handle -- it is cleared
only if it is still the object this instance published, captured at
assignment so it cannot drift from what went out. A registry entry is
keyed by instance, so my key is always mine to delete, and deleting the
whole registry would strip the siblings.
Verified against the two-instance case rather than the single-mount one:
A mounts, B mounts and takes the named slot, A unmounts -- B keeps its
handle and its registry entry, and after both unmount nothing is
retained.
One delete per line, deliberately: the check reads these statements, so a
loop over a key list would release correctly and still be reported as a
leak. The whole block is wrapped because a frozen or cross-origin window
must never break teardown.
The board collapsed to a single panel below 760px; the set charts stacked their
legend below 900. Between those widths the board still held three columns while a
chart inside it had already given up on having room — at 768 the centre column
was about 145px, so one figure was crushed to 124 and the other's own minimum
made it spill under the right-hand panel. Neither number was wrong alone; the
defect was that they were two numbers, with nothing connecting the width at which
a chart needs room to the width at which the board gives it any. They are one
constant now.
Ring labels take four anchors instead of one. A single label at the top meant a
reader zooming anywhere else on a ring saw no ring name, which is most of the
ring and certainly wherever the dots are dense. The engine drops the extras when
they would collide, so low zoom is unchanged.
The cross is drawn as geometry rather than as a text glyph. It was a symbol whose
label was the character itself, and the layer rendered zero features: a glyph
appears only if the font has it, and nothing reports a missing one. Two line
segments depend on no font, and are the same mark in every locale — including the
right-to-left pages, where a text cross would also have been subject to bidi
reordering.
Both charts fit their whole extent on load. The default zoom clipped the outer
rings top and bottom, so the reader met a drawing already cut off — which reads
as a rendering fault rather than as something to zoom out of.
Twelve globals were set across three plugins and none was ever deleted. Each
pins whatever it closes over — a map instance pins its canvas, its sources and
through them the whole app — so every mount left another copy behind, measured
elsewhere at roughly 10 MB each.
Released as a set rather than one at a time. A block that sets several globals
and clears the obvious one leaks the rest silently, and the obvious one is
whichever the author happened to remember.
Identity-checked, which is why the value is recorded as it is set rather than
cleared by name. Two instances can share a page, and clearing by name alone
would let the second unmount delete the first's live hooks — trading a leak for
a harder bug in which a live map loses its test surface at a distance.
A first attempt compared against a name that did not exist. Inside the try/catch
that guards these, the ReferenceError was swallowed and every delete silently did
nothing — a leak fix that looks exactly like a leak fix and leaks. The guard that
makes the assignment safe is the same guard that hides its failure, which is the
argument for recording what was set rather than inferring it at teardown.
The guard added last time is what kept this broken. createBoardMap resolves only
after awaiting the load event, so by the time the component runs, load has fired.
The guard asked map.loaded() — which does not mean "the load event fired" but
"the style and every tile are loaded" — and the basemap tiles were still
arriving. It returned false, took the else branch, and registered a handler for
an event that had already happened. A check added for a hypothetical future
engine broke the only case that exists.
Defensive code that changes behaviour is not defensive. If a check is ever wanted
here it is isStyleLoaded(), which is true at this point; loaded() answers a
different question than the one being asked.
The figure was crushed rather than unstyled. The zero flex-basis shipped
correctly and the figure was still 39 pixels wide: a basis of zero lets it grow
from nothing, but min-width:0 also lets it be squeezed to nothing, and the
decoder card's own content width wins that contest. The figure now refuses to go
below a usable size and the card shrinks or scrolls instead, because the chart is
the main view and the legend explains it.
Seven changes to two plugins, re-applied onto current main as ONE source patch
rather than rebased, because the rebase conflicted in the generated bundles —
where a conflict is never resolvable by hand, since neither side matches what
either source builds. Source re-applied clean, everything rebuilt from it.
people-groups-grid
· the saved column count is restored before first paint, not after, so a
returning reader no longer watches the grid snap from auto to their choice.
It re-reads if the profile-config key arrives late, and never over a choice
the reader has since made by hand.
· the cards-per-row control is inset from the card edge it was sitting level
with (Driver round 2, #5).
· the window vocabulary handle is released on unmount, identity-checked so the
first of two grids on a page cannot strip the second's live handle.
regions-dashboard
· the four stat cards no longer let Population stretch across a tablet row.
auto-fit collapsed the empty tracks; auto-fill with the track minimum capped
at a quarter of the row gives at most four columns and never a stretched
orphan. Measured at nine widths.
· the dashboard is printable at all. It had no @media print rule, so the app
shell's absolute root and inner scroller meant the browser printed one
screenful; the legend stayed on top of the map; interactive chrome printed.
· both window globals are released on unmount, not just the remembered one,
each identity-checked and on its own line.
shared asset
· the worldmap watermark is 242 KB rather than 311 KB — same 394 paths, same
drawing, coordinate precision trimmed to what the render resolves.
Neither chart drew anything. createBoardMap already awaits the map's load event
and resolves after it, so attaching a second load handler waited for something
that had already happened: the layer setup never ran, each map held only its
basemap layers, and the panel showed an empty whiteboard.
It failed silently, which is why the visible-failure branch did not catch it.
Nothing threw — the map was fine, the source was simply never added. A guard that
reports errors cannot report an absence of work.
The setup now runs directly after the await, and still binds once('load') if a
future engine resolves earlier. That keeps the fix correct rather than merely
right today.
The figures also grow from a zero basis instead of from their content. The map
inside is absolutely positioned and has no intrinsic width, so a basis of auto
asks for a content size of nothing and the legend takes the column — the figure
was measured at 39 pixels across while the decoder had the rest. This was hidden
behind the first bug: an empty chart and a chart with no room look the same.
The neighbour table is 2.6 MB. Until it arrives the joined set is legitimately
empty, so both panels drew a chart of nothing and captioned it with a count of
zero — indistinguishable from a real answer of none, which is the same defect as
a join failure reported as a fact about a people.
Found by sweeping my own plugins for the Driver's bug classes rather than by
anyone reporting it, which is the point of the sweep: the newest code was the
code with the defect, because it had been read least.
The loading line is announced politely for a screen reader, since the change it
describes is a region of the page becoming real rather than an alert.
Every width change reset the legend from the breakpoint, so opening the decoder
on a phone lost it on rotation, and closing it on a wide window reopened it by
dragging the edge. The resize was undoing the reader's decision rather than
responding to it.
The width now decides only until the reader does. After that their choice holds
at both widths.
This is the same defect fixed in the other plugin's legend earlier today, written
again here a few hours later, because the two legends are separate
implementations of one control and only one of them had learned. That is the
argument for promoting the control, not for being more careful next time.
The Prayer-list dropdown went into PosterWall's per-card print row. The
profile people open -- upg-100-list -- does not render PosterWall at all;
its print path is the legend's Export menu, which still offered two items
and printed immediately. So the feature was built, landed and served, and
invisible on the only screen it was asked for.
Two surfaces legitimately offer printing and both carry a button labelled
Prayer list, so the label was not enough to tell them apart. The grep that
would have caught it -- does this profile mount that component -- takes
five seconds and I never ran it.
The legend's packet item is now a disclosure: it is named UPG 100, it
explains what a UPG 100 list is, and it reveals Full detail and
Lightweight. The explainer matters because this menu item is the only
place a reader meets the idea, so it wraps rather than truncates.
The submenu opens BELOW its trigger by construction, not by positioning:
the menu is a flex column, so a block after the button flows underneath
it. A panel that grows its parent cannot cover its parent -- which is
exactly what the card-deck row could do, hanging out of its card at
top:100% with nowhere to go on a short viewport.
emitExport widens its existing string to packet:full / packet:lightweight
rather than adding a second event; poster and a bare packet still work, so
an older consumer keeps working and one event means nobody can handle only
half the menu. printPrayerPacket takes the variant and forwards it to
printPacket, which already accepts it.
UPG 100 is allowed to be identical in all eleven locales, recorded in
check:i18n-never-translated as a brand name rather than as debt: it is not
waiting on a ruling, it IS the ruling, and the explainer beside it carries
the meaning per locale. Inventing eleven names for one product would be
the failure that gate exists to prevent.
Found by the bug-CLASS sweep, which is the point of doing one: the busy
indicator was added to the selection HUD after a report that pressing its
buttons showed no sign of work, and the legend's Export menu -- Poster and
Packet -- had none at all. Same defect, different surface, and I fixed the
reported instance without looking for its siblings.
Both do the same slow work: two dynamic imports, a map-SVG fetch, a funnel
fetch, then a multi-sheet render. onLegendExport now guards re-entry (a
second click would run two renders over one print frame), sets busyAction
so the control says what it is doing, and clears in finally on every exit
path including the early return inside the try.
The gate's busy assertion had two holes that let this through, and both
are fixed. It counted setters by matching the literal strings 'packet' and
'poster', so a ternary assignment was invisible -- a regex that enumerates
the shapes it knows about will always miss the next one, so it now matches
the assignment and excludes the clear. And it only asserted a whole-file
count, which "some handler somewhere sets it" satisfies; it now asserts
PER FUNCTION for all three print paths, so a surface cannot be forgotten
while the file as a whole looks fine.
Mutation-tested: removing the busy handling from onLegendExport now fails
naming that function, and dropping a finally fails naming its own.
Also drops a stale "page 3" from a shipped CSS comment -- page order is
variant-dependent since lightweight started omitting two sheets, so a
fixed page number in the output is wrong even where no reader sees it.
Two things the Driver found, both about a control being in the reader's way
rather than about it not working.
The collapse caret was absolutely positioned over the panel's top-right, and the
title was hidden when collapsed — so collapsing left a lone glyph floating on the
map with nothing to say what it would reopen. It now sits inline in a header row
beside the title, which is where the shared legend puts its own: a flex item with
no positioning at all. The header stays visible when collapsed, so the control
always carries its label.
Side by side there is room for the target and its decoder. Stacked on a narrow
pane there is not: the decoder is a tall card that pushes the chart off the
screen, so a reader who asked to see the target got a glossary. Below the
breakpoint the legend now starts closed behind a named toggle. It is not hidden —
the control says what it opens, and opening is one tap.
The toggle reuses keys that already exist in every locale rather than adding a
word of its own. A new string would have meant eleven catalogue entries owned by
another tab and landing in another batch: a cross-owner dependency bought for one
label, when the fleet already had the sentence.
The map inside is absolutely positioned to fill its figure, but the figure was
not a positioned ancestor, so it resolved against the panel instead and covered
the legend beside it. The bounded set's figure already carried position:relative;
this one did not — the difference two files drift into when the same rule is
written twice rather than shared.
In the narrow column a flex child sized by its content can resolve to zero
height, which is what a chart measured at 0x0 is. The figure now has a floor, and
stretches rather than centring itself into nothing.
The legend takes a flex basis instead of growing to its content. Reported
squeezed to 145x145 at 1440: a card that is only told a maximum will still take
what it asks for, and the figure had nothing but min-width:0 with which to argue.
The ring legend declared a fullscreen emit and rendered a labelled, focusable
button for it. The parent bound select and not fullscreen, so the button looked
right, tabbed right, announced itself right, and did nothing. It is a Driver ask,
and it is the half-wired shape exactly: the visible half worked, so nothing
appeared broken.
The fix is not to bind the emit. The button is now the shared control, so there
is one implementation instead of two — and it brings the part a second one would
have missed. iOS Safari does not implement requestFullscreen on arbitrary
elements and fails silently, so the shared control carries a pseudo-fullscreen
fallback. A hand-rolled handler would have worked everywhere I could test it and
nowhere I could not.
A first attempt kept the hand-rolled button and called the shared component's
toggle through a template ref. That component has no defineExpose, so under
script setup the method is private and the call would have resolved to undefined
— silently reintroducing the same defect while appearing to fix it. Checked
before committing rather than after.
The panel element is passed in, because full screen should show the chart and the
legend together, and the legend does not own the panel it sits in.
Found by sweeping for the half-wired-config class rather than by anyone reporting
it, which is what that class is for: nothing about it looks wrong from outside.
The layout modules moved to library when they were promoted, and three of my
files still named the old paths: the two new whiteboard components, which were
written before the move landed, and the geometry check, which was written after
it and still copied the old import from its neighbours.
GiBoundedSet also carried an unresolved conflict — markers and all — committed by
a cherry-pick loop of mine that resolved generated output automatically and swept
the source conflict along with it. The file had both imports of the same symbol,
one of them pointing at a deleted path. It parsed, so the SFC check passed; only
the bundler would have caught it, and only at the point where it could not
resolve the file.
The lesson is about the loop, not the file: resolving one class of conflict
automatically is fine, but `git add -A` afterwards does not distinguish the
conflicts you meant to resolve from the ones you did not. A loop that stages
everything needs to refuse when a marker is still present.
Both set charts now pan and zoom. The chart still makes one claim — which side
of the line a group is on — so nothing here encodes distance or rank; what zoom
buys is reading the names, because at two thousand dots the inside is a cluster
until you can get close to it.
The boundary is the shared dish layer, which is the same layer a language
family's circle uses, so it is not a new type for either of us. Its radius takes
an exponential-base-2 zoom interpolation rather than a fixed pixel value.
circle-radius is screen pixels: a constant would hold its size while the dots
spread out from under it, leaving the circle correct at exactly one zoom and
wrong at every other — and for a dot near the edge the inside/outside reading
would silently invert, which is the only thing this chart says. Pixels per degree
double with each zoom level, measured at exactly 2.000000 between adjacent
levels, so the interpolation tracks map units exactly rather than approximately.
The boundary is drawn as a line with a transparent fill. A filled disc would say
the inside is a region, when what the chart means is a threshold.
The cross rides in the point collection as its own tier rather than as an
overlay, so it pans and zooms with the drawing instead of floating above it.
The panel now mounts the zoomable chart in place of the SVG. Pan, zoom and
labels that appear by tier as the reader moves in were the ask; the engine
supplies tiling and label collision that an SVG version would have to
reimplement, and badly.
The printed component stays SVG, which is not an inconsistency: a sheet has no
zoom and no hover, so paper and screen genuinely want different renderers. What
they share is the ring vocabulary, so the two cannot drift apart in what they
call a ring even though they draw it differently.
The ask changed and so did the call. A static target is fine in SVG; a zoomable
one carrying ~2,100 labels is not. The engine tiles the collection per zoom so it
draws less when zoomed out, and its symbol layers do label collision, which is
the part that would take longest to do badly by hand.
The label staging is one layer per tier with its own minzoom: band names when
zoomed out, ring names in the middle, group names in close. None of that is
tree-shaped, which is why it is a shared layer rather than a copy of one.
Coordinates go through the shared projection, never a scale. It is the exact
inverse of the renderer's transform, so a circle stays a circle; a linear scale
would draw concentric rings as ovals worsening outward. The only number this
chart owns is its extent.
Dimming a deselected ring is a paint expression rather than a filter, for the
reason the SVG version dims instead of hiding: a ring emptied of its dots reads
as a ring with no groups, which is the opposite of what a reader selecting it
just asked for.
The layer id is read back from the handle rather than written from memory. The
first version reached for a name the module does not use; inside a try/catch that
does not throw, so the dimming would simply never have happened — a legend that
appears to do nothing rather than an error. Asking the handle means a rename
cannot silently disable it.
The projection is passed in rather than built here, so the chart and its checks
share one instance and this module still owes nothing to a coordinate system it
does not define. The dish takes its radius from the projection's exposed scale:
deriving it by projecting two points and subtracting would be right on the
equator and wrong everywhere else.
The guard reports the outermost latitude, because that is the real limit and
shape is not. The projection is the exact inverse of the renderer's transform, so
a circle stays a circle at every extentFraction — measured at 0.000% aspect
error. An earlier reading of mine said otherwise and was wrong: it computed
screen x as longitude times cos(latitude), a plate-carree correction Web Mercator
never applies, which manufactured a distortion the renderer does not produce.
What does bite is the pole. The outermost point reaches 52 degrees at the default
0.17, 77 at 0.35, 83 at 0.45, and Web Mercator is undefined at the poles with
engines clamping near 85.
That ceiling is a property of extentFraction alone, not of a chart's extent:
the scale is extentFraction over extent, so the outermost point is at
extentFraction whatever the extent, and every chart reaches the same latitude.
Measured across extents 100, 228, 260 and 1000 — identical to three decimals. So
a consumer cannot buy headroom by choosing a bigger extent, and a check written
against one chart's ceiling holds for all of them.
The shared whiteboard layer draws a point collection where each feature carries
an id, a label, a tier and a priority, and stages its label layers by tier and
zoom. This decides which features exist, what tier each belongs to and how they
rank — which is this chart's business — and it does not convert to degrees.
Ring and band labels are placed on the radii the chart already draws rather than
on a second calculation, so the label and the ring it names cannot drift apart.
Groups rank by population, so when two names collide the larger people wins: a
reader zoomed out should meet the names carrying the most people, not whichever
was drawn first. Bands outrank rings outrank groups, because that is the order a
reader zooming in meets them.
Coordinates out are abstract on purpose. Converting them is a projection, not a
scale — the whiteboard shrinks degree height outward so the map's own stretch
cancels it, and a linear scale skips the cancellation. Measured on a ring of 720
points: with a linear scale the rendered aspect is 0.9968 at the inner ring and
0.7952 at the outer, so the target draws as ovals that worsen outward, worst
exactly where the ring labels sit. With the projection it is 1.0000 at every
ring.
That measurement also settles where the guard belongs: at the inner ring the
linear error is three parts in a thousand and would pass any sane tolerance. An
assertion placed there alone would be inert against the one mistake it exists to
catch. It goes at the outer ring, and at both.
rega measured a cold load of Simple Research pulling 24 people-group photos, 5.3 MB,
DURING BOOT — alongside the map engine and the API data, on one connection. Nothing was
wrong with any single image. There was no instruction about WHEN to fetch them, so the
browser fetched them all at once, which is the correct behaviour for markup that says
nothing.
Past the first row the cards now load lazily, at low fetch priority, decoded off the main
thread.
★ THE FIRST ROW STAYS EAGER, and that is the half a blanket loading="lazy" gets wrong.
Lazy-loading what the reader is already looking at trades a visible flash of empty cards
for bytes nobody saves. EAGER_IMAGES is a COLUMN COUNT, not a taste: the grid is
repeat(auto-fill, minmax(240px, 1fr)) unless a host overrides cards-per-row, so the first
row is never more than five cards at any width. It is a constant rather than a read of
perRow because the override is the host's state and this only needs a safe CEILING —
generous by a card costs a few KB, short by one lazy-loads something in view.
The size is stated on the element as well as in CSS. .uupg__image already carries
aspect-ratio 1/1, which is what actually reserves the box; width/height repeat the same
1:1 as an intrinsic ratio for the moment before the stylesheet applies. The same truth
twice on purpose, not a second different one.
check:grid-image-loading exists because this component is being MERGED with the regions
list right now, and a merge rewrites markup: four attributes on one <img> survive a review
and not a rewrite, and losing them is invisible — the page still works and costs 5.3 MB
again. It also fails if it cannot find the element, and asserts the first row is eager,
which a bare loading="lazy" would otherwise pass. Negative-tested four ways.
Coordinated with regions-plugin before touching a file they are actively reshaping: their
six hunks bracket this element without touching it, so neither of us collides.
Found by the bug-class sweep. RegionCountryMap is passed :research-url; the
UupgsList beside it is not — zero research mentions in its entire tag. So a host
setting profile-config.researchUrl got it honoured on the map popup's Full
Profile button and silently ignored by every profile link in the country list,
which fell back to a hardcoded doxa.life default.
The shape is worth more than the line: one config, two consumers, only one
bound, and the unbound one carrying a plausible default. A missing binding that
fell back to an empty string would have rendered a visibly broken link; this one
renders a working link to somebody else's site, which is why it survived. It is
invisible in either file alone — the parent's tag list has to be read against
the children's props.
One line. Bundle rebuilt; the binding is in the artifact.
Four reviewed changes to ONE file. SOURCE ONLY — no built artifacts — see the note below.
PRINT. .rm-map-area is flex:1 1 0 and has no height of its own; print layout has no
viewport height, so the box collapsed and the toolbar and legend, positioned against it,
followed its top edge up. Nothing in this render path declared an @media print rule at
all, so the browser's default print stylesheet laid the page out. A profile with no print
rules is not neutral in print; it is unspecified.
MOBILE EXPORT. LegendMobile is opt-in by construction -- its exportEnabled is
`typeof inject('onLegendExport') === 'function'` -- and this profile provided nothing, so
a reader on a phone had no way to export a poster or a packet while the same map on a
desktop did. Provided guarded by EXPORT_FEATURE_ENABLED, the way country-posters does it.
BUSY STATE (bug-class sweep C7). openPoster guarded re-entrancy with a REF wired to
:active/:disabled/:aria-busy; openPacket guarded it with a plain `let`, which drives no UI
because it is not reactive -- so the SLOWER action showed nothing. packetBusy is a ref
now, and legendExportBusy reports either build for the legend's Export menu.
DEAD PROBES. docScrolls and ancestorScrolls were function-local consts, computed and
discarded, kept alive by a comment claiming the edge affordance keyed its indicator off
them. This profile has no such indicator and never calls useEdgeScrollAffordance.
_findScrollportAncestor STAYS: it is still a ResizeObserver target.
Plus two physical CSS properties made logical for RTL, and the verbatim Driver quote in
the scroll-zoom note paraphrased per the L64 policy.
★ WHY NO BUILT OUTPUT. I built this and tried to self-land it, and backed out: staging
public/ wholesale swept in country-posters' artifact and three leaf pages as they were at
MY build base, so the commit carried a REVERT of exp-shepherd's rebuild and a stale
integrity hash for their bundle -- an SRI mismatch, not a cosmetic diff. Caught by
comparing every bundle's integrity against main rather than trusting that a clean rebase
means a clean diff: a rebase resolves TEXT, and an artifact nobody else edited in the same
lines still loses to whichever commit touched it last. main then moved twice more while I
rebuilt. So this is source-only and the build belongs with the build owner, who is
rebuilding these bundles anyway.
Mobile has had a fold control since the narrow layout existed; desktop had none,
so a reader who wanted to see the map underneath had no way to move the legend
off it. The state was already there — every section read "not narrow OR open", so
on desktop the OR was always satisfied and the flag governed nothing. Now the
flag alone decides at both widths.
The control is deliberately not a new one. Same 22x20 caret, same rotation, same
aria contract as the shared SemanticTreeLegend's collapse button, which the
mobile legend also copies. Matching it costs nothing and means a reader who has
met one of these legends has met this one.
Collapsed, the panel shrinks to the caret. Hiding the contents alone would have
left a 380px box of empty background over the map: the reader presses collapse
and gets a blank panel instead of their map, which is worse than having no
control at all.
The choice is remembered for the session, so it survives working with the map but
not a fresh visit, and a remembered choice beats the width default — otherwise
dragging a window across the breakpoint would silently reopen a legend that had
been deliberately closed, undoing a decision rather than responding to one.
Storage access is wrapped: a sandboxed embed throws on it, and a legend that
cannot remember is much better than a map that will not load.
Found by the bug-class sweep. RegionCountryMap is passed :research-url; the
UupgsList beside it is not — zero research mentions in its entire tag. So a host
setting profile-config.researchUrl got it honoured on the map popup's Full
Profile button and silently ignored by every profile link in the country list,
which fell back to a hardcoded doxa.life default.
What makes it the sweep's most useful find is the shape rather than the line:
two consumers of one config, only one wired, and the unwired one carrying a
plausible hardcoded default. A missing binding that fell back to an empty string
would have rendered a visibly broken link; this one renders a working link to
somebody else's site. It is invisible in either file alone — the parent's tag
list has to be read against the children's props.
One line to fix. gates:pre 40/40.
Added a regions-look instance so the merge can be compared side by side against
the standalone one: same component, same cards, only the two props he chose
differing. If anything else differs between those two pages, the merge changed
something it was not asked to.
Then the preview would have proved nothing, and finding that was the point of
building it. These generated pages set exactly ONE attribute —
el.setAttribute('profile-config', JSON.stringify(cfg)) — and never per-prop
attributes. A prop read only from `props` is therefore invisible to every
preconfigured instance: the page would have rendered the default look while its
title announced the variant. A demo that cannot fail.
So the merge props read profile-config as well, with the prop still winning when
a host passes one directly. Measured on the generated page rather than assumed:
BASE_CONFIG carries "filters-toggle":"link" and the element receives it inside
profile-config, never as an attribute.
Bundle rebuilt before the tree, and the props are in the artifact.
⚠ THE SAME GAP LOOKS LIVE FOR show-search / show-filters, and it is not mine to
fix blind: the shipped `driven` instance sets "show-search":"false" in
BASE_CONFIG, and useSearch reads only props.showSearch. If that never arrives,
the driven preview has been rendering its own controls all along while
advertising that they are off. I have not touched it — it needs one look to
confirm before anyone changes behaviour that three hosts embed.
sr caught a layout change hiding inside a styling one. dx-life's root treatment
carries min-height:400px, and shipping that as part of backdrop:'root' was
tempting because it came with it — but in a scroll stack a min-height is a FLOOR
on the grid band, not a backdrop rule. An empty or heavily-filtered grid that
collapses to a short band today would have become 400px tall with a world map
behind it. That is the shape of thing a reader finds before we do. The floor is
now its own opt-in prop, named for what it does.
And the gate sr asked for. "Every default reproduces today's grid exactly" is
the entire safety argument for putting three consumers on one component, and it
is exactly the kind of claim that is true when written and false three props
later — the grid ships in Big Picture, Simple Research and the studio preview,
so a drifted default moves all three at once, silently, because none of them
passes the prop that changed.
It reads the defaults through the SFC and JS parsers rather than by grep, since
a default is an AST node and a regex cannot tokenise JavaScript. It also asserts
the 400px is not hardcoded anywhere in the backdrop, which is the specific
defect above rather than a general rule. Falsified: flipping the backdrop
default to 'root' turns it red. A negative control pins that the reader really
sees defaults, so the arms are not vacuous.
Simple Research's own answer is recorded in the shape of the code: their filters
stay non-collapsible by default, because a collapsed panel on a map-plus-grid
hides the control a reader came to use.