Commit graph

145 commits

Author SHA1 Message Date
Doxa Maps
a7c9e9557f The prayer map's legends see the pins it draws
rega, row 46 at 835ec1ac: on the prayer map neither the shared-point sentence nor the standing pin note
appeared. research-map and countries-map provide normalizedPeopleGroups to their legends; the prayer map
did not, so its legends fell back to dataStore's cache, which holds the RAW API rows: field names the
co-location index cannot read, and no GeoJSON geometry for the standing-note detector to find.

useMapData now provides the normalized rows it draws, so every map built on it offers them. LegendMobile
and SemanticTreeLegend read them first (the cache stays the fallback), and the detector accepts a row with a
name and a position as well as a GeoJSON point. check:approx-pin-note asserts the provide and the
normalized-row match; the provide check fails on the previous useMapData.
2026-09-27 02:17:35 +00:00
Doxa Maps
0d0ee3cbed SemanticTreeLegend says a dot is a locality, on every map with people-group pins
The Driver's card BUILD-APPROXIMATE-PIN-NOTICE: every map that shows people-group pins must say so plainly.
The standing APPROXPIN note existed only under LegendRows and in the pin card, so maps whose legend is
SemanticTreeLegend (the prayer map's flat tabs among them) never showed it (rega, row 46).

It now renders under the tree when the map's own data holds named Point features (the injected dataStore);
maps without people-group pins show nothing. A pinNote prop overrides for a leaf that already says it.
2026-09-26 22:06:58 +00:00
Doxa Maps
09aba6dc92 C4 wave 2: five library components share the guard, and keep their own English
The five are NOT one shape, and treating them as one would have changed what
readers see. Measured before touching them:

  ApproxPinNote, FilterDropdown   fall back to a literal map
  SemanticTreeLegend, PosterWall  fall back to humanizeKey() — 'legend.columns.name' renders 'Name'
  PinHint                         falls back to a one-key map
  SemanticTreeLegend, PinHint     also capture i18n.te (used 3x and 1x)
  PosterWall                      also captures i18n.locale

So useOptionalT grew an optional `fallback` hook, and useOptionalI18n was added
for the callers that need te and locale. Every site passes the fallback it
already had, so not one rendered string changes. Routing them through the
catalogue instead would have altered what seven bare-host bundles print, and —
because tStatic honours the runtime locale — would have started translating
strings that are English today. That may be an improvement; it is not this
commit's to make. Changing a default is not a migration.

te keeps its existing contract too: FALSE on a bare host, never "true because the
catalogue has it". PinHint and SemanticTreeLegend both record why — te is how
their callers know to use their own defaults.

THE CHECK NOW LIVES ON MAIN, which is the point of this commit as much as the
migration is. dx-shepherd: the 18-assertion version was in _qa-cycle, so it would
never have run again. internal/check-optional-t-equivalence.mjs is registered as
check:optional-t-equivalence — script line, //scripts note and gates:post token,
all inserted by hand rather than by re-serialising package.json. It carries 46
assertions: the helper's contract across all three host states, a branch-for-
branch equivalence against the old hand-written code, and a PER-SITE REGISTER
that every later C4 wave appends a row to. Negative-tested from outside: strip one
site's own fallback and it fails naming that site.

check:library-index went red on the new composable and is green again after
`bun run library-index`. That is the staleness gate working, not a flake.
2026-09-26 22:06:57 +00:00
Doxa Maps
c2987e8134 PinHint shows only while its map is on screen
It gated on a map canvas EXISTING. A composite (pane-wrapper) keeps the map pane mounted while the reader
scrolls to another pane, so on UPG-100's phone view the pill was clamped into view over the poster pane,
where there is no map to tap (Driver, README picture; traced by upg100-plugin and repo).

An IntersectionObserver on the map box now decides: under a fifth of the map in view hides the pill, and
scrolling back shows it again. Only the reader's close button dismisses it for the visit, as before. Without
IntersectionObserver the old behaviour stands.
2026-09-26 22:06:57 +00:00
Doxa Maps
2449098a68 The legend mirrors in Arabic: logical insets instead of left/right
The shared legend was placed with physical left/right, while the map controls use inset-inline-end. In
Arabic the controls mirrored to the left, onto a legend that stayed left: on Simple Research, Prayer Progress
and UPG-100 (rega's RTL survey at 52eeb0db; traced by upg100-plugin and repo).

SemanticTreeLegend: the panel, its reopen button, the tree-guide hairlines and the export menu use
inset-inline-start/end; the closed panel slides off to the right and its collapse arrow flips under
:dir(rtl) (additive overrides, as VerticalTabsLayout does). LegendDesktop, LegendMobile, LegendRows,
LegendTools and LegendProgressBar: the card, reopen, detail close/collapse buttons, the name gutters that
clear them, indents and spacing are logical; LegendTools re-centres under RTL. LTR renders the same.

check:legend-logical-insets (with :self-test, both in gates:post) fails on any physical left/right in the
legend components unless it is symmetric, an auto reset, or allowed with a reason.
2026-09-26 22:06:57 +00:00
Doxa Maps
0ea7dd48e7 ApproxPinNote renders on a host without vue-i18n
It called useI18n() unguarded at setup. On an embeddable leaf whose host never installed vue-i18n that
throws inside the custom-element boundary and leaves an empty shadow root: the FilterDropdown failure
that blanked people-groups-grid. It now guards the call the way PinHint does, and falls back to the
English pins.* wording with the same {names}/{n} placeholders filled. Reported by gospel-plugin.
2026-09-26 17:28:46 +00:00
Doxa Maps
dec7059e87 Decouple the library's prose: no host-platform names in the QA widget, StackLayout or script notes
The QA widget comments (QaCorner, ProfileLoader, app-wrapper-material) say our own surfaces and the host's
session instead of naming one host; StackLayout describes a normal host page's flow; the live-cors script
note says it was measured on the live test host. Comments and notes only; no behaviour changes.
2026-09-26 17:28:45 +00:00
Doxa Maps
99528fa97f MapToolbar: the mobile comment names the top it actually sets (72, a 26px gap)
The 767px block sets top:72px, but its comment still described top:80 and a ~34px gap. Comment only;
the value is unchanged.
2026-09-26 17:10:06 +00:00
Doxa Maps
c683d49716 The dashboard now says out loud what a filter just did
Cross-filtering is the one interaction whose whole point is that acting on ONE
control changes a number somewhere ELSE. Pick "Islam" in the left rail and 2,107
becomes 693 across the KPI strip, the map, the table and the charts. A sighted
user watches that happen. A screen-reader user heard nothing at all: this
element carried exactly one role="status" (the table's selection pill) and zero
aria-live regions. The filter worked; the feedback did not exist.

ONE polite live region in the shell, not one per panel. Six surfaces change on
every brush, and polite announcements QUEUE — six regions would make the user
wait through five repeats before reaching the next control. It is always
rendered and starts empty, because a live region inserted already holding its
text is routinely not announced; it is visually hidden rather than display:none,
which would take it out of the accessibility tree along with the pixels. It is
debounced at 700ms for the same reason the selection camera is debounced: typing
re-derives the row set per keystroke, and an un-debounced region would queue one
announcement per character. It stays silent on arrival and only ever reports a
change. It names the count, the total, the active facets and the search term,
formatted by the shell's own formatter so it can never read as a different
number from the KPI strip beside it.

ONE RESIZE HANDLE ANNOUNCED ITSELF IN ENGLISH in all 11 locales. Four of the five
read their title and aria-label through T(); the map/table one held raw string
literals. It survived review because each half looks correct on its own — the
ternary is right, the strings are right, only the missing T( is wrong. Both now
resolve through the catalogue. (The keys themselves are on
sr/dashboard-a11y-strings: aria.json held eleven keys and not one was a resize
key, so ALL five handles have been falling back to English. The T() call sites
were right the whole time.)

THE CHIP ✕ IS NOW BIG ENOUGH TO HIT. The live strip's remove button was a
~13x16px glyph — the smallest control in the product, and the only way out of a
narrowed board, where a miss costs the reader the whole view. The row height
cannot grow: three comment blocks above it record a measured layout-shift bug and
the Driver's "a lot more subtle" instruction that pinned it to 16px. So the HIT
AREA grows and the BOX does not, via an absolutely-positioned 24x24 ::after that
contributes nothing to the flex row's intrinsic size. Nothing moves by a pixel.
2.5.8 is a TARGET SIZE criterion, not a touch one, so it is unconditional. A
focus-visible ring too, since the chips live in an overflow-x strip where being
scrolled into view is not the same as being visible.

The kit's own DashboardFilterChips were spans with click delegation — not
focusable, no click from Enter or Space. They are buttons now, with an aria-label
naming the ACTION rather than the filter ("Religion: Islam" is a good name and a
useless action), type="button" so a partner's form is not submitted by a chip, a
focus ring and a 24px floor. The reset-all chip deliberately gets NO aria-label:
its visible words already are the action, and overriding them breaks voice
control.

Recording what this does NOT fix, because the measurement that prompted it named
the wrong file. DashboardFilterChips is exported from the kit and mounted by
NOBODY — round 1 of BUG-DUPLICATE-FILTER-BADGES removed it, and the strip on
screen belongs to SearchFilterHeader, whose remove button was already a real
<button type="button"> with a localized aria-label. So the keyboard half was a
latent defect closed before the next dashboard mounts it, not a live one. The
live defect in that same control was its size, above.

check:dashboard-filter-a11y guards all of it, on the kit component AND the strip
that actually ships. It walks the parsed template AST rather than grepping,
because grep cannot tell a bound :aria-label from a literal one, cannot tell a
button from a span named like one, and cannot see a live region hiding inside a
v-show'd cell. It also reads the built bundle, since source-correct plus
bundle-stale is a real repeat failure here. Fourteen mutants were run against it;
every one fails, naming the file and the reason. No browser — that is rega's.

STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid (the
composite bakes the shell in). Part D of the gate fails until you do, by design.
2026-09-26 13:35:57 +00:00
Doxa Maps
c7d68dc3fa Four shared controls get a 24x24 target at every pointer, not only on touch (WCAG 2.5.8)
Their 24px floor sat only behind @media (pointer:coarse), so a desktop mouse still got the small target
(regions' finding; the phone probe runs with touch, so it measured the passing branch). 2.5.8 is a target-
size criterion, not a touch one. .mobile-collapse-caret (22x20 in a 20+4px slot), .stl-hdr-num (a 9px line
box), .rm-link-btn (11px, padding 0) and .pin-hint-x (16px) each get a 24x24 absolutely-positioned ::after
hit area at every width (the f29b8151 pattern). It adds a target without a box, so no row or slot
geometry moves; the coarse query still sizes the box on touch.
PosterWall's .pbtn, .pstock and .tape-h are HELD: their spacing is the Driver's sign-off.
The desktop verdict (2.5.8's spacing exception, an ancestor with overflow:hidden) needs a rendered layout,
so this is a needs-eyes item for rega.
2026-09-26 13:35:57 +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
1ac32fe3d0 People-group pins say what they are: one shared note, and the groups that share a point are named
A people-group pin is a representative point, not an address, and some pins ARE the same point: on the
Doxa roster 2,107 groups sit on 2,048 coordinates, 108 of them on a point shared with another (gospel-
plugin's measurement). Every map with people-group pins must say so, in one wording.

- library/components/legends/ApproxPinNote.vue: "The dot marks where this people is found, not a town or a
  boundary." always; when the selected pin's point is shared, "It sits on the same point as <names> — <n>
  groups share this coordinate…". Never prints the raw coordinate. Wording: gospel-plugin's P5 proposal,
  in a new pins catalogue (11 locales).
- library/utils/colocation.js: the co-location index, built from the roster THE MAP draws (rows or
  GeoJSON features), never a hardcoded count.
- LegendDesktop and LegendMobile (research map, simple map, country posters, cities, UPG-100) show the
  note under the legend, and under the detail card with the shared names.
- check:approx-pin-note (headless, gates:post): the index, the two sentences, no coordinate, Arabic.
2026-09-26 13:04:15 +00:00
Doxa Maps
83fc3f5029 Share, export, clusters and the pin hint stop being typed in English
Second of the shared-library batches, in the shepherd's order: ShareButton,
DataTable, LegendTools, and PinHint's dismiss label.

★ THE ONE THAT MATTERS MOST IS THE PARAGRAPH A PARTNER READS. ShareButton's
embed tab explains where to paste the snippet — "WordPress, Squarespace, Wix, or
any site that accepts HTML widgets" — and it was English on every locale of a
product whose whole pitch is that a partner can drop it into their own page. The
two tab labels beside it likewise.

Also wired: the data table's CSV and JSON buttons, its row-detail hint and
dialog label; the legend's Clusters button and the Minimum Spanning Tree option
with its explanation; and the pin hint's Dismiss.

★ SIX SITES NEEDED NO NEW WORDING AT ALL — they already had translations in all
eleven locales and were still typed into the template: four "Close" buttons
(buttons.close) across PosterDialog, DataTable, MapControlMenu and the QA
corner, "Expand legend" (aria.expandLegend) and PosterWall's "people groups".
Those are the cheapest defect in the whole catalogue: the work was done, the
reader just never saw it.

CSV and JSON are keyed but identical in all eleven, like DPI in the poster
batch. A format acronym is not an untranslated string; the key exists so a
language that does translate it can, without anyone editing a component.

★ LIBRARY CHANGE — studio's to land under rule 5B. I have NOT rebuilt anything.
Until it lands, every string falls back to the English it reads today.

Measured with the source audit: shared-library strings with no catalogue entry
go 32 → 21, and the has-a-key-but-hardcoded class goes 6 → 2. Remaining, in
order: QaCorner (10, a developer tool), SemanticTreeLegend (4), PosterPreview,
PosterWall and VerticalTabsLayout (2 each).
2026-09-26 12:23:49 +00:00
Doxa Maps
182dc37571 The poster export dialog speaks the reader's language
First of the shared-library batch, and the one the shepherd put first because a
reader operates it: PosterDialog had seventeen English strings with no catalogue
entry — the whole dialog. Size, Orientation, Portrait, Landscape, DPI, Format,
both file-type options, Title, Subtitle, Slots, Scale bar, North arrow, the
footer's attribution note, Cancel, and its own heading and aria-label.

Every English string is kept exactly as it reads today, per the shepherd's
ruling, and the eleven locales are filled in. A new poster.json per locale: the
i18n layer assembles catalogues with an eager `import.meta.glob` over
locales/*/*.json, so a new area file is picked up with no index to edit.

Two of the nineteen sites needed no new wording at all — "Close" and "Legend"
already ship in every locale as buttons.close and legend.header.default, so they
reuse those rather than gaining a second spelling. That is the same rule that
sent the grid's "Clear all" to controls.reset_all: two keys for one word is how a
control ends up saying different things in different languages.

DPI is left as DPI in all eleven. It is a standard acronym, not an untranslated
string, and inventing an expansion per language would be the opposite of what
this batch is for.

★ THIS IS A LIBRARY CHANGE, so it is studio's to land under rule 5B — the
catalogue is compiled into every bundle, so every consumer needs rebuilding. I
have deliberately NOT rebuilt anything. Until it lands, every one of these falls
back to the English it reads today, which is the tStatic contract.

Measured before and after with the source audit: the shared-library
no-catalogue-entry count goes 49 → 32, and PosterDialog leaves the list entirely.
ShareButton, DataTable, LegendTools and QaCorner are the remaining batches, in
the shepherd's order.
2026-09-26 08:51:39 +00:00
Doxa Maps
4a12b009aa Every runtime CDN script and stylesheet is an exact version with an integrity hash
maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime loader set `integrity`,
so a CDN could serve any bytes to every partner's visitors unnoticed (CRITIC-5).

- library/constants/cdn.js is the one registry: mapbox-gl 3.24.0, maplibre-gl 5.24.0 (the release the
  @5 range already resolved to), both geocoders (1.9.4 / 5.0.0) and d3 7.9.0, each with its sha384.
  Every host answers Access-Control-Allow-Origin: *, so integrity + crossorigin work cross-origin.
- ensureEngineScript / ensureEngineCSS, the geocoder loaders (library and PPLR) and the generator's
  static MapLibre tags set integrity + crossorigin from it; the embed snippet's engine preload matches.
- check:cdn-pins (gates:post): no floating CDN url in shipped code, and every one is in the registry;
  --online re-fetches each and compares the hash (all 9 match today).

MapLibre stays on 5.x knowingly. GHSA-jrc7-96c5-q579 (DOM.sanitize, reached through a source's
attribution string) affects <= 6.4.0, and 6.x is ESM-only (no UMD global), so the upgrade is a loader
migration of its own. The attribution strings rendered today come from our configured basemap styles
and our own sources; the note in cdn.js says who is exposed until then.
2026-09-26 05:50:24 +00:00
Doxa Maps
31ed8968d5 Poster wall: 'Show on map' says when it is on (aria-pressed), since a second press turns it off
The button is a toggle (a second press clears the map selection) but carried no pressed state, so a
second press was indistinguishable from a first to a screen reader. Reported by regions from rega's
re-shoot of the show-on-map fix.
2026-09-26 05:16:02 +00:00
Doxa Maps
f1ef566af9 The legend's export menu speaks the reader's language
Six user-facing strings in SemanticTreeLegend were hardcoded English, in the
one surface that generates a document translated into all eleven locales. A
reader in Arabic opened the export menu and met "Poster" and "Packet" beside a
packet whose every page is Arabic — two honest surfaces disagreeing.

  legend.export.poster       menu item
  legend.export.packet       menu item
  legend.exportButton        the trigger's visible label
  legend.exportSelection     the trigger's title AND aria-label
  legend.noDataAtThisLevel   the empty state
  aria.expandLegend          reused for the collapsed-legend reopen button,
                             which had a title and NO aria-label at all, so a
                             screen reader announced an unlabelled button

WHY A GATE NEVER CAUGHT THESE. Both menu items only exist after a click opens
the menu, so a check that renders a leaf and scans visible text cannot see
them. The trigger's accessible name is worse: it is never visible text at all.

KEY NAMES CARRY THE FALLBACK, so they are not free. This component has no
i18n on a bare host (useI18n() is guarded) and falls back to
humanizeKey(lastSegment). I first named the keys after their ROLE — .label,
.selection, .empty — and measured the result: the export button rendered
"Label". The last segment must BE the English word, which is why
legend.columns.name -> "Name" has always read correctly. Verified all six:
Poster / Packet / Export / Export Selection / No Data At This Level / Expand
legend. Never blank, never a raw key.

TRANSLATED, NOT COINED. check-i18n-never-translated warns against inventing
terminology because a wrong term looks authoritative to a reader who cannot
check it. That warning was written about "UUPGs", a term of art. These are
ordinary print-shop words with undisputed equivalents, and the test is whether
a bilingual reader would dispute the word: not for "poster", yes for an
unreached-people-group acronym. Leaving them English was the defect.
es/pt legitimately share "Exportar"; the gate fails only on all-eleven.

Verified: check:i18n-parity PASS (11 locales complete, 0 debt),
check:i18n-never-translated PASS (my six keys added nothing to the allowlist),
BUNDLE=my-upg-100-list vite build 318 modules clean, and a static sweep of the
template now finds zero hardcoded English in text nodes or in title/aria-label.
2026-09-26 04:38:59 +00:00
Doxa Maps
05ab8b9da1 Place search makes no Mapbox call without a real token, and says so
(cherry picked from commit cd03a2c6d0)
2026-09-26 03:34:31 +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
d400861d79 Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/big-picture/big-picture.js
#	public/doxa-maps/big-picture/big-picture/big-picture/index.html
#	public/doxa-maps/big-picture/big-picture/frontier/index.html
#	public/doxa-maps/big-picture/big-picture/index.html
#	public/doxa-maps/country-posters/countries-map/index.html
#	public/doxa-maps/country-posters/country-poster-wall/index.html
#	public/doxa-maps/country-posters/country-posters-mfe/index.html
#	public/doxa-maps/country-posters/country-posters.js
#	public/doxa-maps/doxa-research-map/doxa-research-map.js
#	public/doxa-maps/doxa-research-map/research-map/index.html
#	public/doxa-maps/doxa-research-map/research-map/maplibre/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/people-groups-grid/people-groups-grid.js
#	public/doxa-maps/people-groups-grid/people-groups-grid/driven/index.html
#	public/doxa-maps/people-groups-grid/people-groups-grid/index.html
#	public/doxa-maps/people-groups-grid/people-groups-grid/people-groups-grid/index.html
#	public/doxa-maps/research-dashboard/research-dashboard.js
#	public/doxa-maps/research-dashboard/research-dashboard/index.html
2026-09-25 09:18:16 +00:00
doxa-maps-88655c70
53e2c6623e The last English in the shared header, panel and facet rail — and a 24px touch target
E RESIDUALS, from rega's in-gallery re-sweep. All three lived in SHARED
components, which is why a grep of my own plugin found nothing:

  SearchFilterHeader  its label map was a literal object of English defaults —
                      the fallbacks every host gets, since no host passes
                      `labels`. Filters, Selected, World and the chip remove
                      button now come from the catalogue; a host's own labels
                      still win, which is what the prop is for.
  ResearchMapFilterPanel  the two "Any" options and the "{n} active" pill.
  DashboardFacetRail  the Expand/Collapse aria template, which repeats per row:
                      one untranslated template is dozens of English strings in
                      a screen reader.
  dashboard.config.js the facet SECTION titles (People, Place, …). The earlier
                      pass converted `label:` and missed `title:`.

★ AND A LOOKUP BUG THAT LOOKED EXACTLY LIKE A MISSING TRANSLATION. 'Any' and
'0 active' stayed English after being catalogued, because the panel asks for
`controls.<key>` while I had authored them under `dashboard.<key>`. The
catalogue had the words; the component was looking one namespace over. Keys now
live where the panel reads, and its lookup falls back across both, so a string
authored in either place renders.

MEASURED after, ru, on the built tree: 0 English chrome strings on Simple
Research and 0 on Big Picture.
One probe correction worth keeping: a substring match on 'People'/'World'
flagged 'South Asian Peoples' and 'Arab World' inside aria labels whose frame
was already translated — data, not chrome. Whole-string matching separates them.

TOUCH TARGET (regions' phone sweep): .rd-proj "🌐 Globe" measured 74x23 at
390x844, one pixel under WCAG 2.5.8 AA. min-height:24px under
@media (pointer:coarse) — stating the 24 rather than growing padding, because
the height is padding plus a line box the renderer derives from the font and the
arithmetic lands at 23.7. Mouse sizing untouched. MEASURED after: 73.5x34.

Every bundle embedding the changed shared components is rebuilt in this commit —
a shared fix is not landed until its consumers are.
2026-09-25 09:15:30 +00:00
doxa-maps-88655c70
79dd00a12c Merge main: no source conflicts, artifacts rebuilt rather than merged
Every conflict was a build artifact — another agent had pushed a full rebuild of
all 23 bundles. Minified .js and baked-SRI leaf pages are generated files: merging
their text is meaningless and can produce a bundle that matches neither side, so
they are resolved by taking one side and then rebuilding from the merged source.

The incoming diff carries 68 source files, among them library/composables/
useMapInstance.js and the dashboard/gospel catalogues in 10 locales, so the
rebuild is all 23 bundles and not only the ones I touched — regenerating the tree
without rebuilding has shipped a stale .js here before.
2026-09-25 08:42:13 +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
3aaa53d501 Merge main: take the poster tape's overlap rule, keep the touch minimum
Their tape system supersedes mine and is the better invariant, so it wins whole.
I had made both stickers self-centre on the card's edge; they pin both by
--card-pad-top and --tape-overlap, which makes the OVERLAP onto the banner the
constant instead of the centre. That is what the Driver's rule actually asks for
-- extra height then grows into empty space rather than walking over the artwork
-- and one variable now governs both stickers so neither can drift from the other.

Kept only the orthogonal part of mine: the (pointer:coarse) block that lifts
.pbtn, .pstock and .tape-h to the 24px WCAG 2.5.8 minimum. Dropped my padding
override on .tape-h -- their rule sizes and places that sticker, and re-stating
its padding here would fight a geometry the Driver has already approved. Also
took their new .pstock.is-on state.

Fixed a comment of mine that the merge made false: it claimed both tapes
self-centre, which stopped being true in this very commit.

Rebuilt all 23 bundles, not just the ones I touched. The incoming diff brought
library/i18n/index.js and three shared library components, which reach nearly
every bundle -- and regenerating the tree without rebuilding has shipped a stale
.js here once before. tree + fix:sri after; check:sri PASS across 54 script
references on 55 leaf pages.
2026-09-25 07:47:06 +00:00
doxa-maps-88655c70
fab96ce5db Phone: every shared control clears the 24px touch minimum
Measured all seven production maps at 390x844 with touch emulation. 195 controls
sat below WCAG 2.5.8's AA minimum of 24x24 CSS px, spread across five maps -- but
they were not five owners' bugs. Grouped by class, 195 failures were ten classes,
and 119 of them lived in library/: the same PosterWall, PinHint, LegendMobile,
SemanticTreeLegend and ResearchMapFilterPanel controls repeated down a list and
across four maps. Routing the per-map counts would have filed hundreds of defects
against owners who do not own the geometry.

Raised the shared controls, scoped to (pointer:coarse). 2.5.8 is a touch
ergonomics rule, and these components sit on maps this change does not own: a
finger gets the bigger target, the mouse keeps the density those owners designed.
The poster row is the reason this matters -- its height was tuned by eye against
the card's blue outline, and growing it for a pointer that needs no help would
have spent the artwork clearance to fix nothing.

min-height states the 24 instead of deriving it. Growing the padding first
MEASURED 23.7px and 23.0px -- under by a third of a pixel and by one, because the
height is padding plus a line box the renderer computes from a 10.5px font. The
declared minimum also survives a font swap or a locale with taller glyphs.

The header tape now self-centres like the footer. It hung on top:-11px, half of
its then-fixed 22px height, which silently becomes wrong the moment the height
changes -- as it just did. top:0 with translate(-50%,-50%) sits on the border at
any height, the mechanism the footer already used, and one less number to keep in
sync.

Two exceptions in 2.5.8 are now encoded in the probe rather than remembered.
Inline: 48 .uupg__country-link "failures" are an <a> inside a paragraph, computed
display:inline, sized by the line-height of the text around it -- the spec exempts
them and the probe now skips display:inline, counting them separately. Equivalent:
a .lpb-seg progress segment is 10px tall inside a track that clips overflow, so no
hit-area extender can reach 24px; it resolves to a tree node and calls the same
selectNode a full-width legend row calls.

195 failures on five maps -> 4 on two. UPG-100 alone went 137 -> 0. The poster
tape gate and per-row persistence still pass.
2026-09-25 07:30:14 +00:00
doxa-maps-88655c70
5516c157bb Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/country-posters/countries-map/index.html
#	public/doxa-maps/country-posters/country-poster-wall/index.html
#	public/doxa-maps/country-posters/country-posters-mfe/index.html
#	public/doxa-maps/country-posters/country-posters.js
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-25 07:14:11 +00:00
doxa-maps-88655c70
b197055bf0 Print a UPG-100 list straight from the map legend
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The Driver, as a first-time user: "I select one and it gives me print
options at the top right of the legend ... that's the only way that map
would be usable."

The affordance already existed - SemanticTreeLegend renders an export
menu gated on a selection, the plugin passes :export-enabled, and
@export was wired. What the handler did with it was the problem: the
packet branch delegated to the shared composable, and the poster branch
hand-rolled an <a download> of posters/<id>.webp. That is wrong twice.
It downloads a file silently, which was ruled out on 2026-09-23 ("no
silent file download as the default action"), and it has no fallback, so
for the lists whose .webp is not shipped it saved a 404.

The wall had a correct poster print the whole time, private to
PosterWall.vue, so this surface could not reach it. It moves into
usePosterPrints.printPoster and both surfaces now call the one copy -
what the card asked for: "reuse the existing three print actions rather
than growing a fourth implementation." Two guards moved with it, each
learned the hard way: a THROWN fetch is a miss rather than an error (or
the rasterise fallback is skipped entirely), and a blank raster fails
loudly (an SVG in an <img> is an isolated document, so a missing
stylesheet paints black with no error).

Also in this commit, both from measurement rather than review:

THE STICKER OVERLAP IS NOW A CONSTANT, NOT HALF A VARIABLE. Centring the
print row on the banner edge put half its height on the banner - and
that height is content-dependent: 68px in en, 54px in zh, taller when ro
wraps at 390px. Measured ro/390px: 27 of 27 stickers on the map by 4px
while en and zh were clean at the same padding. No fixed padding can
clear half of a variable. The row's top is now pinned --tape-overlap
above the edge and extra height grows downward into the row gap, so the
amount on the banner is identical in all 11 locales at every width.
15 width x locale combinations now pass with map-covered 0.

THE PER-ROW PREFERENCE IS CLAMPED TO WHAT THE VIEWPORT HOLDS. It renders
as an inline grid-template-columns, and inline beats the stylesheet, so
the Driver's default of 2 overrode minmax(210px,1fr) at every width -
including 390px, where two columns give a ~162px card. Reported from a
390x844 phone shot. The preference is for desktop density, not an
instruction to overflow a phone; it survives, clamped by the same 210px
minimum the stylesheet already uses.

THE ROW GAP WAS CLEARING A DISTANCE NOTHING OCCUPIES. 88px was tuned
when the print row hung fully below its card. It now straddles the
banner edge, so the same shot showed a ~180px empty band between rows.
56px still clears a two-line row in the long-label locales.

And the composite shell re-dispatches doxa:select-zone, so a list chosen
in the poster wall is ISOLATED on the map rather than merely scrolled
to. The map half was already built and listening on its own element; the
shell owed it one re-dispatch and nothing here re-implements selection.
2026-09-25 07:10:23 +00:00
doxa-maps-88655c70
89bd9c5d79 Poster wall: the banner gets stickers, top and bottom
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
The card is a BANNER that displays a map, the way a label displays a
design on a bottle. A banner taped up gets a sticker at each end, and
each sticker straddles the edge it tapes: part on the banner's own white
space, part overhanging. Neither one touches the map.

MEASURED at 1440px before this change:
  banner 240-426, map 241-425  ->  1px of white space at each end
  header sticker  35px above the banner's top edge, -1px below
  footer sticker   1px above the bottom edge, 67px below

So the map filled the banner edge to edge and there was no banner
surface for a sticker to sit on. The footer row could only hang off into
space, which is what shipped, and every assertion in the gate was green
through all of it.

The banner now reserves white space at both ends (--banner-pad-top /
--banner-pad-bottom on .main), and each sticker centres on its edge:

  header sticker  17px on the banner / 17px above it
  footer sticker  35px on the banner / 33px overhanging
  map covered by either: 0px

The print row also moved INSIDE .main. It was a sibling, so top:100%
resolved against the CARD, 27px past the banner - the re-parenting and
07d3341c's centring transform are one fix, and neither works alone.
That commit's per-row localStorage preference is preserved.

Card Deck is no longer a peer button with a 162px <select> beside it.
The stock picker is a disclosure: press Card Deck, choose a size, and
that choice prints. A native dropdown in the row collides with the host
page's own dropdowns, which is why it is buttons rather than a <select>.

Print-button labels WRAP instead of clipping. With the per-row default
now 2, a 390px viewport halves the card to ~170px, where Romanian's
"Pachet de cartonașe" cannot share a line: measured 54 of 81 controls
clipped at ro/390px, en and zh clean. The ruling on this wall is that a
long label wraps; nowrap was forcing a clip.

Also fixes the transparent strip above a pinned section header. Sticky
pins to the scroll container's PADDING box, so with .wall's 22px
padding-top the header rested 22px down and posters scrolled visibly
through the gap. A ::before extends the header's opaque fill upward by
exactly that padding, read from the same var so the two cannot drift.
Measured by sampling the strip's pixels: 1 colour, 100% header
background, where before it was 1338-2831 colours of poster artwork.

The MFE shell's own chrome is translated, in all 11 locales. Measured
with ?lang=ar: "Select View", "Views", "Prayer Resources" and "Map"
rendered English while the poster wall's buttons inside the panes
translated correctly, because each pane installs i18n and the wrapper
around them never did - the chrome a reader meets FIRST was the
untranslated part. The wall's own heading, tag and intro go through t()
for the same reason, with the <strong> inside the translated string so
each language emphasises its own words.

The gate grew four assertions, each mutation-tested to fail on the
defect it was written for: off-frame (row floated off the banner),
head-off (header sticker not straddling), map-covered (a sticker on the
poster), and selects (a native dropdown back in the row).

Includes a rebuild of the four PosterWall consumers, which also picks up
studio's shared useMapInstance unmount guard and exp-shepherd's
translated LegendMobile labels.
2026-09-25 06:41:56 +00:00
doxa-maps-88655c70
9a18dee1c2 Merge main into bundler-2/work
# Conflicts:
#	package.json
#	public/doxa-maps/country-posters/countries-map/index.html
#	public/doxa-maps/country-posters/country-poster-wall/index.html
#	public/doxa-maps/country-posters/country-posters-mfe/index.html
#	public/doxa-maps/country-posters/country-posters.js
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-25 06:12:10 +00:00
doxa-maps-88655c70
b4a0387495 Translate the chrome that was English in all eleven locales
Measured under ?lang=ru on both composites: every Latin-script string left on the
page was ours. The map above was fully translated; underneath it the grid, the
map's own side menu and filter panel, and the whole research dashboard were
English in Arabic, Chinese, Hindi, Russian and everything else.

Three surfaces, three reasons, none of them a missing translation:

  the grid          read labels from a host-supplied `t` object that nothing ever
                    filled, with an English literal at each call site as fallback
  side menu +
  filter panel      plain string literals
  dashboard         labels declared as literals in dashboard.config.js — facet
                    rail, chart titles, KPI strip, table headers, segmented
                    control, search placeholder

New catalogues for all 11 locales (grid, controls, dashboard), REUSING the
catalogue's existing wording wherever the word already existed — Регион, Блок
родства, Население, Вовлечён, Графики — so two panels never name one thing two
ways, and nothing is coined that the project already has a word for.

Read through a new plugin-free tStatic() rather than useI18n, because these
components are also mounted by entries that install no i18n plugin. An unguarded
useI18n() throws and a thrown setup() removes the subtree with no build error —
the defect that had just blanked people-groups-grid everywhere (6603dc17).

★ THE CONFIG LABELS ARE GETTERS, AND A SPREAD WOULD HAVE LOOKED IDENTICAL.
dashboard.config.js is imported once at load; the locale is only known later. My
first version spread a getter object, and a spread COPIES THE RESOLVED VALUE, not
the accessor — every label would have frozen at whichever locale loaded first,
and it would have looked perfect in English. `get label() {…}` resolves on each
read.

MEASURED after, served built tree, identity checked first: Big Picture ru/ar and
Simple Research ru/zh have ZERO Latin-script chrome strings. What remains is
Mapbox's licence attribution (never translate), country and people-group names
(data), and 'UUPGs' — which every locale file deliberately keeps as the acronym.

Three gates went red on the way, two of them mine and worth recording:
  check:locale-reachable  slices this i18n module between two markers and EVALS
                          the slice; I had put tStatic between them, so the gate
                          hit an `export`. Moved to the end of the file.
  check:library-index     my import+helper above a component's docblock became
                          that component's DERIVED description. Moved below it.
  check:kpi-unit          its parser assumed single-brace entries, so getters made
                          it find ZERO pct KPIs — and it failed rather than
                          printing a green PASS about nothing, which is the gate
                          working. Parse now understands getters AND checks the
                          translated labels, so a '%' reintroduced in any of the
                          11 catalogues fails by name. Both controls fired.
2026-09-25 06:10:27 +00:00
doxa-maps-88655c70
502a4186dc Cities with Internet and the phone legend speak the page's language
Cities with Internet was English end to end: the legend title, the four bands,
the World Bank caveat, the new people-group row and the whole country sheet. Its
chrome now comes from a connectivity catalog area in all eleven locales, following
gospel-resources: i18n is optional, English from the shipped catalog is the
fallback, and carrier and platform names stay as data. The legend and the sheets
are anchored with logical insets, so in Arabic the legend sits opposite the control
rail instead of under the zoom buttons.

LegendMobile hard-coded its column labels ("Prayer Status", "Engagement Status",
…) and titles ("Legend", "Languages", …) in English. They now read
legend.colLabel.* and legend.titleBar.*, which every catalog already translates.
English is byte-identical, so other consumers are unchanged until they rebuild.

Measured in the Research Gallery tab on a served built tree, at 1280 and 390, in
en/ar/zh/ru/hi: Cities shows no English UI string (only the Mapbox and
geoBoundaries attribution); ar is right-to-left with 0 clipped and the legend
clear of the control rail.
2026-09-25 06:05:46 +00:00
doxa-maps-88655c70
4cfe8ec5e2 Merge commit 'd989d598f18227e677f762c15c21df27013a57ad' into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-25 05:16:03 +00:00
doxa-maps-88655c70
07d3341c83 Poster footer becomes tape on the border, and per-row persists (Driver)
Driver: "see how the header for show on map is inline on the top of the poster
box? but the footer print options are not inline on the border or outline of the
box... we want the footer to imitate the header, by being inline like a piece of
tape, but not overlapping the poster itself".

The header manages that with .tape-h{top:-11px} -- a fixed offset that works only
because its height is fixed. The footer's height is not: it carries two to four
controls plus a select and grows with translated labels, so the old
margin-top:-14px left it hanging BELOW the edge rather than sitting on it, and
any height change moved it again. translate(-50%,-50%) at top:100% centres it on
the bottom border at ANY height, which is the same piece of tape the header
achieves and is self-correcting when a locale makes it taller.

MEASURED on a rendered card: the header's centre sits 1.0px from the card's top
edge (height 22.0) and the footer's centre is now -1.0px from the bottom edge
(height 30.0) -- tape on the border, mirroring the header. It also stays well
clear of the artwork, footer top 800.1 against a preview bottom of 557.5, which
is the rest of the Driver's rule: half the tape overlaps the card and half hangs
below, so the overlap grows only half as fast as the tape does.

Per row, also Driver: minimum 2, maximum 5, default 2, "and it needs to honour
local storage so if you change the default number it is saved in local storage
so you can reload and your default you set is persistent". Choices are now
2..5, the default is 2, and the chosen value is stored under one key for the
whole wall rather than per instance -- a preference that only survives on the
page you set it on is not a preference. A stored value outranks the `columns`
prop, because the prop is only what the page suggests before the reader has
said otherwise. Every localStorage access is wrapped: it throws outright in a
private window or with site data blocked, and a preference must never be able
to break the wall.
2026-09-25 05:15:58 +00:00
doxa-maps-88655c70
4d773d6ee8 Move internal files out of the repo; fix five dead links; a fuller delegation diagram
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Moved out (kept outside the repo, in the team's internal tracker):
- AGENT-BRIEF.md: operating rules for the team's own agents
- documentation/slide-deck-outline.md, documentation/README-longform.md:
  internal pitch material; the README and why.md cover the same ground
- plugin-designs/gospel-resources/SOURCES-CHECKED-AND-REJECTED.md: research log
- .claude/ (an editor status line), .forgejo/ (a CI workflow that never ran),
  _archive/ (a retired staging page; already gitignored but still tracked)
Seven code comments that named these files now describe the rule instead.

Fixed five dead links left from the docs/ -> documentation/ move, including
the one in template-bundle that every new plugin inherits.

The delegation diagram now shows the whole model: the Plugin Studio with the
shared library and the merge queue into main, seven production plugins with
capability badges and the Experimental shelf, one agent per plugin (a roaming
agent for the shelf), the shepherd's oversight ring, a legend, and the five
ways a build ships.
2026-09-25 05:14:10 +00:00
doxa-maps-88655c70
6494b97993 library: PosterWall and SemanticTreeLegend no longer require i18n to render
Some checks are pending
build-gate / build (push) Waiting to run
Both called useI18n() UNGUARDED. That function THROWS ("Need to install with
`app.use` function") on any host that never installed vue-i18n, and seven
bundles here do not — big-picture, doxa-maps-spa, doxa-vision-card,
golden-path-check, people-groups-grid, plugin-studio, stack-demo. In a library
component this widely consumed, an unguarded call is a BLANK PAGE waiting for
the first bare host that renders it: exactly how library FilterDropdown blanked
people-groups-grid, a production plugin, fixed by studio in 6603dc17.

★ THIS IS PREVENTIVE, NOT A FIX FOR AN OBSERVED BREAK, and the distinction
matters. MEASURED: no bare bundle currently consumes either component, so the
blank page cannot be reproduced today — which matches the shepherd's framing
("LIVE today: none; latent debt"). What is fixed is the embedding hazard, and
the gift policy asks for plugins that work "standalone AND embedded".

⚠ THE FALLBACK IS DERIVED, NOT A TABLE. Hardcoding English for these eleven
keys would put a second copy of the `en` catalog inside the component, and two
copies of one fact drift — the exact trap behind the embedBase asset-path bug
this week. Humanizing the key's last segment gives "buttons.prayerList" ->
"Prayer List", "buttons.cardDeck" -> "Card Deck", "legend.columns.name" ->
"Name": readable English that CANNOT go stale, rather than a raw key shown to a
reader. te() answers false so every caller falls through to its own default.

VERIFIED: the fallback produces the correct English for every real key these
components use; with i18n present nothing changes —
check:legend-info-localized PASS (es/ar/zh still translate, 0 blank),
check:poster-card-fits PASS across 5 widths x 3 locales,
check:a11y-button-names PASS, SRI consistent.

I also confirmed 6603dc17 is an ancestor of my HEAD before pushing, per the
shepherd's warning: the FilterDropdown fix is intact in my tree, not reverted.
2026-09-25 04:02:56 +00:00
doxa-maps-88655c70
90ceac36ba PinHint must not require vue-i18n either, and a gate so the next one is caught
The shepherd reported a P0 I caused: db36397b made library FilterDropdown call
useI18n() unconditionally, which throws "Need to install with `app.use`" on any
host that never installed vue-i18n. people-groups-grid deliberately has none, so
the grid rendered blank standalone, in the studio and in every composite nesting
it -- one of the seven production plugins going to DX. Studio fixed that
instance in 6603dc17.

That fix was NOT on my branch. 6603dc17 was not an ancestor of my HEAD and my
tree still carried the unguarded call, so my next push would have reverted it.
Merged before doing anything else.

PinHint carried the same unguarded call, added by me in 9ba6ad7a. Guarded the
same way: host translations when present, the English literal otherwise, with
te() answering false so callers fall through to their own defaults.

The gate the shepherd asked for is _chk-library-i18n-optional.mjs. It scans
library/components for useI18n() calls that are not behind a guard, and it
separates LIVE from LATENT, because a wall of identical failures gets ignored
while a list of one gets fixed: it computes which bundles install no i18n and
which of those actually import each offender.

Measured: 17 library components still call useI18n() unguarded, 7 bundles
install no i18n (big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check,
people-groups-grid, plugin-studio, stack-demo), and LIVE is currently none --
no bare host renders an unguarded component today. The 17 are debt, not safety.
They are left for their owners rather than rewritten from here, since library/
reaches every bundle.

Two of my own errors on the way, both caught before they reached anyone. I first
reported 14 bare bundles, because I grepped for createI18n when the install is
usually createAppI18n from @map/i18n -- country-posters does install it. And the
gate first reported PeopleGroupDetail as LIVE in people-groups-grid on the
strength of a COMMENT mentioning it: a substring match cannot tell an import from
prose, which is precisely the defect I reported in check:shared-reached a day
earlier and then rebuilt myself. It now strips comments and requires a real
import statement or an element tag.
2026-09-25 03:43:56 +00:00
doxa-maps-88655c70
6603dc176b FilterDropdown: translate when the host has i18n, never require it
Some checks are pending
build-gate / build (push) Waiting to run
db36397b made the dropdown call useI18n() unconditionally. That throws
"Need to install with `app.use` function" on any host that never
installed vue-i18n, and people-groups-grid deliberately doesn't (its
labels come through its `t` prop). The grid rendered 2 DOM nodes and a
page error: standalone, in the plugin studio, and inside every
composite that nests it (research-map-plus-grid, stack-demo).

The dropdown now uses the host's translations when it has them and the
English strings otherwise. Verified: people-groups-grid renders with no
error again, and region-dashboards in French still shows "Aucune option
trouvée".
2026-09-25 03:19:23 +00:00
doxa-maps-88655c70
a7f57c117d poster cards: a blue outline on hover, and a gate that can see it
Some checks are pending
build-gate / build (push) Waiting to run
The card asks for the Pinterest/Instagram behaviour: hovering a poster outlines
it in blue so a reader knows which one their click is about to act on.

★ THIS IS NOT A RESTORED BORDER. Several reports asked to bring back "the blue
outline around the poster", and MEASURED 2026-09-24 there has never been one:
all 136 elements painting a frame on this wall compute outline:none, and every
border is grey (.card #e3e6ea, .main #dfe3e7). Nothing regressed -- the
affordance was never built. The reports kept recurring because a hover state is
invisible to a screenshot, so each check confirmed "no outline" and concluded
the sighting was mistaken.

outline, NOT border, and that is load-bearing. An outline paints OUTSIDE the
box and takes no space, so it cannot move anything. The rest of this wall's gate
asserts where the print row sits at five widths; a hover that grew a border
would re-lay-out the card and push that row, so a cosmetic state could break a
gate about print controls.

:focus-within carries it to the keyboard, and @media (hover:none) drops the
whole state on touch -- with no pointer it would either never appear or stick
after a tap, leaving a card looking permanently selected.

THE GATE NOW MOVES THE POINTER. Everything it measured before was of a loaded,
untouched page, which is precisely why this affordance could go unbuilt while
being repeatedly requested.

⚠ AND ONE OF MY OWN ASSERTIONS WAS DECORATION UNTIL I MUTATION-TESTED IT. I
first asserted the card's OUTER width is unchanged by hover. It can't change:
the card is a grid item, so the track fixes its width and a border grown on
hover eats inward. MEASURED with border-width:4px on hover -- outer 224->224px,
unfalsifiable, while the CONTENT box went 222->216px. It now asserts clientWidth,
which is what actually repositions the print row.

MUTATION-TESTED: remove the outline -> FAIL at all four pointer widths (exit 1);
grow a border alongside it -> FAIL naming the content-box shift (exit 1);
restore -> PASS (exit 0), reporting "hover 2px rgb(47, 111, 219)" at 768-1920
and "no outline (touch, correct)" at 390.

The print/open controls the card also asks for are already there and were
verified clickable in the same pass: Prayer List, Card Deck, the stock select
and Poster Map, all four with full labels and real handlers, 0 clipped.
2026-09-24 23:17:42 +00:00
doxa-maps-88655c70
77989089e0 Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 22:05:31 +00:00
doxa-maps-88655c70
5a959d6b94 Two posters per row on the regions wall, with a control to change it
The Driver, verbatim: "i need two posters per row on the regiosn posters and
witha setitn got icnreat eh number of poswers per row".

The wall's own grid is repeat(auto-fill, minmax(210px, 1fr)), which lays five
across a region card. It now takes a `columns` count and an optional
`columnsControl`, both opt-in and both defaulting to the behaviour that shipped
— so country-posters and my-upg-100-list, which did not ask for this, keep the
auto-fill they have always had. Measured after the change: they still lay out
five across and neither grew a control.

The control is a <select>, not a stepper or a pair of chevrons. It is what
every reader already knows for "choose one of a few", it is keyboard- and
screen-reader-operable with no work, and it shows its current value without a
second element. Imitating the solved pattern rather than inventing one. Its
label is catalogued in eleven locales like everything else on this surface.

The probe measures GEOMETRY, not CSS. Asserting grid-template-columns would
pass on a rule that says repeat(2, …) while the cards actually wrapped three
across because one had a min-width the track could not honour — and a computed
style is exactly the kind of proxy that has fooled this suite five times today.
It counts how many cards share a rendered top coordinate, which is what a
reader sees: [2,2,2,2,2].

And it proves the control by USING it — set it to 3 and the rows become
[3,3,3,3,3]. A default that happens to be right would pass a one-shot check
with an inert knob.

One thing worth recording for the next person: the first attempt put the
explanatory comment inside the PosterWall tag's attribute list, and the Vue
compiler rejected it. An HTML comment belongs above the tag. This file had
already learned that once.
2026-09-24 22:04:28 +00:00
doxa-maps-88655c70
88a566f49d legend: the height cap now derives from the offset instead of repeating it
Some checks are pending
build-gate / build (push) Waiting to run
THE LEGEND OVERFLOWED THE BOTTOM OF THE MAP because two numbers that had to
agree were written out twice.

.stl-panel is capped at calc(100% - var(--stl-top) - 12px), but this profile
set `top` DIRECTLY with !important instead of setting --stl-top. So the panel
moved down to 58px while the cap kept subtracting 8px, and the two silently
disagreed by exactly the difference.

  MEASURED at 390px in a 420px-tall host: top 58px, max-height calc(100% - 20px),
  panel bottom 458 in a 420px map -- 38px PAST the map. Same 38px at 560px,
  because the error is the mismatch, not the map's size. After: -12px, the
  intended bottom margin, at 390/420, 390/560 and 1440/500 alike.

Two more copies of the same mistake went with it. The mobile rule paired
top:64px with max-height:calc(100vh - 72px) -- a constant unrelated to the
offset, AND the wrong box: 100vh measures the VIEWPORT while the panel is
positioned inside the MAP. Those agree only when the map fills the screen,
which is true of every standalone leaf and false the moment a host puts a
header above it. .fit-content had the same fixed 72px. Both now derive from
--stl-top, so they cannot drift apart again.

★ THIS IS INVISIBLE ON EVERY PUBLISHED LEAF. A standalone leaf gives the map
the whole viewport, and a panel that overflows a full-height map is still on
screen. It only appears when the host makes the map SHORTER than the viewport
-- which is how the element is actually embedded. Verify in a constrained
host, never on the bare leaf.

The a11y half of this work is NOT here. I fixed the same two nameless legend
controls locally while the remote was unreachable, then found on rebase that
they were already fixed on main -- better, with the row's own label
interpolated ("Expand Africa" rather than a generic string), and in one
key namespace instead of the second one I had introduced. Took main's version
whole and dropped my duplicate locale keys; only the CSS above is mine.
2026-09-24 22:00:52 +00:00
doxa-maps-88655c70
608ef3dd56 poster cards: print options wrap to full words instead of an ellipsis
The Driver rejected the ellipsis by name: the print options must LINE WRAP so
every label stays readable, because a button that no longer says what it does
has not been fixed.

Three measurements drove the CSS:

  - a <fieldset> takes min-width:min-content from the UA stylesheet, which
    OVERRIDES width and max-width. .pfs read width:82% while computing 431px
    inside a 212px card. min-width:0 goes on the fieldset ITSELF, not only on
    its children.
  - the row is anchored at its TOP (top:100%;margin-top:-14px), not its bottom.
    At bottom:-14px every extra line grew UPWARD over the preview image, which
    is how the first overflow fix broke the card it was meant to repair.
  - wrapping took the row from 35px to 78px, so the grid's row gap moves 64px
    to 88px. The old gap was tuned when the row was a single line.

No text-overflow anywhere in .pfs. min-width:0 alone had let three 76-79px
labels share 172px, showing 40px each.

The gate gains the assertion that would have caught the ellipsis: a control is
clipped when scrollWidth exceeds clientWidth. Mutation-tested -- restoring
text-overflow:ellipsis reproduces 81 of 108 clipped and exits 1. Without that
assertion the gate passed a wall whose buttons all read "Pra...".

Measured at 390/768/1024/1440/1920px: 0 clipped, 0 overflow, 0 over-preview,
0 neighbour intrusions, 108 controls.
2026-09-24 21:59:34 +00:00
doxa-maps-88655c70
db36397b93 Translate the last three English literals in components the clone renders
A dropdown every localised surface renders had no t() at all. Its two visible
strings — the clear button and the empty state — were English literals, and one
of them, "No options found", was ALREADY catalogued in eleven locales elsewhere
in this repo: the translation existed and the component was ignoring it. The
poster wall's export modal had the same shape, reassuring the reader that
nothing is uploaded in English only.

Three keys in buttons.json across eleven locales, and the literals replaced.
Both bundles that render these components — regions and country-posters,
whose cards are routed to me — are rebuilt. check:i18n-parity passes with no
locale regressed.

AND A BUILD HAZARD IN MY OWN COMMAND SHAPE, worth writing down because it is
exactly the failure the brief warns about. I ran

    BUNDLE=x bun x vite build 2>&1 | tail -2 && bun run tree && bun run fix:sri

The vite build FAILED on a syntax error — I had put a const inside
defineProps — and tree and fix:sri ran anyway, because the exit status of a
pipeline is the LAST command's, and `tail` succeeded. So the chain regenerated
the tree and re-hashed against a bundle that had not been rebuilt: the "tree
alone ships a stale .js" trap, opened by the pipe I added to keep the output
short. Caught because the next build's byte count moved, not because the chain
complained. Any agent using `| tail` after a build needs pipefail or a separate
status check.
2026-09-24 21:26:49 +00:00
doxa-maps-88655c70
9ba6ad7a7a Name the legend's carets, and translate a hint that had never been translated
TWO SHARED-COMPONENT DEFECTS, both routed to me, both a step away from where
the routing pointed.

THE 14 NAMELESS BUTTONS ARE ONE COMPONENT. check:a11y-button-names reported 14
visible buttons with no accessible name across two country-posters leaves. The
routing named countries-map.vue, which contains exactly one <button> and it has
visible text — the gate prints the LEAF, so the file was an inference. Running
the gate and then a probe that prints what a developer can grep for — class,
innerHTML, box size, the custom-element host chain — named the real one:
library/components/legends/SemanticTreeLegend.vue:1228, seven
<button class="stl-caret">, 14x14, a 9x9 chevron and nothing else. The 14 is
those 7 counted twice, because country-posters-mfe composes countries-map;
worth saying, or the next reader hunts a second defect that does not exist.
"(no title either)" in the gate's output is the diagnostic half: it rules out
the title-only control this gate was built for, and it rules out Mapbox's own
controls, which ship aria-labels. These were ours and they were empty —
assistive technology said "button" once per expandable branch.

Fixed at the source, which is why it lives in library/: an aria-label naming
the branch the caret discloses, and aria-expanded, because a name tells a
screen-reader user what the control is for and still not whether the branch is
open. Three keys across eleven locales rather than a hardcoded label. And the
same defect one element over, latent: .stl-x-chip carried only a `title` —
precisely what this gate exists to reject — and hardcoded English besides. It
escaped the sweep because it renders only while a row is selected.

Seven surfaces render that legend, so one fix reaches all of them. The
artifacts are per-bundle though: country-posters is rebuilt here because its
cards are routed to me, and my own bundle with it. The other five stay stale
until their owners rebuild, and I have not touched them.

AND THE PIN HINT HAD NEVER BEEN TRANSLATED. The ask was to audit eleven locales
for a stale "red dot". There is nothing stale, because there is nothing: no
locale file contains "Tap a", "pinHint" or "coloured dot"; PinHint.vue imports
no useI18n; the sentence is a JavaScript string literal. So every map that
renders the hint has been showing an English sentence to readers in Arabic,
Russian and Chinese.

Which is worth noticing about the fix that came before it. The colour half was
found and fixed carefully — the comment above it records the Driver reporting it
five or more times, and why a shared component must not name a colour it cannot
know. All correct. Nobody asked whether the sentence was in the catalogue. Same
shape as the Russian headings next door: wrong only for readers who do not file
bug reports in English, and so invisible to everyone who could.

Eleven locales, every one saying coloured and never red. The pinHintLabel
override still comes first, so a profile that does want to name its colour can.
And the key falls back to the English sentence when a locale lacks it, so a
missing translation degrades to today's behaviour rather than rendering the
literal "messages.pinHint.tapDot" — the failure mode of a naive t() swap.

Flagged and not taken, because they are shared components with owners:
FilterDropdown renders a hardcoded English "Clear selection" and "No options
found" (the latter already exists translated in the regions catalogue, so the
component is ignoring a string that is right there), and PosterWall's export
modal renders its reassurance line untranslated.
2026-09-24 21:14:47 +00:00
doxa-maps-88655c70
15b9c94399 Phone: the pin hint stops sitting on top of the legend sheet
MEASURED at 390x844 on doxa-research-map: the hint pill sat at y=700 and the open
legend sheet begins at y=603, so "Tap a coloured dot — someone still needs
prayer" printed straight across the Progress rows. 1,418 Needs Prayer and the
Full Prayer Coverage line were unreadable underneath it, in the phone screenshot
the README showcases.

Nothing was clipped, which is why the existing below-the-fold clamp had nothing
to say: the pill was fully visible and fully in the way. So the sheet becomes a
FLOOR, exactly as the attribution strip already is — this component has always
treated the credit line as something to clear rather than cover, and the sheet is
the same kind of obstacle. If the pill would cross the sheet's top edge, it lifts
by the overlap; only when the two really intersect horizontally, and only ever
upward, so a surface with no sheet is unchanged.

TWO THINGS THE FIRST ATTEMPT GOT WRONG, both found by re-measuring rather than by
reading the diff:

  · The sheet does not exist when the pill mounts — LegendMobile renders after the
    map's first canvas. A mount-time querySelector found nothing, so no observer
    was bound and the clamp never ran again: the pill moved 0px and the "fix" was
    a no-op that looked plausible. The observer is now bound lazily by the first
    clamp that SEES a sheet, with three settle re-measures after mount to make
    sure one of them does.
  · The sheet also changes height as the reader drags or collapses it, and each
    height is a different floor. The existing host observer watches the MAP, which
    a sheet drag does not resize, so the sheet needed its own.

MEASURED AFTER — 390, 360 and 414px wide: no overlap between the pill and any
painting part of the sheet; screenshot read at 390, where Progress now shows 689
/ 136.7M, 1,418 / 69.6M and Full Prayer Coverage in full. Desktop 1280x900
unchanged: pill still at the map's bottom edge, no lift applied. The two other
hint consumers (country-posters-mfe, regions-mfe) show no overlap either.

One probe note worth keeping: .legend-sheet-root spans the whole map with
pointer-events:none and paints nothing, so an overlap check that counts it reports
a collision no reader can see. The obstacle is the sheet, not the box it lives in.
2026-09-24 20:09:51 +00:00
doxa-maps-88655c70
c57b51044f Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-1/work
# Conflicts:
#	public/doxa-maps/index.html
2026-09-24 19:56:38 +00:00
doxa-maps-88655c70
b026c3ef83 poster cards: the print row belongs below the preview, and a fieldset ignores width
Some checks are pending
build-gate / build (push) Waiting to run
The Driver caught my own fix breaking the poster section, in the same session I
landed it. flex-wrap wrapped the print row onto three lines; the row is anchored
at the card BOTTOM, so growing it 35px to 78px pushed its TOP up from 216px to
173px in a 238px card — straight over the preview image and the outline. I
traded a horizontal overflow for a vertical one.

My gate measured only left and right edges, so it reported PASS on a visibly
broken card. Same instruments-lie family the flock banked today: a green that
measures the wrong thing.

The real cause of the ORIGINAL overflow, which both the old CSS and my first fix
missed: a <fieldset> gets min-width:min-content from the UA stylesheet, and that
OVERRIDES width and max-width. The rule said width:82% while the element
computed 431px inside a 212px card. That is why "it has a width, looks fine"
survived two rounds of this bug — the declaration was there and inert.

Fixed with min-width:0 on the fieldset itself, flex-wrap:nowrap, and flex:1 1 0
on the stock <select>, which sizes to its widest option and was taking 188px of
a 198px row.

Measured through all three states: 431x35 at top 216 overflowing, then 78px tall
at top 173 covering the preview, now 172x35 at top 216 — one line, below the
184x184 preview, inside the 212px card. Clean at 390/768/1024/1440/1920px with
all 108 controls full size.

The gate now measures the vertical axis and sweeps five widths. Mutation-tested
against my own regression: restoring flex-wrap fails with "27 print rows COVER
the preview image (37px)" at four of the five widths. 390px stays clean, which
is exactly why single-width testing would have missed it a second time.
2026-09-24 19:54:43 +00:00
doxa-maps-88655c70
f22609fb3c Clone the six global rules the component's own stylesheet never showed
The Driver looked at the two sides and named the components: "the tbabel
compoent is not the same", and of Prayer Coverage / Adoption Progress /
Engagement Progress, "thes three are not th esame css of the dx lfie site, it
properl spces and lcolros thigns while ours doe snot". He was right about both,
and for one reason: I ported RegionDetail.vue and its <style scoped>, and the
rules that differ are not in either. They are in the global sheet every page on
their site loads.

  base/_globals.scss:99-105  every <table> is a 25px-rounded, #666-ringed WHITE
                             card; :205-207 gives every <td> a 1px #666
                             gridline. Ours was bare and borderless.
  composition/_stack.scss    .stack spaces children with margin-top, not gap —
                             and .region-goal{margin-top:auto} OVERRIDES it.
                             Ported as gap, which no margin can override, so
                             both applied and every goal card carried an extra
                             23.526px. That is his "spacing is not the same".
  base/_globals.scss:19-23   every <section> is padded
                             clamp(--spacing-2xl,6vw,--spacing-4xl) top and
                             --spacing-4xl bottom: 73.614px on each side of the
                             countries table, where we had none.
  base/_globals.scss:132-141 every <a> is --color-brand-light at weight 500
                             with a 2px/4px underline. Their country cell is a
                             NuxtLink; ours is a <button>, which inherits none
                             of it and read as plain body text.
  base/_globals.scss:200-202 <strong> is 500, not the UA's bold. The "Goal:"
                             label wears it three times.

All five ported, and the remaining .stack ports converted from gap to their
margin-top so the next margin-overriding child behaves as it does on their
page. Measured against live doxa.life/regions/africa: 107 properties
identical, 0 CSS differences, 0 structural-text differences.

The measurement gap matters more than the fix. _probe-region-vs-live.mjs
reported 79/79 IDENTICAL through every one of these and could not have failed:
goalCard's spec was padding/radius/background/colour/align with no gap, so the
property that spaces the children was never read; table's was minWidth and
width; td had no border; and the country link had no entry in the selector map
at all. The spec now also reads gap and flexDirection on the card, marginTop on
its children, the td border, the table's radius/background/box-shadow/overflow,
section padding, strong weight, rowGap on all five stacks, and a new tdLink
pair — 107 properties.

Also, the Driver's other report on that screen: "poster not generated yet ...
it is not supposed to say not generated yet it is supposed to show the
previews". Ethiopia's country-eth.webp exists — 174 of the 175 records have
theirs, India being the one that legitimately does not and carrying its own
reason. Measured on the built tree and on the dev server: every preview request
200, zero cards in that branch, and the lazy contract holds (nothing fetched
until the dropdown is opened). So the file is not missing; a host layout is.
PosterWall now tries the self-contained copy generate-tree stages inside the
tree before giving up, and when it does give up it no longer claims the poster
was never generated — it says the preview is unavailable, which is the only
thing a failed image request establishes.
2026-09-24 19:46:36 +00:00
doxa-maps-88655c70
7ae1572c94 poster cards: the print-options row stays inside its own card
Some checks are pending
build-gate / build (push) Waiting to run
The repo-hygiene agent found this in a fresh 1440px screenshot, which is the
whole lesson: .pfs already had width:82%, so reading the CSS said it was fine.
Flex items default to min-width:auto and refuse to shrink below their content
width, so four controls — Prayer List, Card Deck, the stock select, Poster Map —
pushed the row open regardless of what the container declared. A constrained box
does not constrain flex children, and only geometry shows that.

Measured at 1440px on cap4-upg100-mfe: 27 of 27 cards overflowing, worst 110px
past a 212px card, 36 intrusions into a neighbouring card's box. After
flex-wrap:wrap plus min-width:0 on the children: 0 and 0, with all 108 print
controls still a usable size.

That second number is asserted deliberately. "Nothing overflows" is trivially
satisfied by a row whose buttons collapsed to zero width, which would be a worse
product than the overflow it replaced. check:poster-card-fits holds both, and is
mutation-tested: reverting the fix reproduces the reporter's exact figures.

Also adds check:backlog-refs — a card may not depend on a card that does not
exist, because a dangling depends_on can never be done, so the card can never
become ready and nothing says so.

Its first run disproved the finding that motivated it. I had censused three
dangling references, including the PRINCIPLE card the loop prompt lists as open
work. The gate came back PASS, and chasing the discrepancy rather than assuming
the gate was broken showed my census was wrong: it read only the builder
backlog, and all three cards exist in 01-ideation-research. Cross-phase
references are legitimate, and a single-backlog census structurally cannot see
them. Reporting three dangling refs would have sent someone hunting for cards
that were never lost.

It fails only for depends_on on an OPEN card; a dangling parent is reported and
passes, because failing on filing debt teaches people to delete the reference
rather than restore the card. Both severities mutation-tested.
2026-09-24 19:19:02 +00:00
doxa-maps-88655c70
6e958bc22f legend: open on the UPG-100 list, which is what the Driver asked for first
Some checks are pending
build-gate / build (push) Waiting to run
Measured at the rendered layer: the legend opened on REGION at desktop and
mobile. That is the Driver's first complaint about this legend word for word —
"it shouldn't have me sitting there on Regions, I don't know what to do, it's
confusing" — and the card recorded that part as shipped.

Every source signal said it worked. LEGEND_DEFAULT_TAB = 1 existed, defaultTab
was a declared prop, it seeded a local ref, and it was named in a watch fallback.

The root cause is one layer down. SemanticTreeLegend resolves its tab as
`instance ? instance.activeTab.value : localActiveTab.value`, and
usePplrInstance initialises activeTab to ref(0). Zero is indistinguishable from
"the user chose tab 0", so for every profile sharing an instance store the
defaultTab prop was dead — read on every render, never able to win. An
activeTabTouched sentinel fixes it: the store only wins once something actually
chose a tab, meaning a tab click or a node selection that jumps to that node's
generation. A sentinel rather than a new default, so no other profile's landing
tab moves as a side effect.

And a regression of my own, from the previous commit. There was only one
:default-tab left in the profile and it was on <LegendMobile>, where defaultTab
is not a prop. Last tick I "dropped the inert attribute" with a string replace
that matched the DESKTOP block first, deleting the working one and leaving the
inert copy. A dropped attribute is silent, so the build stayed green. Restored
to SemanticTreeLegend, and LegendMobile now has a real defaultTab prop so the
phone honours it too.

Both widths open on "UPG-100 Lists", and on ?lang=es both open on "Listas
UPG-100" — locale-independent. A user's own choice still sticks: clicking Region
holds at 0, and selecting a row does not snap back, so the sentinel is not a
trap.

check:legend-export-reachable still passes and now selects a real zone rather
than a region, because the legend opens on the Lists tab. The two fixes compound:
a phone user lands on the lists and can export the one they pick.
2026-09-24 17:59:57 +00:00