Commit graph

55 commits

Author SHA1 Message Date
Doxa Maps
d95c3b9820 Map-print template: the verse numbers read left to right in Arabic, the map's name reaches Simple Research, and the prayer line is fetched ahead
In a right-to-left footer the range "10:11–15" printed as "15–10:11": the numbers of each reference now sit in a
left-to-right isolate (packet-render's own split of book and numbers). The research-map-plus-grid composite
forwards the host's mapTitle to its map, which it did not pass on before. And the packet strings are fetched
when the map mounts (when the browser is idle), so the print's 3 s wait reads them from the cache instead of
dropping the line on a slow network.
2026-09-30 10:20:27 +00:00
Doxa Maps
76ba9c1a6d Simple Research's map band ends on the first screen
On a phone under DX Life's header, the band ran 32px past the fold (y132 to y876 on an 844px
screen). The legend sheet's bottom and its collapsed strip began below the fold, so a reader who
collapsed the legend could not reopen it without scrolling. The band now takes the height that is
visible below its own top, capped at the old 88svh, which is also the fallback. It is measured once,
and again only on a width change, so the URL bar showing and hiding does not resize the map.
2026-09-30 02:32:00 +00:00
Doxa Maps
1fd562c28e Read the page's own direction at mount, not at our first write
The clear-only-ours fix recorded the host's dir lazily, inside the rtl branch,
the first time it wrote. That is correct only while nothing else has written a
direction first. If anything set dir="rtl" on the host before that moment, the
element recorded rtl as "the page's own declaration" and then faithfully
restored it on every switch away from Arabic -- so the direction never cleared,
which is the defect this whole change exists to remove.

Measured on the Big Picture leaf: ar left rtl, and zh put rtl straight back,
while its three siblings with the same code passed. The difference was not the
code; it was that something writes rtl on that host before the element does.

Reading the attribute on the FIRST call instead, before any write of ours, is
the only moment it is certainly the page's and not ours. A page that genuinely
declares dir still owns that decision and still gets it back.

Five cases driven against the real function in all four files: no dir at boot,
a page declaring ltr, a page declaring rtl, an element mounting while already
Arabic, and -- the case that reproduces the failure -- a FOREIGN writer setting
rtl after mount and before ours. Restoring the lazy capture to one file fails
that fifth case alone and leaves the other four passing.
2026-09-29 20:31:04 +00:00
Doxa Maps
d975337501 Push the locale under both keys, because every reader prefers one of them
The push wrote `lang`. Every reader takes `locale` FIRST and only then falls
back to `lang`: ProfileLoader.vue:189, and people-groups-grid.vue at :273 and
:380. So writing one key is correct exactly while no child config carries the
other, and stops working silently the day one does -- a `locale` left by a boot
copy or a host-list path would shadow every fresh `lang` we push, for the rest
of the session, with both values sitting in the same object.

Writing both keeps them in step whatever put them there.

Not broken today: the nested configs were measured carrying no locale key at
all. This closes the class rather than a symptom, which is worth one line.

Shown by driving the real function: with a stale {lang:'en', locale:'en'} the
push now yields ar in both keys, and a config carrying only `locale` switches
too. Reverting to the lang-only write leaves {lang:'ar', locale:'en'} -- the
exact shadowing above, where the map reads English while holding a fresh lang.
2026-09-29 20:31:04 +00:00
Doxa Maps
77415cf361 Switching from Arabic to Chinese left the page right-to-left
Every element that sets a reading direction did it one way: if the locale was
RTL it wrote dir="rtl", and there was no else. So a reader who moved from an
RTL locale to an LTR one kept the right-to-left layout for the rest of the
session -- Chinese laid out backwards, which reads as a broken page rather than
a stale setting.

The reason the clear was missing is sound and is kept: a host page that
declares its own dir owns that decision, and blindly removing the attribute
would take that with it. So each element now remembers what it found before it
first wrote, restores that value verbatim when the locale stops being RTL, and
removes the attribute only when there was none to begin with. It clears its
own, never the host's.

FOUR elements, not the three the report named: big-picture.vue,
research-map-plus-grid.vue and people-groups-grid.vue all carry it, and so does
dashboard-plus-grid.vue -- the Big Picture composite, same shape, found by
checking the neighbours of the named lines rather than only the lines
themselves.

The comment above each one said "Only ever SET, never cleared". Left alone it
would now describe the opposite of the code, so it says what the code does.

Verified two-sided against all four shipped files, driving the real extracted
function: it clears what it set, preserves a host's own dir, and still sets rtl.
Restoring the one-way body to one file fails exactly the first two and leaves
the third passing, with the other three files unaffected.
2026-09-29 20:31:04 +00:00
Doxa Maps
b0d08224c1 A live language switch never reached the panes inside Simple Research
The composite re-pushed its FILTER state to the map and the grid on every
change, and nothing else. So a host switching language moved the composite's own
chrome and stopped there: the outer element received the new profile-config,
the push re-parsed each child's EXISTING attribute, and the boot `lang` survived
every write. Measured on a live en to ar switch: the map and the grid both stayed
English while the frame around them changed.

The panes were never at fault. Each child's own chain is fully reactive -- it is
reactive to its own prop, and its prop was frozen.

The map now carries the locale in the same push that carries the filters. The
grid needs its own line, because its three linked facets travel as separate
attributes while its language travels in profile-config, which this composite
set only when a host supplied a list, and never again. And a locale change now
triggers a push at all: the existing watchers cover filter state only.

The grid write is conditional so an unchanged locale writes nothing. That
matters here more than it looks: setting a pane's attribute makes it re-filter
and emit, and the composite reads those emissions -- which is what the `pushing`
guard above exists for, after a measured round trip once cancelled a selection
the composite had just applied.

Verified two-sided by driving the real pushToChildren against stub elements:
the locale reaches both panes, and a repeat at the same locale adds no write.
Removing the map's line fails that assertion alone and leaves the grid's passing.
2026-09-29 19:30:31 +00:00
Doxa Maps
5e81b05281 check:no-free-identifiers reads internal/ and .mjs, which is where two gates had crashed on load
A commit on 09-25 gave some 30 browser gates `const ROOT = …(_fileURLToPath(…))`.
Two never imported it, and crashed with a ReferenceError the moment they
loaded: check-legend-info-localized (found by hand after four days, fixed
this morning) and library/scripts/posters/verify-posters.mjs, found now by
this change and fixed here. Both run only by hand, so nothing noticed.

The gate read only .vue and .js under plugin-designs/ and library/. It now
reads .mjs as well, and internal/. Scanning them surfaced standard globals
the list lacked (eval, EvalError, Bun, and the window members that
page.evaluate() callbacks use), which are added. A file tagged @one-shot
is skipped: it is kept as a reproduction recipe, not run.
2026-09-29 09:18:26 +00:00
Doxa Maps
c0ffb13f86 check:no-free-identifiers counts __DX_LOCALE_SRI__ as build-bound: the locale plugin prepends it, and its one read is typeof-guarded 2026-09-29 07:31:17 +00:00
Doxa Maps
7d278c3e2a check:no-free-identifiers knows atob and btoa, the two base64 globals every browser and Node 16+ define 2026-09-29 06:32:21 +00:00
Doxa Maps
59c2d1b838 Keep the reason, drop the speaker
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.
2026-09-29 03:46:37 +00:00
Doxa Maps
9b4db40443 Three post-landing gates green again: the library index, the capability table, Path2D
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.
2026-09-29 02:35:35 +00:00
Doxa Maps
3d7ad038a9 A relative share link works when you click it, which is why nobody caught it
DRV1650-SR-EMBED-URL-RUNTIME-ORIGIN. The Driver copied
`research-map-plus-grid/research-map-plus-grid/index.html` from a page at
http://127.0.0.1:5192/, a link that works on no other site.

resolveShareUrl's tier 1 returns its input VERBATIM, correctly: a host handing over a
whole url has already decided. So a composite that BUILDS a tier-1 url takes on tier 2's
job, and both of mine wrote the same thing:

    base ? base + '/' + leaf : leaf

On any host where the tree root cannot be derived from the script url — a dev server, an
ESM build, a tree served from a path that does not match `<name>/<name>.js` — base is ''
and the else branch hands tier 1 a bare relative path, which it then returns unresolved.

★ IT LOOKS RIGHT IN THE ONE PLACE ANYONE CHECKS. A relative share link WORKS WHEN
CLICKED, because the browser resolves it against the page you copied it from. It is the
EMBED that breaks — an <iframe src="…"> on a partner's page resolves against THEIRS — so
the failure is invisible exactly where it is tested and visible only where it ships.
research-map-plus-grid.vue:174 even documents the requirement ("It MUST be absolute,
which is the non-obvious part") four lines above the branch that violates it.

absoluteEmbedUrl() is tier 2's resolution made available to tier-1 builders: the
configured base when known, otherwise resolved against document.baseURI, which yields the
runtime origin. Never relative in a browser. The ladder is unchanged; only its last rung
is.

The gate has both arms, because either alone would rot: the RESOLVER must be absolute
with no base (the failing case, not the passing one), and no plugin may rebuild the
`: leaf` fallback. Negative-tested: removing the origin fallback, and re-introducing the
ternary in a plugin, each turn it red.

"Copy link didn't work" is most likely the same defect rather than a second one —
ShareButton's copy already falls back from navigator.clipboard to execCommand for
insecure contexts, so the copy almost certainly succeeded and produced a useless URL. If
it still fails after this, it is a separate bug and needs a browser to see.
2026-09-28 17:40:34 +00:00
Doxa Maps
379509c5c2 The factory stopped being a one-liner, so a whole bundle stopped being looked at
check:i18n-keys-exist reported PASS over two planted poison keys in gospel-internet,
three rounds running, and over 12 real keys its new panels name that exist in no
locale. Reproduced before changing anything: with both poisons on disk the gate found
1 missing key; it now finds 15 on that same tree — the 2 poisons plus the 12.

TWO CAUSES, EITHER ALONE ENOUGH TO HIDE EVERYTHING.

factoriesIn() demanded the exact shape `export function useX() { return useOptionalT('ns') }`.
useGiT is no longer that. It became a WRAPPER -- `const t = useOptionalT()` and a
returned arrow adding an English-fallback argument -- which is ordinary refactoring and
moved the bundle outside this gate: useGiT was not a factory, so `const T = useGiT()`
was not an alias, so every T('…') was not a call site. Discovery now also accepts an
exported function whose body BUILDS a translator, reading a namespace only when the
call states one literally.

And when a namespace could not be read, `if (ns === null) continue` dropped the binding
in silence. That is the line that turned "I cannot see this" into "there is nothing to
see", and it is why the bundle passed by having nothing looked at. Note what it is NOT:
useOptionalT('') gives ns === '', an author DECLARING no namespace, which was already
handled and counted. Empty is a decision; null was this gate's own ignorance.

Such a binding is now registered for the FULL-KEY pass anyway -- a fully dotted key is
checkable whatever the namespace is, which is what catches both poisons -- while the
BARE calls it genuinely cannot qualify are counted, listed by binding, and FAIL. The
gate states which translators it could not resolve instead of scoring them clean.

Both poisons are pinned as permanent fixtures HERE rather than left in gospel's tree,
so the proof does not depend on another agent keeping deliberately-broken code on main.
The fixture asserts the four things that were independently broken: a wrapper factory is
discovered, no namespace is guessed for one that states none, its binding still reaches
the full-key pass, and a bare key behind it is recorded rather than skipped.

Negative-tested: restoring the silent `continue`, or narrowing discovery back to
one-liners, each fails the self-test; restored, it passes.

NOT wired into gates:pre and left on demand, as it already was. It reports 13 real
missing keys on today's main -- 12 of gospel's panel keys plus a pre-existing
legend.noList -- and those are their owners' to add, not this gate's to hide.
2026-09-28 16:49:01 +00:00
Doxa Maps
1aa9a0ac99 A composite embedded bare dropped tokenUrl, and passed because a ProfileLoader sat above it
PM-tok, PRODUCTION-MATRIX.md:285. `tokenUrl` names a token SERVICE a host points at
instead of pasting a key: the element asks that URL when it was handed no token. Only
ProfileLoader ever read it. A pane composite bakes its children in rather than mounting
them through ProfileLoader, so a composite embedded BARE -- one tag on a partner's page,
which is the entire point of a composite -- dropped a tokenUrl-only config on the floor.

It worked in every place anyone looked, because a ProfileLoader elsewhere on the page
fetched and published the token: the composite was passing an exam somebody else sat.
And the failure wears the wrong face. No token is "No Mapbox access token provided",
0 tiles, no canvas -- identical to a missing `tk`, which IS threaded -- so the natural
reading is that the host mistyped their config rather than that a knob is unread.

The rule lives in library/composables, not in each composite, because it has already
been copied once (ProfileLoader.vue:132) and a third and fourth copy is how one of them
later learns to ask ALWAYS -- which a token service billed per call notices before we do.
The pure decision is split into host-token-url.js with NO framework import, because a
fresh worktree has no node_modules and a module importing vue is unloadable by the gate
that proves it right. Same shape as describeGroup keeping countryName injectable.

check-composite-token-url takes its expectation from ProfileLoader -- the contract a
composite REPLACES -- not from the composites it audits, so it cannot pass by
construction; it exercises the decision rather than grepping for it; and it finds its
subjects by a PROPERTY, a production bundle already forwarding `tk: hostToken`, so a
third composite is covered the day it is written. stack-demo has the same shape and is
excluded by its STAGE, not by name, so promoting it pulls it in automatically.

Negative-tested 5 ways. The fifth found a bug in the gate itself: /tokenUrl/ also matches
`tokenUrlX`, so the check meant to notice the contract being renamed could not fail. It
is \btokenUrl\b now. A guard that cannot fail is the one nobody ever finds by reading.
2026-09-28 15:52:47 +00:00
Doxa Maps
fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
2026-09-28 08:20:21 +00:00
Doxa Maps
e2cebd9130 Shared links keep partner param names, and a CSV export matches multi-value filters
A shared table link can now be written as one query param per facet, and a
param name may map to a different bus key, so links already published on
partner pages keep working. The CSV export matched no rows once a reader picked
a second value in a filter; the default row match now reads a multi-value filter
as OR, and the gate no longer exempts that line.
2026-09-27 18:40:47 +00:00
Doxa Maps
655bbdc561 A filtered table becomes a link anyone can open, and the link now carries the typed search
Card BUILD-SHAREABLE-FILTERED-TABLES, for researchers and media-to-movements teams: narrow the table to
the peoples you are working on, then give that exact view to a teammate or reopen it on a phone.

TWO GAPS, both measured on main before building.

1. IT WAS NOT REUSABLE. The share hash was a PRIVATE function inside
   plugin-designs/research-dashboard/src/composables/useDashboardShell.js, so exactly one dashboard in
   the fleet could produce a link. go-int's board had none — grepped, zero matches — and every future
   kit dashboard would have had none either. Nothing in it was research-specific except its address.
   It is now library/composables/useShareableView.js, parameterized: the bus and labelFor are passed
   in, the filter and search param names are options, and no dashboard is named inside it. The
   research dashboard supplies the one genuinely research thing — labelFor, for the display-only slug.

2. THE LINK WAS INCOMPLETE, AND SILENTLY SO. The hash serialized filterSet ONLY. Typing in the search
   box is the commonest way this persona narrows a table, so a shared link reopened with MORE ROWS than
   the sender saw, with nothing to tell the recipient their set was wider. For population data passed
   between teams that is worse than a link that plainly fails. `q` is appended only when a term is
   present, so a dashboard with an empty search box writes byte-identically the hash it wrote before.

`&` joins params now, so it is reserved in values alongside `;` `:` `*` `,` `%` — otherwise a search for
"rice & beans" would truncate the link at the ampersand.

check:share-policy grows a DRIVEN arm: the two pure functions are lifted from the kit source and RUN,
so nine guarantees are measured rather than asserted — the old format byte-for-byte on an empty search,
slug still display-only, multi-value still dropping the slug, the typed search reaching the link and
coming back, a literal comma in a people-group name surviving, an ampersand in the search surviving, and
both legacy shapes (JSON hashes, and old #f= links with no q) still restoring. No browser needed to
prove a string round-trips.

Source only. Rebuild: research-dashboard, dashboard-plus-grid.

check:filter-multivalue arm C follows the file: it lifted escapeValue/unescapeValue from the shell to
drive the hash round-trip, and said so plainly when they moved ("the hash format moved and this gate is
blind to it") rather than passing vacuously. Repointed to the kit module.
2026-09-27 18:25:01 +00:00
Doxa Maps
199cc61a19 check:no-free-identifiers reads .js modules, which is how the NEUTRAL P1 got past it
The P1 that emptied Big Picture's Religion and Region charts was a free identifier:
useDashboardAggregations.js read NEUTRAL five times and never imported it. This gate exists for exactly
that class and did not fire, because it walked only .vue files. A bundle compiles .js and .vue alike,
so the gate was seeing half of what ships.

Two things were needed, and the first attempt got only one of them. Adding .js to the walker is not
enough: a .js module was then handed to Vue's SFC parser, which found no <script>, and acorn reported
"Unexpected token (2:1)" — the second line of the file's own doc comment. A .js file is its own script
block, so it now parses directly, with offset 0 so reported line numbers are the file's real ones.

VERIFIED BOTH DIRECTIONS, which is the only way to know a gate works:
  - clean main: rc=0, no false positives
  - NEUTRAL import removed again: rc=1, naming useDashboardAggregations.js:173 and :216
So it would have caught the P1 before it reached the Driver's console.

Blast radius measured on main first: exactly 2 findings, both false — DOMException and
createImageBitmap, standard browser globals missing from the known-globals list. Added. No other
owner's file is implicated, and nothing was changed outside this gate.
2026-09-27 06:42:55 +00:00
Doxa Maps
be3ee1cdde Kit 4d-1: the shared search is declared once, which landing 4a meant to do and did not finish
Landing 4a extracted buildSearchAccessors / makeSearchMatcher / filterRowsBySearch into
library/composables/useDashboardSearch.js. It left them DEFINED in useDashboardShell.js as well —
4.3KB of code-identical copies, including the Fuse.js matcher. I compared all three function bodies
with comments and whitespace stripped: identical, byte for byte.

Nothing had diverged, so nothing failed, and that is exactly why it survived: the facet rail ran the
library copy while the shell's KPIs and three panels ran the shell's. The comment that used to sit
above those definitions said the point of ONE declaration is that "the searchable text can never
drift from what the panels show" — and there were two. This tree has already paid for that shape once,
at Jaccard 0.82, with useNeighborT and usePplrT.

The shell now imports them like every other consumer, and DashboardChartsRail, DashboardDataTable and
DashboardMapPanel read '@map/composables/useDashboardSearch.js' instead of reaching through the shell.
No re-export shim: all four consumers are repointed in this commit, so there is nothing for a shim to
serve, and an unretired shim is how three older ones sat in the tree unnoticed until this afternoon.

check:filter-multivalue grows arm E, deliberately beside arm B. Arm B exists because a copied FILTER
predicate lets one panel disagree with the others about what is on screen; a copied SEARCH is the same
hazard with a different name. Arm E asserts the library module declares all three, that the shell
declares none of them, and that no file anywhere imports them from the shell. Its importer sweep walks
plugin-designs and library rather than a hardcoded list, because a hardcoded list of "files that might
import this" is the assumption that let 4a's copies survive.

Groundwork for 4d-4: this removes 4.3KB from the file that has to be split into a generic kernel and a
research composition root, and it removes three exports from the surface that split has to preserve.

Source only. research-dashboard and dashboard-plus-grid need a rebuild.
2026-09-27 06:04:09 +00:00
Doxa Maps
24b9ada48e Plugin Studio and the gallery split production from experimental, from each bundle's own stage
The Driver: a public plugin that is not yet a production map shows under Experimental until it is. A
profile's .meta.json declares "stage": "production" | "experimental" (default experimental; a bundle is
production if any profile says so) and "heavy": true for a live WebGL owner. generate-tree carries both into
manifest.json, so the Studio and the gallery read the same field and neither keeps a list.

The seven production bundles are marked: dashboard-plus-grid, research-map-plus-grid, regions-dashboard,
my-upg-100-list, doxa-simple-map, cities-internet, gospel-resources. The Studio rail keeps its capability
sections for them and puts every experimental bundle (gospel-internet among them) in one final Experimental
section. The gallery's auto-discovered Experimental folder skips a production bundle. gospel-internet-board
joins HEAVY_ELEMENTS (it can own two WebGL contexts). check:studio-rail asserts the split.
2026-09-27 06:04:08 +00:00
Doxa Maps
437e591da8 Arabic set after mount never turned the composites RTL, because the instance was read too late
goal (d). Both composites reflect the reader's direction onto their custom-element host with
reflectDir(), and reflectDir read `getCurrentInstance()` at the moment it ran. That call answers only
while setup or a lifecycle hook is on the stack. reflectDir is called from BOTH:

  onMounted(reflectDir)        -> Vue has set the current instance; this worked
  watch(textDir, reflectDir)   -> nothing has; the read returned null and nothing was reflected

So a host that declared lang=ar BEFORE mount got dir=rtl correctly, and a host that SWITCHED to ar
afterwards got nothing at all. Arabic then rendered in a left-to-right layout — readable glyphs in the
wrong places, which the comment above this code already names as reading like "a styling quirk rather
than the bug it is".

THAT SPLIT IS WHY IT SURVIVED REVIEW, and it is the part worth remembering: the function is not wrong
and neither caller is wrong; the pairing is. Either line read alone is correct code, and first paint —
the thing anyone actually checks — was right. Only the later switch was broken.

The instance is now captured in setup as _selfInstance, which is the fix research-map.vue already
carries for the same failure in emitFilterState (measured there under Vue 3.5.42).

TWO THINGS DELIBERATELY NOT CHANGED, so nobody has to wonder:
- reflectDir still only ever SETS dir and never clears it. That is documented directly above it — a
  host page that declares its own dir owns that decision — so switching ar -> en leaving the attribute
  is intended, not a second bug.
- research-map-plus-grid.vue's onMounted(() => { getCurrentInstance()... }) at :422 is CORRECT and is
  left alone. Vue sets the current instance for the duration of a lifecycle hook, so that read
  resolves. Changing it would be churn, and flagging it would be a gate crying wolf on working code.
2026-09-27 05:05:03 +00:00
Doxa Maps
e514dd8544 check:i18n-keys-exist was blind to a translator that arrives from a composable
go-int planted T('poison_key') behind "const T = useGiT()" and this gate reported the tree clean.
That is the one failure a check is not forgiven: a red gate gets fixed, a green gate that should be
red gets believed, and this is the gate that guards goal (d).

Three independent blindnesses were stacked, and any one alone hid the key:
  1. the file never writes the word tStatic, so the precondition skipped it before anything ran;
  2. useGiT is not tStatic, so the per-file alias discovery found no alias at all;
  3. the alias is PREFIXED, because useOptionalT carries a namespace, while its call sites pass BARE
     keys -- which the full-key pattern cannot match, because it requires a dot.

A translator bound from a composable is now resolved like any other, and the namespace is READ from
the factory rather than listed here -- useOptionalT and useOptionalI18n directly, plus any
"export function useXxxT() { return useOptionalT('ns') }", which is how useNeighborT and usePplrT
are written. That keeps the next author's factory covered the day they write it, which is the same
reason the wrapper NAMES were already read per file instead of hardcoded. Because qualify() prefixes
a bare key and leaves an already-qualified one alone, one binding registers as both shapes; the two
patterns are disjoint, so nothing is counted twice.

MEASURED before and after, with the same poison on disk: the previous version PASSES it, this one
fails it as "neighbor.zzz_not_a_real_key (via nt())". Coverage went 683 -> 743 full-key and 49 -> 56
prefixed call sites, so 67 call sites were being scored as clean without being read. The tree itself
is still clean: no real key was hiding behind those 67, so there is nothing to route to any owner.

A self-test asserts all three fixes SEPARATELY and exits 3 if any one goes blind, because testing
them together would not say which was fixed -- and because this gate had already been trusted once
while lying.

Empty-namespace bare keys stay outside the model and are COUNTED rather than assumed away. Measured
at zero today: all five such sites pass fully dotted keys, so every one is checked by the full-key
pass. The counter stays because the shape is writable.

Gate and description only. No product code, no rebuild.
2026-09-27 04:14:14 +00:00
Doxa Maps
7e04a94c34 Kit landing 4c-3: the facet rail follows the chip bar into the library
The rail's rendering is unchanged. What changed is where its last two plugin imports point. 4b had
already removed the research COLOUR and LABEL tables -- those stay in plugin-designs and arrive on
dashboardConfig.legend -- which left exactly two, and both now read the module that OWNS the
function rather than a re-export:
  useDashboardAggregations -> @map/composables/useFacetAggregation.js   (extracted by 4c-1)
  useDashboardShell        -> @map/composables/useDashboardSearch.js    (extracted by 4a)
Pointing at the shim instead would have been invisible and wrong in a way this tree has already
measured: check:filter-multivalue carries the note from landing 3 that a gate left at the plugin path
read the shim, found no predicate to object to, and PASSED VACUOUSLY.

The dead injects go too. `dataStore` and `searchSourceId` were here only to construct
useTreeLegendColors and useTreeLegendLabels, and 4b removed both constructions. An unused inject is a
claim about the host: a kit component that appears to need a research dataStore is one a new
dashboard will dutifully provide before finding out it was never read. The chip bar shed the same
pair in 4c-2.

check:filter-multivalue and check:legend-mirrors follow the file rather than being left pointing at a
path that no longer exists -- the multivalue gate fails loudly on a missing panel, which is what it
is for, and it is how I would have caught forgetting.

UNLIKE THE CHIP BAR, THIS ONE IS ON SCREEN. 4c-2's component is mounted by nobody, so its gate was
the whole story. The rail is mounted by research-dashboard.vue and is 770 lines of tree, search and
drag behaviour: "the imports resolve" says nothing about whether it still opens four trees by
default or whether its search still narrows. It needs eyes.

The barrel re-exports from the library for one cycle, so no importer's line changes in the same
landing that moves the file. documentation/reference-library-index.md regenerated.
2026-09-27 04:14:14 +00:00
Doxa Maps
bad6991b03 D3: the bus can point at a record without choosing it
SPEC-DASHBOARD-KIT D3, and the last of the three decided-not-built items. The bus
carried two coordinated channels — filterSet NARROWS scope, selectionSet MARKS
individuals — and was missing the third: hoverKey POINTS at the one record under
the pointer. It is how a reader sweeps a table and watches the map answer without
choosing anything, and kepler.gl treats hover as a configured interaction beside
filters for the same reason.

IT IS A PLAIN ref AND NOT A SET, AND THAT IS THE WHOLE DESIGN. Hover fires on
every mousemove across a 2,107-row table. selectionSet had to become a plain Set
with a version counter precisely because a reactive collection fires per element;
a single ref has none of that problem — one dependency, one write, no iteration.
A repeat is a no-op, so an unchanged value never wakes a panel.

HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. The cheap implementation is to
put the key in selectionSet "just while hovering", and every consequence of that
is bad while none is obvious at the call site: useSelectionCamera watches
selectionVersion, so a hover would fly the camera at every mousemove — the
resize-strobe bug wearing a new hat, on a Driver who is photosensitive. The CSV
exporter reads selection > filter > all, so a hover would silently change what an
export contains. The table's selection pill and the shell's live region both
report selection counts, and both would narrate the pointer. So a panel may fade
or outline on hover; it may not fly, select or filter.

NO PANEL IS WIRED, deliberately, and I want that on the record rather than left
to be noticed. D3 says panels opt in, and the first opt-in belongs with a
consumer that has a reason: wiring one into Big Picture speculatively would be a
visual change to the flagship that no reader asked for, landed while the kit
stack is paused. That does leave a channel nothing uses — the same criticism I
made of the unmounted filter chips — so the difference has to be earned rather
than asserted: this is a documented, gated contract with its first consumer
named, not a component that was quietly orphaned. check:hover-channel is what
makes it a contract instead of dead code; the first panel to use it inherits the
rules instead of inventing them.

Three mutants, all caught: routing setHover through selectionSet, dropping the
equality guard, and removing hoverKey from the returned surface.

SOURCE ONLY. Stacked on landing 2. No rebuild needed — nothing renders it yet.
2026-09-27 02:17:35 +00:00
Doxa Maps
15820f1432 Dashboard kit, landing 3: the camera and the CSV exporter
Both are zero-domain in the sense the spec meant: they import only `vue` and read
rows through accessors. Bodies are what research-dashboard shipped apart from the
two parameters the spec named and one policy object, and the old paths re-export
for one cycle.

★ THE FIRST STEP FOUND TWO GATES THAT READ THESE FILES BY PATH, which is why it is
now the first step. Landing 1 nearly disarmed check-facet-cascade the same way.
  internal/check-csv-export-shape.mjs does not import useCsvExport; it READS the
  source and extracts esc() out of it. Left at the plugin path it would have read
  the one-line shim, found no esc(), and failed with "could not extract esc()" — a
  gate accusing a working exporter because its subject moved.
  check-filter-multivalue.mjs — MINE — lists the same file in its PANELS array.
  Left at the plugin path it would have read the shim, found no predicate to
  object to, and PASSED VACUOUSLY. That is worse: a gate going quiet about the
  thing it guards. Both move in this commit.

THE COORDINATE IS A PARAMETER; WHAT MAKES A COORDINATE VALID IS NOT. `coords`
replaces only the READ of row.longitude / row.latitude, which is pray-tools'
spelling and not a universal one. Every guard stays shared: Number() on API
strings, 0 accepted per axis, the exact 0,0 null-island sentinel rejected, the
out-of-range check. A kit that let each caller re-implement that validation would
hand the next dashboard the same four bugs to rediscover.

★ AND THE OPTION WAS DEAD WHEN I FIRST WROTE IT. `coords` was accepted in the
signature and never passed to selectionBounds, so a caller could set it,
type-check clean, build clean, and watch it do nothing. This tree has paid for
that shape before — a defineProps on a component the custom element never binds —
and it is worse than a missing feature because it READS as supported. Nobody files
a bug against a knob they assume they misused. check:kit-options-reachable now
asserts, across all four shared composables, that every option accepted is used.

THE CAMERA'S EIGHT CONSTANTS ARE ONE DECISION, so CAMERA_POLICY exports them
together. The failure that prevents is a second dashboard re-picking them one at a
time and arriving at a camera that feels different from Big Picture's for no reason
anybody wrote down. It is read-only and deliberately not yet overridable: making
each a per-caller option means threading eight of them through every use, and no
consumer has asked for a different camera. Exporting the decision so it can be
cited is the half with a use today; saying so is better than shipping eight
options nobody passes.

THE EXPORTER GAINS exportLabel and sources. "Research Dashboard" was this
dashboard's name, and the provenance line said nothing about where the data came
from — which is what a provenance line is for. Both default to today's output, to
the byte.

★ AND THE NEW GATE HAD THE COMMENT HOLE TWICE IN ONE SITTING. First it searched
the raw body, and the comment beside the fixed call site says "`coords` IS PASSED
HERE" — so the mutant that removed the argument left it green. Then, stripping
comments only per-part, it split the signature's comment PROSE on commas and
reported that useCsvExport accepts `and`. Comments are text; every text search has
to say so, in both directions. That is the fourth variant of one mistake this
week, and all four were caught by running the mutant rather than by reading.

Library index regenerated in the same commit, 103 items. Source only.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid. The
internal/ hunk is one line in check-csv-export-shape.mjs.
2026-09-27 01:16:52 +00:00
Doxa Maps
c58db47ac3 "Arabic" is 23 ISO codes, not 22
rega counted 23 while measuring the language link, and was right. My 22 came
from a cruder comma-split grouping of the labels; the language tier's actual
rule has three arms — exact, "base," prefix, " base" suffix — and selects 23.

Only the prose was ever wrong. Every computed number in the gate and in the
product came from the real rule and has always been 23; these are two comments
explaining it, in two files, one word each. I did not correct them at the time
because the branch carrying them was already in studio's queue, and rewriting a
branch somebody is landing is a worse habit than a stale comment.

A comment that states a measurement is a claim like any other, and this is one a
reader cannot check without redoing the measurement.
2026-09-26 18:38:20 +00:00
Doxa Maps
ca7144a57b The gate learns the aliases instead of being told them, and can read main
A fleet-wide census with this gate as it stood would have been worth nothing, and
worse than nothing once mailed to seven owners. Three things had to be true
first.

IT HAD TO SEE OTHER PEOPLE'S CALL SITES. The first version matched tStatic/$t/t
and found four missing keys while sailing past the ten it was written for. Adding
`T` by hand took it to 22 — and that fix was the same mistake one level up. A
census of the tree finds FOUR wrapper names, not one: `const T` x16, `const L`
x6, `const tr` x1 (library/layouts/VerticalTabsLayout.vue) and `const v` x1. A
gate that knows about T and not tr is blind to whichever bundle used the other
name and reports it CLEAN. So the names are now read out of each file, and the
next author's alias is covered the day they write it. Negative-tested by renaming
T to Tz in a file: all six of its call sites are still found.

IT HAD TO RESOLVE THE PREFIX HELPERS IT WAS DECLINING TO. All six `L`
declarations carry a static prefix — four are `tStatic('dashboard.' + key)`, two
are a ladder `tStatic('controls.' + key) || tStatic('dashboard.' + key)`. Those
are ordinary keys with a known prefix, not runtime expressions, and counting them
as unresolved left most of this gate's coverage on the floor: 49 call sites moved
from unchecked to checked, and the unresolved count fell from 80 to 28. A key
resolves if ANY prefix in the ladder holds it, because that is what the ladder
does at runtime.
★ AND THE FIRST RUN OF THAT RESOLUTION INVENTED A DEFECT. BpResearchHeader
renders `L('reset_all', '') || tStatic('controls.reset_all') || 'Reset'` — the
author saying "if this prefix has nothing, fall to the next rung", and
controls.reset_all does hold it. Reported as missing, that is a gate manufacturing
a problem out of a working ladder — the precise failure I was about to mail seven
owners about. An empty English fallback is the author's own signal that the miss
is intended, and is now honoured.

IT HAD TO MEASURE A TREE IT IS NOT STANDING IN. A census has to be taken against
current main; every bundle but mine is stale in my worktree, so a local scan would
report keys another owner fixed an hour ago. A second checkout was not available
either (the box is at 97% disk), so `--rev` reads the files as blobs out of the
object store: one ls-tree and one cat-file --batch, parsed by length rather than
by scanning for a delimiter that could occur inside a file. No worktree, no temp
copy, no writes.

AND IT HAD TO STOP ITS OWN SILENCE BEING READ AS A PASS. This gate asks whether
the keys a bundle NAMES exist. A bundle that names none has nothing to be missing
and passes without a word — and seven do, including two of mine whose composites
render no chrome of their own. Reporting only the misses would let "0 missing
keys" be read as "fully localized", which is the opposite of true for exactly
those bundles. Participation is now counted and printed beside the result, and
the two are never merged into one number.

THE CENSUS, at origin/main 1a761741: 16 missing keys, and all sixteen are in this
lane — research-dashboard 14, big-picture 1, doxa-research-map 1. Every other
owner is clean. All sixteen are already fixed by this branch's parent, which
PASSES under `--rev`.
2026-09-26 17:28:56 +00:00
Doxa Maps
f79d8f48e2 D1: a filter may be several values, and one function decides what matches
SPEC-DASHBOARD-KIT D1. filterSet[key] may hold an array meaning "any of these";
keys still AND together. The array support is four lines. The reason this is a
landing rather than a patch is the other half.

`a(r) === filterSet[k]` WAS WRITTEN OUT SEVEN TIMES — the shell's filteredRows,
the facet rail twice, the map panel, the table, the charts rail and the CSV
exporter. Seven copies of a one-character rule are survivable while the rule
stays one character. The moment it grows an array case, six of the seven keep
comparing an array with ===, every one of them quietly matches NOTHING, and the
panels disagree with each other about what is on screen. That is not abstract:
the facet rail computes the COUNTS a reader acts on before clicking, so a rail
still using === would print counts for a scope the map and table no longer show.
So the predicate now lives in the bus and every panel calls it. The
de-duplication is what makes the four lines safe.

WHAT A CLICK DOES IS UNCHANGED, deliberately. A plain click still REPLACES. D1
gives the bus the capability; making a rail row additive on an unmodified click
would change what every existing click does to the flagship, which is a product
decision and not this one. The array path is reached programmatically today.

AND THAT PATH IS THE PAYOFF: "Arabic" now narrows the grid. The language tier
covers 22 ISO codes, so no single value expressed it, and the composite had to
send nothing and leave the axis unfiltered — honest, and visibly incomplete,
because the map narrowed and the grid did not. The whole code set now crosses and
the grid ORs it. Verified per tier on the committed fixture rather than on one
example: for all 805 tiers the joined set selects exactly the rows the map
selected.

★ THE SEPARATOR IS `|`, AND MY FIRST DRAFT GOT THAT WRONG IN A WAY WORTH
RECORDING. I joined on `,` and broke label matching outright: 120 of the 864 live
language labels are comma-inverted ("Arabic, Mesopotamian"), so a selection made
BY LABEL was chopped in half and matched nothing. A separator that can also occur
inside a value is not a separator — the rule the URL hash states one file over,
which I had just written and then violated. check:filter-multivalue caught it
before it left this machine, and it caught it because it asserts a behaviour I
was KEEPING (label matching) and not only the one I was adding. Asserting only
the new behaviour would have shipped it.

EMPTY IS NOT A FILTER. An empty array clears the key rather than matching
nothing — a UI deselecting its last chip produces exactly that array, and
treating it as "narrow to zero" is the silent-empty-screen failure the hierarchy
contract already exists to prevent. A one-element array collapses to the scalar
it means, so the hash, the chips and every adapter keep their existing shape
until somebody actually picks two.

THE SURFACES THAT CANNOT SHOW A LIST SAY SO HONESTLY. labelFor resolves each
value and joins them, at the one owner, so the chip bar, the research-filter
chips and the CSV provenance line cannot drift apart. A dropdown shows NO
selection when the key holds several: ticking the first of three would state
something false about the board, which is worse than stating nothing, and the
chip still names every value so the selection is never invisible. The URL hash
carries a list and drops the display slug for it — the slug is one human name for
one code, and there is no honest slug for twenty-two.

check:filter-multivalue runs the real bus, greps the panels for surviving copies
of the predicate, round-trips the hash including a value containing a comma, and
lifts the grid's valueMatches to prove both ends OR the same way. No browser.

Green: check:filter-multivalue, check:language-code-link, check:facet-cascade and
its self-test, check:kpi-unit, check:csv-shape, check:no-free-identifiers,
check:sfc-parse, check:mjs-parse, check:descriptions, check:no-orphan-gates,
check:doc-scripts, check:script-flags, check:quoted-scripts.

SOURCE ONLY. Stacked on kit landing 1, which moved the bus into library/.
STUDIO: needs a rebuild of research-dashboard, dashboard-plus-grid,
doxa-research-map, research-map-plus-grid and people-groups-grid. package.json is
main's copy plus two lines beside check:fits-screen.
2026-09-26 17:28:46 +00:00
Doxa Maps
44a1afba04 Decouple prose: no go-base / nuxtbase / nuxtinator / doxa-base names in plugin comments and docs
Comments and prose only, no code change (DECOUPLE-go-base-mentions.md sections B and C, outside
library/ and README.md): stack-demo's page-flow note becomes 'a normal host page', the share-link
and CORS comments say 'the live test host', embeddability.md's Traefik note says 'a self-hosted
reverse proxy' and drops the internal proxy file path, and how-to's hosting option becomes 'a Git
host you run (for example Forgejo)'. stack-demo.vue's @description feeds the generated tree, so the
landing regenerates the tree. Section A (QA widget), library/ and package.json are left to studio.
2026-09-26 17:28:45 +00:00
Doxa Maps
7f9b0d8983 Twenty-two keys the code asks for that no catalogue has ever had
A localized CALL SITE is not a localized STRING, and nothing in this tree was
watching the join between the two.

The dashboard's five resize handles read their names through
T('aria.resizeFiltersRail', 'Resize filters rail'). Reviewed, that looks
finished: the call goes to the catalogue and the English is only a fallback.
aria.json held eleven keys and not one of them was a resize key. Every handle
had been announcing itself in English in all eleven locales, and the fallback —
which is right to exist, because a missing key must degrade to readable English
rather than to "aria.resizeFiltersRail" on screen — is exactly what kept it
silent.

No existing gate can see this. check:i18n-parity is satisfied because the key is
missing from ALL locales equally. check:english-leaks and
check:composite-locale-leaks both need the catalogue to HOLD the string before
its appearance counts as a leak, and it does not. check:ui-strings-have-keys
reads templates for typed English. Each is looking at a different end of one
wire. check:i18n-keys-exist asks the question none of them asks: does the key
this code names exist?

It found TWENTY-TWO on its first run, all in this lane — the detail drawer's
five rows, the table's two selection toggles and their hints, the map's loading
line, the breadcrumb, the charts rail and the map legend's aria labels, the
empty-search message, and big-picture's footer. Six of them are the resize keys
on this branch's parent; seventeen are added here, English wording kept exactly
as each call site already reads it, in all eleven locales.

★ THE FIRST DRAFT OF THE GATE WOULD HAVE MISSED THE DEFECT IT WAS WRITTEN FOR.
It matched tStatic/$t/t and reported four missing keys while sailing past the ten
resize keys, because the house pattern is a file-local alias —
const T = (key, en) => tStatic(key) || en — and almost every call site in the
dashboard reads T(...). Matching T( took it from 4 to 22. Prefix helpers like
BpResearchHeader's L(), and any concatenated or template-literal key, are COUNTED
as unresolved and never guessed at: inventing the key set a runtime expression
might produce is how a gate starts reporting keys nobody wrote.

AND BIG-PICTURE, the last HOST-LOCALE failure fleet-wide, gets the two halves it
was still missing. Its title was a hardcoded English literal with no key at all —
the largest text on the element, sitting above an Arabic grid. And it never
reflected `dir`, alone among its siblings, so a host asking for Arabic got Arabic
WORDS in a left-to-right layout: a bug that reads as a styling quirk, which is
why it outlived every locale fix around it. The attribute goes on the
custom-element HOST, because `dir` takes effect through the inherited CSS
`direction` and inheritance crosses shadow boundaries — one attribute covers this
element's chrome and both nested elements beneath it. Only ever set, never
cleared: a host page that declares its own dir owns that decision.

The gate is green with this branch and its parent together, and red without
either. Negative-tested: deleting a key that exists fails naming the key and the
call site; a first argument whose area is not a catalogue file is correctly not
reported.

STUDIO: no rebuild needed for the catalogue, but big-picture must be rebuilt.
package.json adds two lines beside check:i18n-never-translated — a different
region from sr/dashboard-filter-a11y and sr/language-code-link, so the three
merge.
2026-09-26 17:10:06 +00:00
Doxa Maps
f3fcce9386 "Arabic" emptied the grid, because no people group speaks "Arabic"
rega, on MapLibre, in BOTH locales: Languages > Language > "Arabic" sent
map-filtered with {language:"Arabic", languageCode:""}, the composite handed the
grid an empty code, and 2,107 rows became 0. An empty grid reads as "no
results" — the worst shape a wiring bug can take, because the reader believes
the data.

ONE LINE, AND IT WAS MINE. The emitter looked for a row whose language LABEL
equalled the selected name. A language tier's label is not a label any row
carries: the legend builds family > language > dialect and derives the language
tier by splitting the API label at the comma. Measured on the live source —
2,107 rows, every one carrying primary_language as {value,label} — there is NO
row whose label is "Arabic". There is "Arabic, Sudanese", "Arabic, Levantine",
"Arabic, Gulf", twenty-two codes in all. The exact match could never hit, in any
locale, which is why it failed identically in en and ar and why the locale fix
that prompted this code was right about the disease and wrong about this line.

This is the derived-value trap one tier down from where I last wrote it up: a
value that crosses a component boundary has to exist on a row. "Arabic" is a
grouping the legend invented; primary_language.label is a field.

So the rows are now matched the way the legend's own node matches pins — base,
"base," prefix, " base" suffix — rather than by a fourth hand-typed spelling of
a rule that already exists in two places. 61 tiers resolved an empty code
before; "Arabic" was one of them.

AND ONE CODE ONLY WHEN THERE IS ONE. "Arabic" means 22 ISO codes, so no single
value expresses it. Emitting one would filter the grid to a fraction of the
selection while looking correct; emitting the tier NAME is what showed zero. The
honest answer is to emit nothing for that axis and leave the grid unfiltered —
the map is narrowed, the grid is not, and the reader can see both. languageCodes
carries the whole set alongside, so this becomes a real narrowing the day
filterSet goes multi-value (SPEC-DASHBOARD-KIT D1) rather than a rewrite.
Measured on the fixture: 764 tiers now resolve one code and filter the grid; 41
cover several and abstain.

A DIALECT IS A LANGUAGE SELECTION TOO, and it emitted nothing at all — the
watcher never observed selectedDialect, so drilling from "Arabic" to "Arabic,
Sudanese" changed the map and left the grid reporting the tier above. Its
originalLabels are exact API labels, so it is the one unambiguously resolvable
case.

check:language-code-link, headless, no network at run time. It LIFTS the
emitter's match rule and the composite's gridLanguage() out of the shipping
source rather than carrying its own copy — a gate with its own copy of the rule
passes while the product is broken, which is exactly how this survived a green
check:linked-vocabulary. It proves the emitter's predicate and the legend's
filter expression agree on every (label, tier) pair, that every tier selects at
least one row, and that an ambiguous tier narrows nothing. The fixture is the
864 distinct label/value pairs from the live source, deduplicated rather than
sampled so the counts above are about the whole set. Four mutants; reverting the
emitter to the exact-label rule reproduces rega's finding exactly — 61 tiers
selecting zero rows, "Arabic" named first.

STUDIO: needs a rebuild of doxa-research-map and research-map-plus-grid.
2026-09-26 13:35:57 +00:00
Doxa Maps
6c804159c3 Three browser gates stop passing hollow or racing (rega's gates:post run at ded4b3c1)
- check:dashboard-geocoder-provider: 0 search bars on BOTH engines. Its default leaf (research-map) hides
  the bar under MapLibre on purpose, and the tokenless Mapbox run could not draw. It now defaults to
  doxa-simple-map (a bar on both engines), and the Mapbox run gets a host token: TEST_MAPBOX_TOKEN, or a
  well-formed fake whose token-carrying requests are aborted, never sent.
- check:composite-embed-knobs: every tk case SKIPPED (no baked token since the auto engine), so every
  provider case went UNMEASURED and the gate still PASSed. It takes TEST_MAPBOX_TOKEN, and a run where no
  case measured anything exits 75 NOT MEASURED instead of PASS.
- check:linked-vocabulary: it attached its groups-filtered listener after goto and could miss the grid's
  first emit ("the grid never reported a total"). The totals are now recorded from page start
  (addInitScript).
Not browser-run here (browsers are rega's): rega's next gates:post pass is the proof.
2026-09-26 13:35:57 +00:00
Doxa Maps
420c36a405 ui-strings baseline: gospel's resources-map types the pin note's first sentence, which now has a key
The shared approximate-pin note (pins.approximate) turned gospel-resources' own hardcoded copy of the
same sentence into 'has a key but typed in'. Recorded as known debt for gospel-plugin to switch to
ApproxPinNote; no string was added.
2026-09-26 13:04:16 +00:00
Doxa Maps
fef8c7b0fd The last of the shared-library English: legend, poster preview and tab loading states
Third and final batch of the shared-library strings, in the shepherd's order:
SemanticTreeLegend (4), PosterPreview (2), PosterWall (2) and
VerticalTabsLayout (2), plus PosterWall's "people groups" which already had a
key and was still typed in.

These are all LOADING and OVERFLOW states — "Loading data…", "Showing top 500 of
2,107", "use the geocoder above to find the rest", "Loading map snapshot…",
"preview unavailable", "Could not load X". Text a reader meets exactly when
something is slow or has gone wrong, which is the worst moment to be handed a
language they do not read. PosterWall's own source called one of them "i18n
DEBT" in a comment; it is paid now.

The counted ones take a placeholder rather than being split around an
interpolation — legend.showingTop is "Showing top {n} of {m}" — so a translator
can move the numbers within the sentence. Languages do not agree about where a
count goes, and a sentence assembled from fragments cannot be reordered at all.

VerticalTabsLayout needed a different shape: it is an options-API component, so
a module-level helper compiles fine and then renders nothing, because its
template can only see what the component returns. T is exposed from setup().

★ THE NEW GATE CAUGHT SOMETHING I WOULD HAVE MISSED, on its first real batch.
Adding these keys flipped two strings in
plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue from "no
catalogue entry" to "has a key and is still hardcoded" — that file is a COPY of
the library component I just fixed, carrying the same debt. It is another lane's
plugin so I have not touched it, and it is reported rather than quietly
baselined away. The baseline is refreshed to today's truth so the gate guards
the new line: 285 → 273 without a key, and the two copies recorded as known debt
rather than as a regression.
2026-09-26 13:04:16 +00:00
Doxa Maps
e9f5edf79d A gate for English typed into a template, so the ground we have won stays won
check:composite-locale-leaks asks a good question — "does the catalogue
translate this string, and is the page showing English anyway?" — but its
criterion is that the catalogue already HOLDS the string, so a string nobody
ever added is invisible to it by construction. And it runs in a browser, so it
only ever sees what happens to be on one screen.

That blind spot was measured, not guessed. After both composites passed the leak
gate with zero findings, a source audit of the same two trees found 31 more
user-visible strings. Most were aria-labels, titles and tooltips: text only a
screen-reader user or a hovering mouse ever meets. Our localization verification
had been "what can I see", and the blind spot was exactly the readers who most
need the words.

TWO CLASSES, because they are different defects with different fixes:
  NO KEY      wording must be written and translated — sometimes the Driver's call.
  HAS A KEY   the translation already ships in eleven locales and the reader is
              shown English anyway. The cheapest defect in the catalogue: the
              work was done and wasted. Nobody was looking for this one; it only
              appeared because the report was split in two.

★ WARN-ONLY AGAINST A BASELINE, DELIBERATELY. Failing on the whole backlog — 285
without a key, 29 hardcoded-with-a-key across 126 components — would make it
unlandable and it would be switched off within a day. It fails on what is NEW, so
the count can only fall; --update refreshes the baseline when a batch lands.

QaCorner is excluded BY NAME, with the reason in the file: an opt-in developer
widget that studio is making host-neutral or removing. Eleven locales for a
corner only we open is not worth the translations, and a judgement like that is
better written down than left as a silent gap.

NEGATIVE-TESTED ON BOTH CLASSES, because a gate never seen to fail is not
evidence: a new text node and a new title attribute are both caught and named;
an aria-label hardcoded to "Reset all" — which already ships in every locale — is
caught as the second class with the fix stated. Restored, it passes.
2026-09-26 13:04:15 +00:00
Doxa Maps
b6abe30698 Link language on the ISO code, not the display name, because the name moves with the locale
Localizing the grid's data (ae5e27c3) would have broken the language link
silently, and the shepherd asked me to prove the coupling with a test. Measuring
it first showed the coupling should not exist.

MEASURED against the live API, en vs ar, 2,107 records:

    primary_language  LABEL  864 names in en, 864 in ar, ZERO shared → localized
    primary_language  VALUE  864 codes, ALL 864 shared               → stable
    religion / wagf_region VALUE also identical across locales

So the fix is not "make both halves fetch the same locale and test that they do".
It is to link on the form that does not move. Region already links on a slug and
religion on a family letter; language was the one facet still travelling as a
display name.

The map now emits `languageCode` alongside `language`, the composite carries
{value, label} for language as it always has for region, the GRID is driven by
the code (valueMatches compares the normalized value as well as the label, so a
code matches in every locale) and the MAP is still driven by the name, because
its legend selects by name. Each half gets the form it can actually use, and
neither depends on the other's locale.

★ THIS TREE HAD ALREADY PAID FOR THIS ONCE. deriveLanguageFamily in useMapData
carries a comment about the 2026-06-22 locale colour bug: deriving a family from
the localized label collapsed every pin onto one bucket, and the fix was to use
primary_language.value. Same trap, one facet over, three months later.

check:linked-vocabulary now runs per LOCALE as well as per provider — en as the
control, ar as the case that would have broken in silence — and asserts the
language facet with a poison case (a language that cannot exist must NOT narrow
the grid). It is a browser gate, so rega runs it; I have not.

★ AND check:no-free-identifiers CAUGHT MY OWN BUG IN THIS COMMIT on its first
outing: I wrote `rows.find(...)` in the emitter where no `rows` was in scope. The
gate named the file and line before anything was built. That is the class of
mistake that broke both composites yesterday, found in seconds this time.
2026-09-26 08:07:39 +00:00
Doxa Maps
e3d25ff6db The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
  profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').

- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
  in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
  to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
  counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
  (71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
  The default list returns the same array, unfetched. A list whose membership cannot load shows no
  groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
  their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
  Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
  are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
  the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
  thread `list` to their children like `provider`.

Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.

Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
2026-09-26 05:16:02 +00:00
Doxa Maps
2a26aefff1 A gate for the mistake I shipped: a name used but never bound
I broke both composites on main. Editing them with a pattern-match script, I
replaced an import line by matching text that had already changed, so the
replacement silently matched nothing and three names — RTL_LOCALES,
getRuntimeLocale, detectLocale — were used and never imported. Setup threw a
ReferenceError and both elements rendered nothing until a hotfix landed.

★ IT PASSED EVERY GATE, AND THAT IS THE INTERESTING PART. It compiled, because a
free identifier is valid JavaScript until it RUNS. It passed my grep of the
built bundle for the new code, because the code was genuinely there. It passed
template-literal, sfc-parse, composite-freshness and SRI, because not one of
them evaluates a setup function. The evidence I had could not distinguish a
working composite from a dead one, and I called it "verified headless".

So this asks the one question those gates cannot: is every name a component uses
actually bound — imported, declared, a parameter, or a known global? It reads
the same source the compiler reads, tracks real scopes (module, function, block,
catch, class), and runs in milliseconds with no browser. Fleet-wide, over every
.vue in plugin-designs/ and library/, because the mistake was a text-editing
habit rather than anything about one plugin.

WRITTEN BY RUNNING IT, which is the only way an allowlist gets honest. The first
run reported 4,259 findings — every one a false positive from a real bug in my
own walker: a helper that takes a node TYPE was being handed the NODE, so the
function branch never fired and every parameter read as unbound. The second run
found the globals this tree genuinely uses (d3, customElements, ShadowRoot, CSS,
Function, self, the typed arrays) and vite's build-time defines. The third found
a real gap in the gate: an SFC may have BOTH a plain <script> and a <script
setup>, and they share a scope — LegendTools exports a function from one and
uses it in the other, which I was reporting as free. Reading only one block
would have taught the fleet to ignore this gate on its first run.

NEGATIVE CONTROL, because a check that has never been seen to fail is not
evidence: restoring the broken import makes it FAIL naming all three names and
their lines; restored, it passes across 127 components.

It is the cheap half of a pair — it proves a name is bound, not that the thing
runs. The expensive half is a check that actually mounts a composite.
2026-09-26 04:21:49 +00:00
Doxa Maps
57009b7e28 Big Picture and Simple Research mount again: import the three locale names they use
08f89f55 gave both composites a text-direction computed that calls
getRuntimeLocale(), detectLocale() and RTL_LOCALES, but imported none of them.
The build does not check free names, so the bundles on main reference three
undefined globals. watch(textDir, reflectDir) evaluates the computed during
setup, so setup throws a ReferenceError in every locale, and Vue renders
nothing for the whole composite: the map, the dashboard and the grid.

Reproduced headless with the same computed + watch shape under
@vue/runtime-core: lang '' fails with "getRuntimeLocale is not defined", and
lang 'ar' with "RTL_LOCALES is not defined"; neither renders.

After this import, both bundles build and neither contains a free reference
to the three names.
2026-09-26 03:02:46 +00:00
Doxa Maps
08f89f55b3 Big Picture's header speaks the reader's language, and both composites turn round for it
TWO DEFECTS FROM THE UNIVERSAL-PROPS MATRIX, and they are the same defect at two
levels: a component can be perfectly localized and still show English, and a
page can be perfectly translated and still be laid out backwards.

★ THE HEADER HAD NO i18n AT ALL. Every label at the top of Big Picture was an
English literal passed as a PROP — FilterDropdown translates nothing, it renders
what the caller hands it — so Region, Bloc, People Group and Religion sat in
English above an Arabic page. They now read the catalogue, grid namespace first
because this header drives the cap-2 grid and its sibling reads the same keys.
Reset uses controls.reset_all, so its English becomes "Reset all": an existing
entry rather than invented wording, and arguably more accurate, since it clears
the search too.

Its search placeholder and two aria-labels have no usable entry — the nearest,
grid.search, drops "or countries", which this search actually covers — so they
are on the DECIDE card rather than quietly reworded. They are invisible to
check:composite-locale-leaks by design: its criterion is that the catalogue
already holds the string, and it does not.

★ RTL IS A DIRECTION, NOT A TRANSLATION. createAppI18n reflects an RTL locale
onto document.documentElement, which is right for a leaf page it owns and
useless for an element dropped into somebody else's page — the case these
composites exist for. A host asking for Arabic got Arabic WORDS in a
left-to-right layout: readable glyphs in the wrong place, which reads as a
styling quirk rather than the bug it is.

Both composites now set `dir` on their custom-element HOST rather than on the
layout inside it, and that choice is the fix: `dir` takes effect through the
inherited CSS `direction`, and inheritance crosses shadow boundaries, so one
attribute covers the composite's own chrome AND every nested element beneath it
— including the grid half the audit found bare. Only ever SET, never cleared: a
host page that declares its own dir owns that decision.

VERIFIED HEADLESS: three bundles build; setAttribute("dir","rtl") is present in
both composites and controls.reset_all in big-picture; freshness and SRI clean,
nothing else stale; gates:pre 6/6. NEEDS EYES: Big Picture and Simple Research
at ?lang=ar — header labels in Arabic, and the layout actually mirrored.
2026-09-26 00:13:46 +00:00
Doxa Maps
b5d849f746 The map follows the list: filter the grid and the camera goes there
The link ran one way. A choice on the map narrowed the grid — region, religion
family and language, verified on both engines — and a choice in the grid moved
nothing. A reader who filtered the list down to 83 peoples had no idea where in
the world they were.

★ IT IS THE GRID'S FILTERED SET, NOT A ROW SELECTION, AND THAT IS A FINDING.
The card says "selecting N rows". people-groups-grid has no row selection — no
checkboxes, no selection state — and the desktop pair's fly-to-selection is
driven by the DASHBOARD's own data table through its selection bus
(useSelectionCamera.js), not by this grid. So building "select rows" here would
have meant inventing a control nobody asked for. The grid already reports the
slugs it is showing on every `groups-filtered`, so that existing signal is the
wire: no new UI, and a grid mounted alone still emits into a room with no
listener.

The composite sends slugs rather than a bbox because it has no coordinates —
this map is the half that knows where anything is, so it frames them itself.

CAPPED AT 400, and the cap is the point rather than a performance worry: a
larger set is not a selection, it is "almost everything", and framing it would
swing the camera to a world view that tells the reader nothing while moving a
lot.

CAMERA POLICY COPIED, DELIBERATELY, from research-dashboard's
useSelectionCamera.js, which worked all of this out for the desktop pair: a
single row has a zero-area bbox and would fit at street level for what is really
a country-scale centroid; several peoples in one place are the same problem by
another route; a set spanning most of the globe animates the wrong way around
the antimeridian if framed literally. One shared composable in library/ is the
right end state — cited rather than imported because a library move cannot be
landed from this lane today, and a later move should delete this block rather
than leave a third copy.

⚠ MOTION SAFETY. The Driver is photosensitive, and a camera that re-animates on
every keystroke is the strobe class this repo has already paid for. An identical
slug set never re-flies, and a reduced-motion preference jumps rather than eases
— same destination, no traversal.

VERIFIED HEADLESS, per the browser policy: both bundles build, `fitSlugs`
appears in the sender and the receiver, SRI and composite-freshness are clean
and no other bundle goes stale. The behaviour itself NEEDS EYES.
2026-09-25 23:28:53 +00:00
Doxa Maps
29fc335b00 Prove the grid-under-map link on both engines, not just the one we happened to run
BUILD-INTEROP-CONTRACT-GRID-UNDER-MAP-BOTH-PROVIDERS asks for a provider matrix:
the same assertions, once per engine, with identical counts. Interoperability
that holds on one provider and not the other is not interoperability.

It is not a formality here. The map half of this link resolves a religion family
through its own legend, and the legend is drawn by the map engine — MapLibre
drops an entire layer in silence for one misplaced paint property, which would
surface as a link delivering nothing while the Mapbox run stayed green.

So the gate now runs the whole measurement under ?provider=mapbox and
?provider=maplibre, and then asserts the two agree: the same starting total, the
same rows for the same family, and the same rows over the composite's wire. A
per-engine pass with different numbers would mean the same choice narrows to
different peoples depending on which engine a partner embedded.

MEASURED, identical to the row on both:

    mapbox     2,107 groups · Buddhism 83 direct · 83 linked · mutant 0
    maplibre   2,107 groups · Buddhism 83 direct · 83 linked · mutant 0

The mutant still runs first on each engine, so a green here still means the gate
can tell a right mapping from a wrong one — twice over now.
2026-09-25 22:35:48 +00:00
doxa-maps-88655c70
412602cdef Five more gate scripts read the tree's name from the build config
The research verify-* scripts and research-map-plus-grid's two gates hardcoded 'doxa-maps' (11
literals); they now import TREE_NAME from internal/_tree-name.mjs like the rest of the tooling.
2026-09-25 16:25:22 +00:00
doxa-maps-88655c70
5639dd1ccf Let a host set the language of a whole page, not half of it
Embedded with profile-config {"locale":"ar"}, these dashboards came back half
translated: the map's six tabs in Arabic and the grid's four dropdown labels in
English, on the same screen. That reads as "some strings were never translated",
and it sends whoever investigates to the catalogues — where every one of those
strings is already present and correct.

Two readers, one locale, and only one of them was being told.

  t()      — vue-i18n, follows the plugin's locale, which ProfileLoader already
             drove from the host's config.
  tStatic  — the plugin-free reader that components mounted as their own custom
             element must use (useI18n() throws at a custom-element root and
             takes the subtree with it). It resolved the locale from the URL and
             the document, which are the only two things a STATIC page can say.

So a locale set at RUNTIME moved every t() string and no tStatic string. Now the
i18n instance publishes its locale whenever it changes — one choke point, so
ProfileLoader, the SPA's picker and the studio's picker all work without any of
them remembering to tell the static reader as well.

★ ON THE WINDOW, NOT IN THIS MODULE, AND THE FIRST VERSION GOT THIS WRONG.
A composite loads SEVERAL bundles — research-map-plus-grid loads its own,
doxa-research-map's and people-groups-grid's — and each carries its own copy of
library/i18n. A module-level variable set inside the map's copy is invisible to
the grid's copy, so the first attempt translated the map and left the grid in
English: the very defect it was written to remove, reproduced one layer down.
Measured, then moved to a window-level store, the way the shared map token has
always crossed bundles. Module state is per-BUNDLE; a locale is per-PAGE.

Both composites also publish the locale themselves, because a composite sits
ABOVE every ProfileLoader on its page rather than under one.

MEASURED with check:host-locale-contract, on c7c2050d and on this commit:

    dashboard-plus-grid/dashboard-plus-grid      32 of 2121  ->  2121 of 2121
    research-dashboard/research-dashboard        75 of 1891  ->  1891 of 1891
    research-map-plus-grid                      489 of 1201  ->  1201 of 1201
    whole fleet                                  18 failing  ->     1 failing

The one left is big-picture/big-picture, which installs no i18n and mounts no
ProfileLoader at all — a different shape of gap, and not this lane's bundle.

Every bundle was rebuilt; composite-freshness and SRI are clean.
2026-09-25 14:26:02 +00:00
doxa-maps-88655c70
471bd05cea Stop showing English where the catalogue already holds the translation
A reader on an Arabic page saw a handful of English words among the Arabic:
the table's "People Groups" title, its count line, the Engagement column, the
Gospel Access chart title, and the side drawer's close button. Every one of them
had a correct Arabic entry sitting in the catalogue; nothing looked it up.

THE CHECK CAME FIRST, AND IT REPLACES A SWEEP THAT PASSED A BROKEN TREE.
The old method sorted every rendered string into third-party / data / chrome and
counted only chrome. That split is a guess, and it guessed wrong exactly where
the defect lived: single words like "Region", "Prayer" and "Country" are as
plausible as values as they are as labels, so they scored as data and the sweep
reported zero on a page that was visibly leaking.

check:composite-locale-leaks asks a question with an answer instead:

    the English catalogue holds this exact string as a value, AND
    this locale holds a DIFFERENT value for that key, AND
    the page still renders the English one.

No judgement about what kind of word it is — if the translation exists and the
reader is not getting it, that is the defect. The two surfaces a lookup cannot
judge (vendor map chrome, and table/facet cells carrying API values) are muted
by NAME with a stated reason, never by word, so muting one can never hide the
next real leak that happens to reuse it. The gate failed on this tree naming 9
strings before any of them were fixed.

Also threaded here: both composites now accept `lang` / `locale` in
profile-config and pass it to the children they mount, alongside `tk` and
`provider`. A partner writing {"lang":"ar"} reached nothing before — the knob
was documented and read by nobody.

The prayer facet's label in DashboardResearchFilters is now a catalogue getter
too. Measured before touching it: dashboard.config.js does define prayer_level
and FACETS spreads the config last, so that literal never rendered for this
config — it is a default for a config that omits the facet, and it stays, with
the merge order written down so the next reader need not re-derive it.

Four strings remain English with no catalogue entry at all — "Top countries",
"Select all {n}", the table dock's title, and the singular "group". They need
wording in ten languages that cannot be composed from existing entries, so they
are a DECIDE card for the Driver rather than translations invented here.
2026-09-25 12:16:59 +00:00
doxa-maps-88655c70
16629f4906 Link religion and language between the map and the grid, by the one table they already share
A reader who picks a religion family on the map now sees the grid narrow to the
same peoples, and a language selection travels the same way. Region already
linked; these two did not, because the two halves do not name a religion the
same way and nothing yet proved what the honest translation was.

The map filters religion through its legend's flat tab, keyed by the FIRST
LETTER of the religion code ('B'); the grid filters through a derived axis that
runs library/colors/religion.js forward per row ('BLA' -> 'Buddhism'). One
shared table, read in opposite directions, copied by neither half — so the wire
carries the family NAME and each side resolves it against its own data.

Two measurements changed the design on the way:

  - mapStore.filters.religions, which the map's filter panel writes, is read by
    NOTHING: the mapLayers.applyFilters() it was meant to reach is called and
    never implemented anywhere in the tree. A link routed through it would have
    set state, passed a smoke test and dimmed no pin.
  - the map's legend selections are mutually exclusive by design, so it can
    light exactly one of region/religion/language. The composite mirrors that
    rather than letting the grid hold three facets the map cannot show.

Religion links at the FAMILY and not at the code, deliberately: a map cannot
distinguish 'BLA' from 'BMA' — both dim as Buddhism — so linking at the code
would promise a precision one half cannot keep.

check:linked-vocabulary proves it by measurement rather than by reading: it
compares the total reached over the composite's wire against the total the grid
gives for the same facet on its own (83 of 2,107 both ways), and runs the MUTANT
first — the legend key 'B' instead of 'Buddhism' — failing as NO-TEETH if the
wrong mapping scores the same as the right one. Measured: mutant 0, real 83.

The CLI doc's apk:debug quote was realigned in passing; package.json had gained
app:start-page and the doc still described the older chain.
2026-09-25 10:20:27 +00:00
doxa-maps-88655c70
7f700935c5 Simple Research + Big Picture: the host's style stops at the box, and the last English goes
TWO FIXES THAT BOTH SHOW UP ONLY WHEN THESE PLUGINS ARE SOMEBODY ELSE'S GUEST.

1. ATTRIBUTE FALLTHROUGH. These SFCs mount AS custom elements, so a style the
   host puts on the tag lands on the component's own root element. MEASURED with
   a host style of "display:flex; position:relative; height:100%": that exact
   string appeared verbatim on the inner root div of people-groups-grid,
   stack-demo, research-map-plus-grid and dashboard-plus-grid. The gallery sets
   one on every tab, and the same fallthrough collapsed the Prayer Walk Planner
   to 76px. inheritAttrs:false on each root — a host styling the TAG is styling
   the box, not our internals.
   AFTER: the inner root carries no inline style on any of the four, and each
   still renders full height (2997-4010px in the harness), so the guard did not
   become the collapse it prevents.
   Worth knowing for the next probe: shadowRoot.firstElementChild is a <style>
   node — Vue injects styles first — so a probe that trusts it measures nothing
   and reports "no fallthrough, nothing to fix". Take the first non-style child.

2. THE RESIDUAL CHROME the in-gallery re-measure found: the dashboard title,
   Align Dashboard and its tooltip, World, Vertical Legend, groups in scope, the
   per-row aria templates (Filter by / Remove filter / groups), and the map
   drawer's aria label. Catalogued in all 11 locales and read through tStatic,
   reusing existing wording where the catalogue already had the word.

The aria TEMPLATES matter more than their size suggests: they repeat per row, so
one untranslated template is dozens of English strings in a screen-reader's ear.
2026-09-25 08:19:56 +00:00
doxa-maps-88655c70
03b838e16d Simple Research: one filter state, so the two halves move together
The composite showed a map and a grid that ignored each other: filtering the grid
to Africa moved its own count 2,107 → 315 while the map beside it kept every pin.
The composite now owns ONE filter state; the children report theirs upward as DOM
events and accept ours downward as attributes, so each stays independently
embeddable — a child mounted alone hears nothing and is handed nothing.

MEASURED on a served built tree, region facet:
  grid → map   2,107 → 315, and the map's own state follows (externalRegion and
               selectedRegion both 'africa'); clearing returns both
  map → grid   the map's map-filtered contract → the grid's list 2,107 → 315;
               clearing returns it
  standalone   grid alone 24 rows / Total 2,107 / 0 errors; map alone canvas 1 /
               0 errors — unchanged
  MUTATION     disabling the composite's push leaves the grid filtering itself
               (315) while the map stays empty, and kills the reverse direction:
               the check fails when the link is broken

Four things this cost, each of which first looked like it worked:

1. StackLayout applies `attrs` ONCE at create — its documented contract. The
   first version recomputed `sections`; the composite adopted the filter and the
   map's attribute in the DOM never changed. A computed nobody re-reads is not a
   channel, so the state is now PUSHED onto the mounted children.
2. The echo cancelled itself. Composite pushes → the grid re-filters → it emits
   groups-filtered with activeFilters still EMPTY → the composite adopts that as
   "the reader cleared it" and wipes what it just applied. Both handlers were
   correct; the round trip undid itself in under a second. A push is not a user
   action, so the push window ignores children.
3. The grid ignored host filters BY DESIGN when its own dropdowns are visible:
   activeFilters picks local OR props, never both. Right for the desktop
   composite, which hides them; silently wrong here. A host facet now seeds the
   LOCAL state, so the map's selection appears as a chip in the grid's own bar
   and the reader can clear it there.
4. That seeding called lbl(), which is local to another computed — "lbl is not
   defined", watcher dead, attribute set, list unchanged. Visible only after the
   probe was widened to console.error. A silent watcher failure looks exactly
   like a feature that was never wired.

SCOPE, DELIBERATE: region only. The map filters religion and language by the
canonical strings its own data carries; the grid filters them by slugs from a
different table. Linking those means inventing a mapping, and a wrong link is
worse than an honest gap.

The map also gains a verification handle (window.__researchMapFilterState), the
same contract as __rdashMap and __grMap: a linked filter is a STATE claim, and
every DOM proxy for it was either tab-specific or a selector guess.
2026-09-25 06:10:43 +00:00
doxa-maps-88655c70
7609cd36f0 Composites: pass the provider prop down, so keyless embeds are keyless
The token fix was half the contract. resolveMapProvider's ladder starts at
profile-config.provider, and a child reads the config its PARENT writes — so a
partner asking for

    <research-map-plus-grid profile-config='{"provider":"maplibre"}'>

got a child still on mapbox, which then demanded a token. MEASURED on a
third-party origin: "No Mapbox access token provided", 0 tiles, no canvas — for
the exact configuration whose entire purpose is needing no token. Keyless at
volume is why MapLibre support exists; a composite that drops the prop cancels
it, and the failure reads as a token problem rather than a plumbing one.

Both composites now thread `provider` beside `tk`, validated to the two known
values so a typo cannot silently select a third engine.

MEASURED AFTER, partner page, NO token anywhere in the config:
  research-map-plus-grid  canvas 1280x906, ZERO requests to api.mapbox.com,
                          basemap painted, 12,379 pins, legend, grid
  dashboard-plus-grid     canvas present, ZERO requests to api.mapbox.com
Screenshot read: the keyless map also shows the deferred search-bar rule holding
on a partner page — no search bar, and the legend risen into its place with no
gap.
2026-09-24 19:05:19 +00:00
doxa-maps-88655c70
dc252bb304 Composites: give a partner's embed the token it passes in
A JS embed of either composite mounted a map that could not draw. MEASURED the
way a partner actually embeds — one origin serving the tree with CORS, a
SEPARATE origin serving the page:

  research-map-plus-grid   "No Mapbox access token provided", style 0, tiles 0, no canvas
  dashboard-plus-grid      the same
  (iframe embeds were fine throughout: style 2, tiles 9, 0 failures)

Both composites declared NO PROPS, so the host's profile-config — token included
— was read by nobody. Our own leaves hide this completely: a leaf carries a
BAKED token and assigns window.MAP_TOKEN, which the shared-token bridge hands to
the nested map. The failure therefore exists ONLY where there is no leaf, which
is precisely the partner page the embed snippet is written for. It is also the
snippet we publish: profile-config='{"tk":"YOUR_MAPBOX_TOKEN"}' is the documented
way to supply a key, and it did nothing.

Both now parse their host config, publish the token page-wide through the shared
composable, and hand it to their children. The two differ in how children are
mounted, so the last step differs:

  research-map-plus-grid composes by TAG + BUNDLE URL, so the token is threaded
  into the child element's profile-config, where its own ProfileLoader reads it.

  dashboard-plus-grid bakes its children in and bypasses ProfileLoader entirely
  — and ProfileLoader is the only provider of `mapboxToken` in the repo, so
  DashboardMapPanel's inject fell through to its empty default. It now provides
  `mapboxToken` itself, from the host's tk with the shared page token behind it.

Spread last and only when present, so a host that supplies none leaves the key
absent and the child asks for exactly what its standalone leaf asks for. A
malformed attribute is caught and ignored: the map half is more useful
token-less than the page is with a thrown setup().

This went in the PLUGINS, not the library. A shared change reaches every map —
that is the lesson SHOW_EXPORT_BUTTON taught this week, and nothing here needs
to be shared to work.

MEASURED AFTER, same harness: research-map-plus-grid canvas 1280x906, style 2,
tiles 9, 0 failures; dashboard-plus-grid canvas present, style 2, tiles 10, 0
failures — basemap, pins, legend, table and charts all drawn. Screenshots read.
Both LEAVES re-checked for regression: canvas 1, 0 failed requests, each still
painting its own map.
2026-09-24 19:00:24 +00:00