Commit graph

1254 commits

Author SHA1 Message Date
Doxa Maps
18eabae1ca golden-path-check finds its manifest from its own script, so it answers when embedded
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.
2026-09-28 23:23:58 +00:00
Doxa Maps
c54bde307f CLDR plural selection, and the "N countries / N regions" keys it was blocking
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.
2026-09-28 23:23:56 +00:00
Doxa Maps
8b8e9fee9c The wall-printed packet gets all five of its card pages
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.
2026-09-28 23:13:47 +00:00
Doxa Maps
9799ebdba4 The full packet's sets page gets its data, from every print path
setsPage landed as a renderer capability with no data supplier: it
returns '' without data -- correctly -- which meant the FULL-DETAIL
packet had never once printed its distinguishing back page in real use.
The full-vs-lightweight split existed in code while the full packet was
missing the page that makes it full.

packetSets.js is the supplier: a pure decode+join with zero imports, so
the identical function runs under Node (the generator reads the table
from disk) and under Vite (the browser fetches it) and the join cannot
fork between the two. The table stays with the caller -- the same seam
as strings, christian and describe.

The join is measured, not assumed: 2,091 of 2,107 groups match by slug,
rings distribute 1-10 with real shape, and the 16 unmatched are inked as
UNKNOWN on the bounded figure rather than guessed onto a side --
an un-inked category reads as zero on paper.

Wired at every path that prints a packet: the offline generator (all 28
zones verified carrying the page, e.g. laos: 0 inside, 94 outside, 1
unknown; 94 placed with "25 same dialect, same people"), the legend
Export menu, the poster wall, and the selection packet -- the last three
through one opts.sets loader fetching the SAME served asset-pool copy
the generator reads, so an embedded bundle prints the same page as the
dev tree.

The decode is a third reading of near-neighbors.json's cols/dict format
(nearRows.js documents being a copy of upg-neighbor's, pending promotion
to library/). Flagged in the header rather than hidden: when the loader
is promoted, the decode here collapses into it and only the join
remains.
2026-09-28 23:13:47 +00:00
Doxa Maps
12b913a711 Wall of People Groups: the poster's prayer-themes chart and closing line print in the reader's language 2026-09-28 23:01:29 +00:00
Doxa Maps
55767fabbc Arabic mirrored the layout but not these sixteen rules
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.
2026-09-28 22:53:40 +00:00
Doxa Maps
00dabc0a9e Wall of People Groups: the poster's footer tells the calling to bless the nations, from Abraham to the church, with Christ the cornerstone at its centre
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.
2026-09-28 22:44:20 +00:00
Doxa Maps
b1dbd9bc87 The research dashboard asked the API for English, then translated half of it
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).
2026-09-28 22:25:31 +00:00
Doxa Maps
34010ce6d8 The grid's "controls off" preset did nothing, and nothing said so
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.
2026-09-28 22:25:30 +00:00
Doxa Maps
c8227bd994 The pin hint only answers to a map canvas; the Experimental "?" moves to the top centre
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.
2026-09-28 21:59:29 +00:00
Doxa Maps
8350617853 The country link went to the people group; four links broke on a missing slash
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.
2026-09-28 21:59:29 +00:00
Doxa Maps
a79a92d70b One shared print busy state, and the dashboard waits for its map before printing
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.
2026-09-28 21:59:29 +00:00
Doxa Maps
1717ab3016 The UPG-100 poster list gets the per-row control, with an Auto option
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.
2026-09-28 21:59:28 +00:00
Doxa Maps
2d6e8e6912 UPG Neighbor: a green group's unreached neighbours one E-level at a time, closest first, and green dots on Campaigns open them 2026-09-28 20:57:15 +00:00
Doxa Maps
e7356fa1b9 Wall of People Groups: the poster prints its chart labels and closing line in English until their translations land, and the sea's New Testament verse is the storm stilled (Matthew 8:26)
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.
2026-09-28 20:51:53 +00:00
Doxa Maps
4b299bad8f Wall of People Groups: the poster tells the story, a prayer-themes chart of treasure, mountain and sea above the people groups and the priestly blessing and the priesthood of believers beneath them 2026-09-28 20:51:32 +00:00
Doxa Maps
317f33c15e Wall of People Groups: a 3 by 3 foot poster size 2026-09-28 20:51:32 +00:00
Doxa Maps
bee12baea0 Wall of People Groups: focus returns to the wall when a dialog closes, and the map pin hint stays off the wall 2026-09-28 20:51:32 +00:00
Doxa Maps
ef7325990a Wall of People Groups: names open the group's prayer page, and the prayer and comment dialogs close on Escape and keep focus inside 2026-09-28 20:51:32 +00:00
Doxa Maps
84c745c44e Wall of People Groups: one canvas instead of 36,000 SVG nodes, drawing only the cards in view, photos loaded as you zoom in 2026-09-28 20:51:32 +00:00
Doxa Maps
b0d336d0d6 Wall of People Groups: the poster is 5 by 8 feet by default, three stacked sheets glued on 6-inch borders, and other sizes fit their own grid 2026-09-28 20:51:32 +00:00
Doxa Maps
43992ccac3 Wall of People Groups: the share notice and the drawing-library error in the reader's language 2026-09-28 20:51:32 +00:00
Doxa Maps
3f3494eac9 Wall of People Groups: in Bless mode, Print poster prints all the people groups as one 8 by 5 foot poster on three overlapping sheets 2026-09-28 20:51:32 +00:00
Doxa Maps
f4c3767784 Wall of People Groups: Bless mode by default, a blessing verse under every people group, and Social mode one click away 2026-09-28 20:51:31 +00:00
Doxa Maps
988092d363 Release every window global this profile sets, on unmount
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 20:46:53 +00:00
Doxa Maps
c9c2280241 Pplr Data Maps: its verification hooks leave window when a view closes, so a closed view no longer keeps the whole app in memory 2026-09-28 20:40:50 +00:00
Doxa Maps
401b5d28d0 Country Posters: Export poster opens its dialog again, and two legend calls that did nothing are gone (the legend's rows are called items) 2026-09-28 20:40:50 +00:00
Doxa Maps
24b32e199f Release the test hooks on unmount, all twelve of them
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.
2026-09-28 20:32:59 +00:00
Doxa Maps
82b2e2aa5a Call the layer setup unconditionally, and stop the figure being crushed
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 20:24:29 +00:00
Doxa Maps
182611c94b Regions batch: perRow boot, control inset, stats grid, print, two leaks, smaller watermark
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 20:20:50 +00:00
Doxa Maps
2f26f61ea4 Draw the set charts at all, and give their figures room to be drawn in
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.
2026-09-28 20:15:38 +00:00
Doxa Maps
66e0697645 Say the table is loading, rather than drawing a confident zero
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.
2026-09-28 20:15:37 +00:00
Doxa Maps
a0f5a9d8e4 Keep the reader's legend choice across a resize
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.
2026-09-28 20:15:36 +00:00
Doxa Maps
f172312cab Put the UPG 100 choice on the surface that actually shows it
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.
2026-09-28 19:41:29 +00:00
Doxa Maps
c54a11d5aa The legend's Export path gets the busy state the HUD already had
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.
2026-09-28 19:41:29 +00:00
Doxa Maps
042111bf3f Seat the legend caret in the title bar, and let the target keep a narrow screen
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 19:13:38 +00:00
Doxa Maps
16ff7e719b Give the centered figure its own positioning context and a height floor
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 18:57:46 +00:00
Doxa Maps
e2086c4d2f Make the legend's full-screen button do something
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.
2026-09-28 18:57:46 +00:00
Doxa Maps
7e9d4d62cb Point the set charts at the promoted modules, and clear a committed conflict
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.
2026-09-28 18:57:46 +00:00
Doxa Maps
092585af6c Put the bounded set on the whiteboard too, with its boundary in map units
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.
2026-09-28 18:57:46 +00:00
Doxa Maps
b68685801e Show the centered set as the whiteboard map rather than a static figure
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.
2026-09-28 18:57:46 +00:00
Doxa Maps
1163d488b3 Draw the centered set on the whiteboard, so it pans, zooms and labels by tier
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.
2026-09-28 18:57:46 +00:00
Doxa Maps
8390f06465 Project the set-chart features onto the whiteboard, and report the real ceiling
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.
2026-09-28 18:57:46 +00:00
Doxa Maps
ff9edd25f7 Shape both set charts as tiered points, and leave the projection to the caller
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.
2026-09-28 18:57:45 +00:00
Doxa Maps
fcad0dc1a0 Wall of People Groups: comment times and the unnamed-group fallback in the reader's language 2026-09-28 18:52:57 +00:00
Doxa Maps
8dcadc16d1 The country list never received the configured research URL
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 18:49:16 +00:00
Doxa Maps
a45f1914a6 Let the legend collapse on desktop, the way it already does on mobile
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
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.
2026-09-28 18:45:13 +00:00
Doxa Maps
a1be5c7db3 Country Posters: a partner's basemap, night look, search switch and start view reach the map inside it 2026-09-28 18:40:21 +00:00
Doxa Maps
786a834610 Map errors in every language, and print buttons that show they are busy (UPG Neighbor, All People Groups, Prayer Walk Planner, Wall) 2026-09-28 18:34:16 +00:00
Doxa Maps
07e086ff23 check:legend-top-offset runs in gates:pre; a grid comment states its rule instead of quoting it 2026-09-28 17:54:33 +00:00