Commit graph

532 commits

Author SHA1 Message Date
doxa-maps-88655c70
1c24759e21 feat(posters): UPG-100 prayer packet generator, letter portrait, i18n
Some checks are pending
build-gate / build (push) Waiting to run
The packet as specified: page 1 the map one-pager, pages 2..n the people
groups 20-per-page with photos, then the 12 prayer points, then the prayer
wheel. Every string is externalised so a new language is a new block in
packet-strings.json, not a code change; fallback is PER KEY, so a partial
translation renders what it has and leaves the rest in English rather than
blanking. Verified with a partial es block before removing the stub.

Three bugs worth recording, each of which rendered silently wrong:

1. zoom, NOT transform:scale(), for print. transform is paint-time, so the
   sheet still RESERVES its 1941px layout box and the paginator breaks it
   across two pages, inking only the top of the first: 8 sheets came out as
   16 PDF pages. Negative margins do not help — they collapse the outer
   space, not the fragmentation box. zoom is layout-time, so pagination sees
   the scaled box. Measured: transform 2 pages/sheet, transform+margins 2,
   zoom 1.

2. The map SVG carries almost no presentation attributes — generate.mjs
   styles it from the poster document's stylesheet. Lifting the <svg> alone
   left those rules behind, and SVG fill falls back to its initial value,
   which is BLACK: 670 elements painted solid black with no error, no broken
   image, nothing any existing assertion could see. Fixed by carrying the
   poster's stylesheet minus its document-level rules, so a palette change
   in generate.mjs still follows the packet automatically.

3. LANG is the shell's POSIX locale ("en_US.UTF-8"), not a BCP-47 tag, and
   throws in localeCompare. PACKET_LANG is the only source.

The verification lesson is the bigger one. The probe PRINTED "PDF pages: 16"
for several runs while reporting the packet verified, because the bad counter
never looked at that value — a number that is displayed but not asserted on
is decoration. Then the first fix for the black map asserted on the first
<rect>, the one element just repaired, and went green while 647 others were
still black. Both assertions now test the property rather than a proxy: one
sheet must be exactly one page, and the map as a whole must not be
predominantly black-filled. Both were confirmed to FAIL on the known-broken
input before being accepted.
2026-09-24 01:39:26 +00:00
doxa-maps-88655c70
32a073d3cc Rebuild regions-dashboard after the merge — main had my source and its old bundle
MY ERROR, caught by re-running the live diff on what I had just pushed. The
push loop resolved the merge's generated-file conflicts from main and then ran
`bun run tree` + `fix:sri` — but NOT `BUNDLE=regions-dashboard vite build`. So
main briefly carried the new source (the vendored font, the restored sizes)
alongside main's older .js, which contains none of it: grep for "bebas-neue" in
the shipped bundle returned 0.

The tell was the same one that caught the 404: document.fonts reported
`registered faces: []` — not an error, NOTHING REGISTERED — because the code
that registers the face was not in the bundle. The computed-style diff dropped
straight back to 61/68 with the 7 font differences, which is exactly what a
stale bundle looks like from the outside.

Rebuilt from the merged source: bundle references the font, face "loaded",
68/68 identical again, check:sri green.

The lesson for the loop: on a merge that touches a bundle's SOURCE, regenerating
the tree is not enough — `tree` rewrites pages and the manifest from source but
never compiles a bundle. The bundle has to be rebuilt before fix:sri, or the
hashes agree about the wrong bytes.
2026-09-24 01:38:02 +00:00
doxa-maps-88655c70
9e1c5ec196 Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
Generated-file conflicts only; resolved from main then regenerated with tree + fix:sri.
2026-09-24 01:36:06 +00:00
doxa-maps-88655c70
b55044ed95 Vendor Bebas Neue: the last 7 region-page differences were all one font
MEASURED before: the region dashboard matched live doxa.life on 61 of 68
computed properties, and every one of the 7 differences traced to a single
cause — Bebas Neue could not be loaded. Three were the font-family itself, and
four were workarounds it forced: an uppercase hack imitating Bebas's caps, and
two font-sizes stepped down because a wider fallback face overflowed their card
widths. So it was never seven problems, it was one with six knock-ons.

DX-life does not ship the file either — @nuxt/fonts downloads and self-hosts it
for them, which is why their computed family reads "Bebas Neue", "Bebas Neue
Fallback: ...". We could not @font-face from their origin: their file is served
without CORS headers, so a cross-origin face fails silently. The font is a
Google Font under the OFL, so it is vendored here from Google Fonts — both
subsets, their own unicode-ranges (css2 v16) — self-hosted, never a CDN, the
same rule public/assets/vendor/tabulator follows.

library/composables/useDocumentFont.js is shared because @font-face is
DOCUMENT-scoped: a face declared inside a shadow root is ignored, so a custom
element cannot register its own webfont through useShadowStyles. That asymmetry
is the reason this is a separate composable rather than a flag on that one.
Idempotent per document, so a composite registers one face for both panes; both
profiles call it because each has a standalone leaf page where the other is
absent.

With the real face loaded, the workarounds are DELETED rather than kept: the
goal-card heading is back to their 59.6046px, the headline figure back to
59.6046px, and text-transform:uppercase and font-stretch:condensed are gone —
Bebas is already caps and already condensed, and stacking those changed the
glyphs. The three-across switcher still holds at 372/372/372 with no overflow,
which is the geometry that forced the step-downs in the first place.

★ THE CATCH THAT MATTERED. With the font vendored the diff said 68 of 68
IDENTICAL, 0 differ — and it was WRONG. document.fonts reported our face in
"error" status: generate-tree stages an ALLOWLIST of asset dirs into the tree,
fonts/ was not in it, so the leaf resolved assets/fonts/... to a 404 and every
heading silently rendered on the fallback. Both sides were agreeing on a
FALLBACK, which is not a match. A font that 404s is invisible to any check that
reads font-family instead of asking the engine whether the family is usable, so
the probe now calls document.fonts.check() and the staging list carries fonts/.
After the fix: usable true, face "loaded", and the headings render in Bebas.

Two honest notes. The fallback TAIL still differs — theirs carries @nuxt/fonts'
generated metric-fallback names we do not produce — so the probe compares the
FIRST family (the face actually used) and reports the tail as an accepted
difference rather than counting it either way. And document.fonts.check()
returns false on THEIR page, which contradicts it visibly rendering in Bebas,
so that check is trusted in one direction only: it proves ours loaded, it does
not prove parity.

The licence ships beside the font, extensionless: check:public-hygiene refuses
*.txt in a served folder and is right to, while the OFL requires the licence to
accompany redistributed font files. The conventional name satisfies both.
2026-09-24 01:35:48 +00:00
doxa-maps-88655c70
b93b4cb984 One prop swaps the engine for every map, not just three
Some checks are pending
build-gate / build (push) Waiting to run
The switch existed and did not reach. resolveMapProvider has long read
profile-config.provider -> ?provider= -> window.MAP_PROVIDER, but
useMapInstance defaulted `provider` to the LITERAL 'mapbox', so the page
switch only reached a map whose bundle happened to pass provider through.
MEASURED: 3 of the 11 bundles that mount a map. The other 8 pinned Mapbox
whatever the page asked — it LOOKED global and was not.

The factory now resolves it when a caller leaves it unset, which moves the
decision to the one place every map already goes through. An explicit
caller still wins (prayer-walk-planner pins maplibre deliberately), and
with no switch set resolveMapProvider returns 'mapbox', so the default is
unchanged.

DashboardMapPanel collapsed anything that was not the literal 'maplibre' to
'mapbox', which outranked the page switch. But dashboard.config.js pins
Mapbox DELIBERATELY — the Driver's ruling for Mapbox Standard's native
starfield globe — so that default had to survive. Both hold by putting the
page switch BETWEEN the per-element config and the bundle default:
  1. profile-config.provider   this embed
  2. ?provider= / MAP_PROVIDER the page — "swap every map on this site"
  3. dashboard.config.js       this bundle's shipped default (Mapbox)

MEASURED across seven map leaves, default vs ?provider=maplibre, counting
requests to api.mapbox.com:
  doxa-simple-map          21 -> 4    engine swaps
  country-posters          24 -> 4    engine swaps
  research-dashboard       25 -> 4    engine swaps (was pinned)
  doxa-research-map        16 -> 4    engine swaps
  research-map-plus-grid   16 -> 0    fully keyless
  my-upg-100-list          19 -> 19   NOT swapped — see backlog
  regions-dashboard         4 ->  4   no map on that leaf

Simple Research reaches ZERO Mapbox requests, which is the case that
matters most: it is the one carrying the search bar, and the geocoder is
the quota risk (GeocoderComponent's maplibre path still geocodes through
Mapbox, so a keyless map with a search bar is not keyless).

Two gaps left, both filed rather than hidden: my-upg-100-list bypasses
useMapInstance entirely and cannot see the switch, and generated leaves
still preload mapbox-gl.js/.css + the geocoder from api.mapbox.com even
under maplibre (library downloads, no token, but still a CDN dependency —
the composite leaf proves 0 is reachable). The second belongs to the tree
verb, which is not mine to change.

22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
2026-09-24 01:34:03 +00:00
doxa-maps-88655c70
3bafe7cd55 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/prayer-walk-planner/prayer-walk-planner.js
#	public/doxa-maps/prayer-walk-planner/prayer-walk-planner/index.html
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
2026-09-24 01:15:03 +00:00
doxa-maps-88655c70
79353ca66e One page, one map token — and the nested search bar comes back
"the element gets the tk to the element itself and does not need to only
gatekeep that to one component only — all components in a plugin can share
that tk" (Driver, 2026-09-23).

Inside ONE element that was already true: ProfileLoader provides
'mapboxToken' and eleven components inject it. The gate is the ELEMENT
BOUNDARY — every custom element mounts its own app with its own
ProfileLoader, which re-derives the token from its own profile-config, so a
composite that nests a real map hands the child nothing.

THE EXISTING FALLBACK COULD NOT FIRE. The ladder already read
window.MAP_TOKEN. MEASURED: that global IS set on the composite leaf and
the child still had no search bar — because the ladder lives in a
computed() and window.MAP_TOKEN is a plain global, not a reactive source.
The composite leaf FETCHES its token, so the computed resolved '' first,
cached, and never re-ran. useSharedMapToken makes it a ref that updates on
publish, and assignments to window.MAP_TOKEN are adopted through an
accessor, so already-generated pages keep working untouched. Storage lives
on window and changes travel as an event: each bundle is its own IIFE, so a
module-level ref is NOT shared between a composite and the bundle it nests.

THAT WAS NECESSARY AND NOT SUFFICIENT, which only measuring showed. With
the token shared and reactive the bar was STILL absent. Real cause:
GeocoderComponent's onMounted hit `typeof MapboxGeocoder === 'undefined'`
and RETURNED SILENTLY — no throw, no console error, no DOM. MapboxGeocoder
is "preloaded via index.html", true for a LEAF and false for a COMPOSITE
leaf, which generate-tree classifies as token-free while it goes on to nest
a real map. Measured, mapbox-gl-geocoder references per page: research-map
leaf 2, research-map-plus-grid leaf 0.

So the script is now fetched on demand when a host did not preload it,
reusing the exact shape the maplibre path already had (one loader, two
callers) and pinned to the same v5.0.0 the leaves preload. It resolves on
error as well as load, so a blocked CDN leaves the map usable and the bar
absent rather than hanging. This fixes every already-generated page without
regenerating any of them.

MEASURED on the built tree:
  leaf mapbox     search@56    legend@104
  leaf maplibre   search@NONE  legend@58    (unchanged: maplibre stays bar-free)
  composite       search@56    legend@104   (was NONE)

library/ reaches every bundle, so all 22 rebuilt: 22/22, 7/7 pre, 18/18
post, and a browser sweep of five StackLayout consumers plus all seven
studio dashboards.
2026-09-24 01:14:37 +00:00
doxa-maps-88655c70
390b90b343 Prayer-walk POSTERS: per-zone walking-scale street map on a poster sheet
Some checks are pending
build-gate / build (push) Waiting to run
The runtime sibling of the UPG-100 poster generator (that one is
build-time over 28 fixed zones; walk zones are drawn live). For each
selected zone the camera fits the zone bounds (maxZoom 17), waits for
idle, and snapshots the keyless MapLibre canvas INSIDE a render frame
-- triggerRepaint + once('render'), because MapLibre does not preserve
the WebGL buffer and toDataURL outside a frame returns a blank white
image that still counts as a 'valid' data URL (measured: the first
sheet shipped a white rectangle; pixel check now reads avg 212
brightness, 0% blank). Camera restored afterwards.

The sheet borrows the poster design language: dark-green title band,
kicker with the zone centre, framed street map with the zone polygon
painted on it, a prayer charge line, zone corners, and the honest
credit (Map (c) OpenFreeMap, Data (c) OpenStreetMap contributors) --
letter portrait, printed in place via the shared
@map/composables/printInPlace.js. Agent flow re-verified end-to-end:
addPolygon -> selectPolygon -> print() raises the dialog once.

(Also restores the public API wrappers a block edit clobbered --
caught by the page's own ReferenceError before commit.)
2026-09-24 01:06:51 +00:00
doxa-maps-88655c70
245e0f960e Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
Generated-file conflicts only; resolved from main and regenerated with bun run
tree + fix:sri so the derived tree describes the merged source.
2026-09-24 00:59:30 +00:00
doxa-maps-88655c70
06c6455fdc Regions: the selected page is now an exact clone; the chooser is the one addition
The rule: a SELECTED region or country must look identical to the DX-life page
you land on when you click it, and the ONLY thing we add is the state their
site has no route for. Three things followed from that.

1. THE BAR CHART IS GONE. It was ours, not theirs. RegionDetail.vue is four
   stat cards, the map, three goal cards and the country table — nothing else.
   The goal cards and the country table now belong to a SELECTED region only;
   they were showing on the no-selection view as totals, which made the chooser
   look like a region page wearing the wrong numbers.

2. THE COUNTRY LIST IS THEIR UupgsList, ported. CountryDetail.vue:63-69 nests
   <UupgsList :lock-country-code hide-see-all-link />, so cloning that page
   means cloning that component — a DataTable of ours was a stand-in, not a
   clone. src/UupgsList.vue carries their markup and their class names so the
   CSS is theirs rather than a lookalike: .filters / .filters__toggle /
   .filters__panel / .filters__active / .filter-chip, the .search-box with its
   1.5em text-indent, the .grid[data-width-lg] 400px track, and the
   .uupg__card grid-template-areas with its 576px reflow. Their endpoint and
   field list too, and .uupg__content stays display:none as theirs is.
   Scoped to what the page can actually reach: CountryDetail passes none of
   useSelectCard / useHighlightedUUPGs / randomizeList / dontShowListOnLoad, so
   the select-card variant, the /highlighted endpoint and the see-all link are
   branches no consumer runs.

3. THE CHOOSER is the one deliberate difference, and it is now the only one.
   Opening tab 2 first is a state DX-life cannot reach (/regions is the list,
   /regions/<slug> always has a subject, [slug].vue 404s otherwise), so it gets
   a generic map of everything plus three ways in: a region dropdown, a country
   dropdown (both the shared FilterDropdown), and the all-regions table whose
   rows were already clickable. Choosing a subject replaces it with the clone.

Also removed a visible difference nothing had flagged: the shared PinHint's
"Tap a coloured dot" coach-mark was floating over the cloned map, and their
page has no such overlay. pinHint:false now, and the fix had to go in the
COMPOSITE — a child profile's meta only configures its own leaf page, while a
nested pane is handed the config regions-mfe.vue builds, and PinHint reads it
off the injected profileConfig. Same cap-4 inheritance gap assetsBase hit, so
pinHint joined INHERITED_PAGE_KEYS as well.

Verified against the LIVE page, not the stylesheet: doxa.life/regions/cameroon
at 1440x1000, filter panel open on both sides, 28 of 28 computed properties
IDENTICAL, 0 differ. That diff found the one real error — their .button sets no
font-size so it inherits 19.5312px, and I had hardcoded 15.625px. Two rows it
could NOT measure are reported as unmeasured rather than counted either way:
.filter-chip is absent on both sides with no filter selected.

Every suite still green: the map draws 934 pins / 51 outline for a region and
61 / 5 for a country, the country page renders 8 UupgsList cards with their
filter toggle and search box, no page has a coach-mark, and all six viewports
still match their container formula.
2026-09-24 00:59:10 +00:00
doxa-maps-88655c70
04a55c66b2 Prayer Walk Planner: full-page dev mount + agent-triggerable in-place print
Some checks are pending
build-gate / build (push) Waiting to run
Two halves of the Driver's directive:

1. FULL PAGE (BUG-PRAYER-WALK-PLANNER-MAP-DOES-NOT-FILL-THE-PAGE).
   The BUILT leaf was already full-viewport; the DEV staging page boxed
   the element in a centred 640px card -- measured 781x584 at y=206 on
   a 1440x900 viewport, exactly 'the map is not filling the full
   page'. The dev harness now mounts fixed inset:0 like the leaf
   (two-host rule).

2. AGENT-OPERABLE PRINT. PrayerWalkPlanner.print() used window.open,
   which is pop-up-blocked without a user gesture -- an agent calling
   the documented API got silence. The poster-wall's in-place
   hidden-iframe mechanism (e957cbe) graduates VERBATIM to
   @map/composables/printInPlace.js (library bar: 2+ consumers,
   parameterized) and the planner uses it; @page pins letter portrait
   (the 839b0c4 ruling).

Verified end-to-end as an agent would: registry -> addPolygon ->
selectPolygon -> print() => {opened:true,count:1}, hidden iframe
print() fired once, portrait pinned, zero pop-ups.
2026-09-24 00:58:10 +00:00
doxa-maps-88655c70
ad534beb64 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	public/doxa-maps/doxa-research-map/doxa-research-map.js
#	public/doxa-maps/doxa-research-map/research-map/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 00:44:26 +00:00
doxa-maps-88655c70
e7bc86d4f2 Map engine is one prop: MapLibre drops the search bar, legend rises
Simple Research showed a Fly + Clusters toolbar where the search bar used
to be, and the map offered no way to pick an engine without writing a
second map.

FLY + CLUSTERS ARE NOW LEAF-ONLY. LegendTools renders at top:14px centred
on the legend column — the search bar's exact slot — so on the Regions and
Languages tabs (the legendTypes its predicate allows) it reads as having
REPLACED the search bar. MEASURED on the leaf: fly toolbar at y=58 against
the search bar at y=56, the two on top of each other. In a stack whose
bottom half is a grid the reader is scanning, a fly-tour that moves the
camera underneath fights the other half, so the composite passes
legendTools:false. The standalone leaf keeps both.

THE ENGINE IS ONE PROP. resolveMapProvider already read profileConfig
.provider, so research-map.instances.json publishes the maplibre instance
and a partner running thousands of embeds changes one value instead of
maintaining a second map. Mapbox stays the default: it carries the richer
feature set. MapLibre is the keyless option for volume.

MAPLIBRE SHIPS NO SEARCH BAR, ON PURPOSE. GeocoderComponent's maplibre path
still calls makeMapboxForwardGeocode(accessToken) (:526) — the geocoding
REQUEST goes to Mapbox whichever engine draws the tiles. Shipping the bar
under maplibre would keep billing the exact key the option exists to avoid
while LOOKING free. Under mapbox it stays, unchanged.

AND THE LEGEND RISES INTO THE VACATED SPACE. SemanticTreeLegend's
--stl-top defaults to 60px that exists only to reserve room for a search
bar; leaving it when the bar is gone is a dead band. MapToolbar's
has-geocoder collapses its own reserved gap the same way.

MEASURED against the built tree:
  leaf mapbox      search@56  legend@104   fly@NONE (Prayer) / @58 (Regions)
  leaf maplibre    search@NONE legend@58   — legend rose 46px
  composite        search@NONE legend@104  fly@NONE on Regions

7/7 pre, 18/18 post.

KNOWN GAP, pre-existing and NOT introduced here: the mapbox composite still
shows no search bar. mapboxToken is injected per element and the composite
passes neither tk nor tokenUrl to its child, so the child's geocoder gets
an empty token and bails while the tiles render from another path. Isolated
but not fixed.
2026-09-24 00:43:44 +00:00
doxa-maps-88655c70
3b7aa1fed4 Merge main into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
One conflict, in the generated root page (it inlines the whole manifest).
Resolved the way a derived file should be: taken from main, then regenerated
with bun run tree so it describes the merged manifest, followed by fix:sri.
Nothing else in the tree moved — no bundle was rebuilt, so main's own build
outputs are untouched.
2026-09-24 00:35:08 +00:00
doxa-maps-88655c70
8578854cbc Search tier 2: a misspelled country resolves to THE country (best match, 2-edit cap)
Some checks are pending
build-gate / build (push) Waiting to run
Driver: 'if I search the name of the country and add n to it, like
Austrian or Canadian, I would like for there to be a selection of that
country -- the table under the map shows the result for that country.'

Three changes, one ladder:
1. resolveFuzzyValues(): WHOLE-VALUE Levenshtein against the DISTINCT
   values of every search key -- 'Sudann' is a misspelling of the
   VALUE 'Sudan', so the rows returned are exactly Sudan's, identical
   to picking it as a Place. The windowed matcher stays only as the
   final rescue tier.
2. Best match wins: only the minimal-distance value(s) resolve. The
   under-the-cap union admitted 47 rows for 'Canadian'.
3. Both fuzzy tiers cap at 2 edits absolute (the did-you-mean bound);
   threshold*len alone reached 3 errors at 8+ chars, which is where
   the cross-facet noise lived.

Measured (dashboard scope / grid count agree): Sudann -> 105/105
Sudan only; Indonesa -> 36/36 Indonesia; Banladesh -> 3/3 Bangladesh;
Bhutann -> 13/13 Bhutan; exact terms unchanged (tier 1).

NOTE: Canada/Austria themselves have NO rows in this dataset (it
holds only the 175 countries with unreached people groups), so
'Canadian'/'Austrian' cannot select them -- a handful of genuine
2-edit neighbours return instead.

Also ships @map/composables/retryableAsyncComponent.js wired at every
defineAsyncComponent site (ProfileLoader chokepoint + 8 lazy
components): a transient ERR_NETWORK_CHANGED no longer permanently
kills a lazy plugin (3 retries, growing backoff). Live fault-injection
proof pending; the wrapper is the documented Vue onError contract.
2026-09-24 00:34:19 +00:00
doxa-maps-88655c70
54860914bf Merge remote-tracking branch 'upstream/main' into bundler-1/work 2026-09-24 00:34:05 +00:00
doxa-maps-88655c70
a334e8ebff Merge main into bundler-1/work
Main moved 20+ commits while the regions work was on its own branch. Every
conflict was a GENERATED file — manifest.json, the root page, and the
regions-dashboard bundle's .js and leaf pages. Zero source conflicts: all six
of my source files (the two regions .vue, the new map child, fix-sri.mjs,
sri-consistency-check.mjs, package.json) auto-merged.

Generated files are not hand-merged. They were resolved to main's side and then
REGENERATED from the merged source — BUNDLE=regions-dashboard vite build, then
bun run tree, then bun run fix:sri. `bun run tree` rewrote every bundle's pages
and produced byte-identical output to main's committed ones for all twenty
other bundles, so the only files this commit changes beyond the merge are the
six that had to be rebuilt. That agreement is also evidence main's committed
tree matches main's source.

The pinHint:false regression I saw before is gone: main's 566ad46 committed the
two metas that were previously only on disk, which was the root cause — a tree
regeneration now preserves them instead of dropping them.

Re-verified against the merged library, which is the part that mattered: main
changed useMapInstance.js, StackLayout.vue and ViewSelect.vue, all of which the
regions element sits on. All three suites still pass — the map draws 934 pins /
51 outline features for a region and 61 / 5 for a country, the pin mode still
follows the subject, the country page still renders CountryDetail's shape, and
every width still matches DX-life's container formula at six viewports.

23 of 25 gates green. The two that fail are pre-existing and not in this diff:
check:library-index is an mtime artifact (regenerating the index produces
byte-identical content), and check:backlog-status reports 6 of 546 non-enum
statuses in the shared _qa-cycle board, which lives outside every repo.
2026-09-24 00:33:28 +00:00
doxa-maps-88655c70
7d38f90b3d The map legend says what it shows, and opens there
Some checks are pending
build-gate / build (push) Waiting to run
"Zone" was our internal word leaking into the UI. The thing the map paints IS a
UPG-100 list -- 27 of them, one per printable packet -- so the tab is named for
that, and the legend opens on it. Region stays, described as what it actually
does: sort those lists by world area when you want that.

ORDER IS DELIBERATELY UNCHANGED, and the comment now says why. Tab INDEX is
coupled to tree DEPTH inside the shared legend -- `activeTab = Math.min(sel.depth,
...)` and again on breadcrumb -- and the tree nests region(0) -> zone(1) ->
group(2). Moving the list tab to index 0 to make it open first would have made
selecting a zone jump to the Region tab instead, and SEVEN profiles share that
component. I started that way and backed it out.

Opening on the list tab uses the mechanism already built for it: `:default-tab`,
added for country-posters with the same need ("it opens to the country's semantic
level in the legend"). One prop at the call site, no shared behaviour touched,
and the component clamps an out-of-range value so it degrades to the top
generation rather than blanking.

LegendMobile takes neither tabs nor defaultTab -- it derives its own view -- so
there is nothing to wire there.

Card: BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST (the rename + default
half; print-from-selection is still open and is the packet's entry point).
2026-09-24 00:30:14 +00:00
doxa-maps-88655c70
90b8109fd0 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 00:26:30 +00:00
doxa-maps-88655c70
dd81a8818c Status line: which plugin this session is working on
Several builder sessions run at once, each in its own worktree, and from
the outside every one looks like a terminal printing tool calls. Asking
"which plugin are you on?" cost a round trip every time.

  doxa-maps · bundler-2/work · ▶ research-map-plus-grid (cap-4)

What it reports, in order: the bundle with the most recently modified
UNCOMMITTED file (that is what "working on" means); else library/ or
internal/ named as a scope, because an edit there is not one plugin's
business and naming whichever bundle happens to be dirty would be a lie;
else the last plugin touched in the recent commits, so the line still says
something true at the moment work lands instead of going blank.

The capability tier is read from manifest.json with the studio's own
formula (5 - PT[type], plugin-studio.vue:293), not a table retyped here.
Two copies of that mapping with nothing forcing agreement is the shape
check:capability-citations exists to catch — the README once captioned a
bundle cap-1 while the manifest said cap-2.

Details that were wrong on the first cut and are now measured: the repo
name comes from the remote, not the checkout directory, or every session
gets labelled with a worktree name nobody recognises (wt-bundler-2); and
the commit fallback uses `git log -m`, without which a MERGE commit lists
no files and the line read "idle" immediately after landing work.

Fail-soft throughout and never exits non-zero: no git, no branch; no
manifest, no tier; nothing edited, "idle". A status line runs on every
render, so one that errors or prints a stack trace is worse than none.
~172ms per render, no absolute paths (check:no-abs-paths).

Verified across all five tiers plus the library/ and internal/ scopes;
7/7 pre, 18/18 post.
2026-09-24 00:26:24 +00:00
doxa-maps-88655c70
98cb50cc4b Search is TIERED: exact matches win outright, fuzzy is only the typo rescue
Some checks are pending
build-gate / build (push) Waiting to run
Driver: 'fuzzy search is too fuzzy -- Bhutan returns more results than
just the country, and there is no interoperability with the other
filters like selecting it as a place.' The first fuzzy pass was a
UNION (exact + every <=k-error neighbour), so the exact term Bhutan
counted 132 rows while Country=Bhutan counted 13 -- two controls
disagreeing about one place.

Now tiered, the conventional search contract: when a term has ANY
exact substring match, only exact matches return -- search agrees
with the facet (13 = 13). The k-errors pass runs solely when exact
yields zero (Bhutann -> 13, Banladesh -> Bangladesh). Applied in the
shared Fuse.search (grid) AND a filterRowsBySearch helper used by the
shell + all four panels (table, facet rail, charts rail, map), so
every surface computes the same tier.

Measured: Bhutan 132 -> 13 (grid 'Showing 13' agrees), Bhutann 13,
Banladesh rescued, gibberish 0, clear 2,107.
2026-09-24 00:21:32 +00:00
doxa-maps-88655c70
39b9a4cca9 Regions: port DX-life's map on our map library, and clone the country page
Completes the clone. Two things were missing: the map both their pages render,
and the fact that a COUNTRY is a different page from a region.

THE MAP — plugin-designs/regions-dashboard/src/RegionCountryMap.vue.
Layers are DX-life's, expression for expression (CountryMap.vue:186-240): the
country-boundaries fill at 0.12, the 2px outline, the single-worldview filter
that stops the highlight drawing three times, the prayer / in-country colour
cases, the circle-sort-key that puts better-covered pins on top, the
zoom-interpolated radius, 0.75 opacity on neighbours, their seven colours.
The machinery is ours: theirs does `new mapboxgl.Map` in a Nuxt page, which
cannot work in a shadow root — container-by-id does not cross the boundary, the
async CSS link gives a first-initialising map a 0-height canvas, and the
wordmark duplicates per instance. @map/composables/useMapInstance.js already
solves all three, so this supplies only the layers. Points come down from the
parent instead of a second fetch, so the pins and the figures above them
provably describe the same rows.

THE COUNTRY PAGE — CountryDetail.vue is head + map note + map + the people-group
list, with NO stat cards and no goal cards. It now renders that, not the region
page wearing a country's numbers. Its map passes no color-by-prayer, so pins
read in-country vs surrounding, which is what its own note tells you to look
for. The list uses @map/components/tables/DataTable.vue rather than a third copy
of their 662-line UupgsList — this repo already ships that as people-groups-grid,
and nesting the real element is a cap-4 composition question.

Four defects found by measuring, not by reading:

1. A CANVAS AT THE RIGHT SIZE IS NOT A MAP. With a <template> branch per subject
   kind, region -> country unmounted one map and mounted another into a
   zero-size container: 1200x780 canvas, correct centre and zoom, layers [],
   nothing drawn, a blank white card — and every assertion passed. One instance
   driven by props now, and the suite asserts queryRenderedFeatures (934 pins /
   51 outline for Africa, 61 / 5 for Cameroon) instead of canvas dimensions.

2. isStyleLoaded() IS THE WRONG GATE for adding layers. It also waits for the
   style's sources, which never arrive at zoom 6 on this box because the local
   token is URL-restricted and its high-zoom tiles 403. Same code drew a region
   and drew nothing for a country. Keyed on 'style.load' — the style being
   PARSED is what makes addSource/addLayer legal — with a parsed-style fallback.

3. THE PIN MODE WAS FROZEN at whatever it was when the layer was created, so a
   country page painted prayer colours. Caught in a screenshot by one yellow
   "1+ praying" pin; invisible to every assertion, because the pins were
   rendered and the legend was correctly absent. applySubject now re-applies
   circle-color and circle-sort-key.

4. THE PROFILE CONFIG ARRIVES BY INJECT, NOT AS A PROP. The element this bundle
   registers IS ProfileLoader, which parses the profile-config attribute and
   provides the parsed object (ProfileLoader.vue:195). Both tabs read it off
   their own string prop, which is always empty — prayBaseUrl, languageCode,
   instanceId and assetsBase all silently defaulted. Nothing looked broken until
   assetsBase mattered: the vendored Tabulator 404'd twice with no error and the
   country list just never drew. Fixed in both tabs, prop kept as a fallback.

Token delivery needs no wiring: the generator detects a map under the bundle and
bakes the ladder itself, so all three leaves now carry it and
check:nested-token passes.
2026-09-24 00:10:27 +00:00
doxa-maps-88655c70
566ad4676e Commit the two pinHint:false metas that were only on disk
Some checks are pending
build-gate / build (push) Waiting to run
cities-internet/connectivity-map.meta.json and
gospel-resources/resources-map.meta.json carry the pinHint:false
opt-out (no 'Tap a coloured dot' on maps with no dots). They were
authored and BUILT INTO the leaf pages but never committed -- a clean
clone would silently regress the fix on its first rebuild.
2026-09-24 00:01:08 +00:00
doxa-maps-88655c70
bd94cf6ed4 Dashboard -20px, map untouched: shellH 582 -> 562
Driver fine-tune: 'almost perfect height -- keep the map the same
height, but decrease the height of the dashboard by about 20px.' Map
row stays 340; the 20px comes out of the table's derived share.
Storage key v5 -> v6. Measured live: shell 703 -> 683, map 340,
table 244 -> 224.
2026-09-24 00:01:08 +00:00
doxa-maps-88655c70
8653147cfc Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-23 23:58:42 +00:00
doxa-maps-88655c70
839b0c453d A letter-portrait sheet, an honest credit, and a privacy line
Some checks are pending
build-gate / build (push) Waiting to run
FORMAT=letter. The same poster on paper a home printer takes. Width stays
1500 for the reason the 3x4 note already gives -- every width-derived
measurement (the 330 gutter, the name columns, the header budget) was
calibrated there and carries over untouched. Only height moves: 8.5x11 at
width 1500 is 1941. Layout, text and spacing are unchanged; the extra height
goes to the label columns, which is where 95 names wanted it.

PRINTS AS ONE PAGE -- and this fixes the SQUARE sheet too. The stage is sized
in CSS pixels while @page declares a physical size, so a browser mapped 1500px
to 15.6in and TILED it: measured, the 3x3 sheet printed as FOUR pages. It went
unnoticed because these posters were always screenshotted by shoot.mjs, never
printed from a browser. A scale computed at generate time (page width in px
over stage width) makes one sheet equal one page on every format. Verified via
page.pdf: letter 612x792pt x1 page, square 216x216pt x1 page (was 4).

ATTRIBUTION CORRECTED. The credit said "(c) Mapbox (c) OpenStreetMap". This
file's own header says NO Mapbox, NO token, NO external fetch at render time,
and the basemap is bundled GeoJSON -- so it credited a vendor contributing
nothing, which is the kind of line that makes a later reader add a token "to
comply". The real source is geoBoundaries (the adm0/adm1 files carry its
shapeGroup/shapeName fields), CC BY 4.0, which REQUIRES attribution. OSM is
named only when OSM tiles are actually fetched (BASEMAP=1).

PRIVACY LINE on the sheet. People-group coordinates arrive already generalised
from the publisher -- deliberately not precise locations. A printed sheet
travels past the person who made it, so it now carries that fact itself. Worded
as provenance, not as a claim about processing we do not perform. It gets its
own row: sharing the credit row was a measured collision (516-984px against a
stamp ending at 543), and it is a sentence to READ, not a provenance stamp.

Also: a backtick inside a CSS comment inside a JS template literal broke
generation outright -- the trap check:template-literal exists for.
2026-09-23 23:57:42 +00:00
doxa-maps-88655c70
32132459cf Merge main into bundler-2/work
# Conflicts:
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/default/index.html
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-23 23:54:22 +00:00
doxa-maps-88655c70
080ef16b74 Rebuild the StackLayout consumers so the Simple Research fix ships
The cap-4 fix lives in library/layouts/StackLayout.vue, which is compiled
into every bundle that imports it, so the built output had to be
regenerated for the fix to exist in the shipped tree: dashboard-plus-grid,
doxa-maps-spa, doxa-research-map, research-map-plus-grid, stack-demo.
Consumer list re-derived by grep after the merge rather than reused.

Verified against the merged tree: 7/7 pre, 18/18 post, and a browser sweep
of all five StackLayout consumers plus all seven studio dashboards, with
the studio root showing the Simple Research map.
2026-09-23 23:52:59 +00:00
doxa-maps-88655c70
da3e3dc589 Map +40px again: shellH 542 -> 582, mapH 300 -> 340
Some checks are pending
build-gate / build (push) Waiting to run
Second Driver raise, same routing as the first: shell and map move
together so the whole gain lands in the map row; the table's derived
share stays at its height. Storage key v4 -> v5 so the new default is
seen immediately. Measured live: map row + canvas 340px, table 244px,
shell 703px.
2026-09-23 23:49:55 +00:00
doxa-maps-88655c70
be77ea954c Merge main into bundler-2/work 2026-09-23 23:48:10 +00:00
doxa-maps-88655c70
61bd1cf8c5 Share menu opens at natural height in a page header (no scrollbar)
Some checks are pending
build-gate / build (push) Waiting to run
MapControlMenu's 'fit inside the map' height clamp is right for a map
host but wrong for ShareButton's header fallback: the fallback tags
the ~56px header band as the area (data-map-area='share-fallback'),
and the menu clamped INSIDE it -- measured on upg-100-mfe, a 291px
share menu rendered with a 69px scrolling body. A menu must never
outgrow a MAP's walls, but a header band is not a wall (nothing clips
below it), so a share-fallback area now bounds against the VIEWPORT.

Measured: header host body 291/291 no scroll, menu 342px tall below
the band; map host (doxa-simple-map) unchanged, 287/287 within the
map. Real map areas keep the original walls -- only the self-tagged
fallback takes the new branch.
2026-09-23 23:47:28 +00:00
doxa-maps-88655c70
ae388d6da8 cap-5 shell: drop contain:layout from .vtl-content -- fixed scrims cover the viewport
Some checks are pending
build-gate / build (push) Waiting to run
contain:paint was removed earlier for the scroll bug, but LAYOUT
containment also makes .vtl-content the containing block for every
position:fixed descendant, so all modal scrims hosted in the shell
resolved inset:0 to the CONTENT PANE: measured, a fixed inset:0 probe
read x=256 w=1184 on a 1440 viewport -- the 256px nav rail stayed
clickable behind an open modal. Visibly almost right, interactively
wrong.

The perf rationale for keeping it does not hold: a nav collapse
changes this element's own width, and a width change re-lays-out the
subtree with or without containment.

Verified: fixed probe now 0,0 1440x900; check:scrim-viewport PASS
(3 scrims cover the viewport in the shell); check:fits-screen PASS
(scroll/zoom/reachability contract intact); nav collapse round-trips.
2026-09-23 23:40:13 +00:00
doxa-maps-88655c70
02fc4bd45c Regions tab 2: clone DX-life's region dashboard
Tab 2 was a subject band, four tiles and one bar chart. DX-life's /regions/<slug>
is five blocks, and the gap was the whole point of the tab. Ported from
RegionDetail.vue, block for block:

  • back-link / title / summary, centred          RegionDetail.vue:47-51
  • four stat cards                               :53-70
  • three DOXA goal cards in their .switcher      :88-141
      Prayer Coverage — the segmented 100+/1+/none bar with its legend
      Adoption Progress and Engagement Progress — progress bars
      each goal sentence verbatim; they are the DOXA goals, not our copy
  • the five-column country breakdown             :144-171

Every figure comes from the rollup this element already computed, so tab 1 and
tab 2 still cannot disagree about a number. Nothing was invented to fill a card.

WHAT HAPPENS WITH NOTHING SELECTED, which was the open question: DX-life has no
answer to borrow — /regions is the list, /regions/<slug> always has a subject,
and [slug].vue 404s when the slug matches neither. A 2-tab element can't do
that, since tab 2 is reachable before anything is clicked. So it keeps their
invariant (the dashboard always has a subject) and makes the widest honest
subject the default: All Regions, every figure summed, the table switched from
countries to regions so it still compares like with like, and a line saying how
to narrow it. The back-link returns there instead of navigating.

Two substitutions, both forced by Bebas Neue being uncloneable (their font file
is served without CORS) and both measured, not guessed:
  • goal-card h2 pinned to --font-size-2xl (38.147px), the FLOOR of their own
    clamp. Measured: 'Engagement' is 380.1px at their 59.6046px in our stand-in
    face and 243.2px at 38.147px, against 261.55px of inner width. Their size
    made each card's min-content ~490px, so the third wrapped to its own row —
    579/579/1200 instead of their 372/372/372.
  • goal-card figures stepped one rung to --font-size-3xl and pinned nowrap, so
    '21 / 2,107' cannot break after the slash and read as two numbers.
Also min-width:0 on the switcher children: a flex item's automatic minimum is
its min-content, which is what blocked their basis from governing.

Verified against the LIVE site, not against the stylesheet: 33 computed
properties on doxa.life/regions/africa at 1440x1000 are now IDENTICAL, 0 differ,
and the four stat figures match their API exactly (315 / 41 / 43,045,275 / 300).
That diff is also what caught a real error — I had the low end of their 1.33
spacing scale shifted a step (xs is 1rem = 10px, 2xs is 7.5188px, 3xs is
5.6532px), so table cells and stat labels were a step tight. Every ported value
is now exact token math rather than one decimal place.

23 assertions across the three selection states, plus the tab-1 swap: reverting
this file to HEAD and rebuilding fails 21 of them.

Not ported: RegionDetail's CountryMap. It needs a per-region centre/zoom/bounds
rollup and a token-bearing map child, and this profile ships isMap:false — a
separate piece of work, not a detail. The comparison chart stands where their
map goes until it lands.
2026-09-23 23:33:51 +00:00
doxa-maps-88655c70
bfcb4eece9 Share hash is readable: code-canonical value + display-only label slug
Some checks are pending
build-gate / build (push) Waiting to run
Was URL-encoded JSON (#f=%7B%22affinity_bloc%22%3A%22A008%22%7D) --
unreadable twice over, and a bare API code is unauditable by whoever
receives the link. Now the Stack Overflow /id/slug pattern:

    #f=affinity_bloc:A012*south-asian-peoples;religion_family:Hinduism

The CODE stays canonical; the *slug comes from the same labelFor()
resolver the chips and CSV provenance use, and the parser STRIPS it --
so links are locale-independent and survive label renames. Literal
%;:*+ in values are percent-escaped so the delimiters cannot collide;
spaces ride as '+'. Legacy JSON hashes (old shared links) detected by
their leading brace and still restore.

Measured: click -> readable hash; fresh page with it -> scope 881
restored; legacy JSON hash -> 881 restored.
2026-09-23 23:28:00 +00:00
doxa-maps-88655c70
3685b196cb Show the cap-4 composite in the studio's Simple Research slot
The cap-5 shell's "Simple Research" tab rendered an error panel instead of
the map, in both trees:

  Could not load <doxa-research-map> from ../../doxa-research-map/
  doxa-research-map.js

StackLayout already anchored sibling-bundle srcs to the tree root, but it
anchored on the /doxa-maps/ segment of window.location.pathname. The studio
mounts at the SERVER ROOT (/index.html), which has no such segment, so the
lookup missed and the literal '../../' was returned untouched. The browser
resolved it against '/', where '../' cannot climb, and requested
/doxa-research-map/doxa-research-map.js — 404, both children, empty stack.

That is why it survived: every gate and every screenshot used a LEAF url,
and the studio is the one host that is not one. The same composite at its
own leaf renders perfectly.

resolveBundleSrc now falls back to bundleTreeRoot() — the bundle's OWN
script URL, which rollup rewrites to document.currentScript.src in the IIFE
build. That is a fact about where the code came from, not a guess about
where the page is, which is the lesson stack-demo.vue wrote down when this
defect appeared for the third time. It returns null in dev ESM and for any
non-<name>/<name>.js layout, so it never guesses. The pathname rule stays
FIRST and the untouched-string fallback stays LAST, so every path that
worked before is byte-identical: this can still only fix a path, never
break one.

research-map-plus-grid also adopts stack-demo's bundleSrc() dev/prod
helper. In dev, vite serves entries at /plugin-designs/<bundle>/index.js and
there is no <bundle>/<bundle>.js to reach, so the dev surface 404'd too.
import.meta.env.DEV is statically replaced, so the built output is
byte-identical to the literal it replaces.

Verified in a browser against the built tree, reading the screenshots:
studio root -> Simple Research paints the world map with its prayer legend,
on the dev server too. Negative-tested: disabling the new fallback and
rebuilding restores the exact 404 and the empty canvas.

No regression across every StackLayout consumer — rmpg shallow leaf, rmpg
deep default/ leaf (the case the pathname rule was written for),
spa -> Simple Research, dashboard-plus-grid, stack-demo — and all seven
studio dashboards. 7/7 pre, 22/22 bundles, 18/18 post.
2026-09-23 23:26:40 +00:00
doxa-maps-88655c70
37abfc3129 Rename the facet rail title: Tree Legend -> Vertical Legend
Some checks are pending
build-gate / build (push) Waiting to run
Driver naming ruling: no 'tree'/'semantic' in the user-facing name;
the drill-down vocabulary is NESTING/FOLDERS. Verified live: the
rail's fr-title renders 'Vertical Legend'.
2026-09-23 23:15:35 +00:00
doxa-maps-88655c70
ff0a996778 Search is FUZZY again, Fuse-parity: typo tolerance restored on every surface
Some checks are pending
build-gate / build (push) Waiting to run
DX-life's search bakes in real Fuse.js { threshold: 0.4, ignoreLocation,
minMatchCharLength: 3 } (UupgsList.vue), which tolerates typos -- an
extra trailing letter ('Bhutann') or a substitution ('Indoa') still
finds the country. Two things were lost in migration:

1. The vendored @map fuzzy.js 'fuzzy' fallback was a strict SUBSEQUENCE
   matcher -- an EXTRA needle char always fails a subsequence, so the
   exact typo class Fuse tolerates was the class it rejected. Replaced
   with the standard k-errors approximate-substring DP (Sellers):
   match iff errors/pattern-length <= threshold, same score contract
   as Fuse with ignoreLocation. Highlight indices = best window.

2. The dashboard's makeSearchMatcher was a plain .includes() -- fuzzy
   never reached the topbar search at all. It now delegates to the
   shared fuzzyMatches() with the exact DX-life options, so the
   dashboard and the grid agree on one matcher.

Measured: dashboard 'Bhutann' 0 -> 13, 'Indoa' 0 -> 1,319, 'zzqqx' 0,
clear 2,107; grid standalone 'Bhutann' -> 13 Bhutan cards, highlights
render. Exact terms now also admit <=k-error neighbours ('Bhutan' 13
-> 132) -- the same widening real Fuse applies in DX-life.
2026-09-23 23:08:48 +00:00
doxa-maps-88655c70
ea390cee7f Regions: honour DX-life's 1200px container cap, and its mobile clamps
Our regions cards stretched to the full pane at every width. Measured at a
1920px viewport they were 1880px wide; DX-life's /regions caps at 1200px and
centres. Ported their .container verbatim from
app/assets/styles/composition/_container.scss:

    width: calc(90% + 16px); max-width: 1200px; margin-inline: auto

so the column is fluid until ~1316px of width, then pinned at 1200px. Applied
to both tabs — RegionDetail.vue and CountryDetail.vue both carry
class="container", so the dashboard half is capped by the same rule.

The mobile half was a separate miss: the first port resolved every clamp() at
its DESKTOP maximum, so a 390px phone got a 47.7px region title and 41.6px of
side padding. DX-life gives that phone the clamp FLOORS — 30.5px and 17.7px.
The clamps are clamps again, in container-query units rather than vw: this
element is also one pane of the regions-mfe composite, where the viewport is
not the width available, so cqw honours their decision (scale with the width
you have) instead of transcribing a unit that only works in their layout. At
the leaf page the pane is the viewport, and the two resolve identically.

Measured against their formula at six viewports, both tabs, real browser:
1920/1440 -> 1200.0, 1280 -> 1168.0, 1024 -> 937.6, 768 -> 707.2, 390 -> 367.0,
each within 1.5px of min(0.9*pane + 16, 1200), no horizontal overflow. Title
font-size tracks clamp(30.5, 4cqw, 47.7) exactly: 47.7 at 1280+, 40.96 at 1024,
30.72 at 768, 30.5 at 390. Reverting both files to HEAD and rebuilding flips
all 12 assertions to FAIL, so the measurement is not vacuous.
2026-09-23 23:07:52 +00:00
doxa-maps-88655c70
e957cbe85f Print the poster in place, on a portrait sheet
Some checks are pending
build-gate / build (push) Waiting to run
Two Driver asks on the UPG-100 poster wall, both measured at the source.

PORTRAIT. The poster path was the only one of the three print paths using
@page{size:auto}; the list and card-deck paths already pin `letter portrait`.
The poster art is a 3ft x 3ft SQUARE, and a square on an auto page has no
reason to prefer the tall axis, so it landed landscape. Pinned to
`letter portrait` to match its siblings.

The print img rule then had to change with it: `max-height:none;width:100%`
scaled the square to the full page WIDTH, which on portrait letter is taller
than the printable height -- it would have overflowed onto a second page.
Constrained both axes so object-fit letterboxes the square inside the sheet.
VERIFIED via page.pdf({preferCSSPageSize}): 612x792pt, ONE page.

IN PLACE. openPrintWindow() opened a popup and document.write()'d into it.
That was deliberate -- an earlier Driver ask wanted a preview window -- and is
now reversed: the dialog must open with no second browser window. A hidden
same-page iframe keeps the entire existing contract (one full <html> string per
caller, the markup's own <img onload> -> print()) while the dialog belongs to
this page. All three print paths stay on the one mechanism.

It also deletes a failure mode: an iframe cannot be pop-up blocked, so the
"Pop-up blocked -- allow pop-ups" branches every caller carried are unreachable.
Their messages now name something the user can act on instead.
VERIFIED in Chromium: 0 window.open calls, 1 iframe, context page count stays 1.

The noopener post-mortem above the helper is KEPT as history -- the code it
described is gone, but "a flag that returns null by spec looks exactly like an
environment problem" is the lesson worth carrying.
2026-09-23 23:00:41 +00:00
doxa-maps-88655c70
446ec38c5b Religion/Language tree TOP TIERS now filter the grid (derived-axis interop)
Some checks are pending
build-gate / build (push) Waiting to run
The vertical legend's Religion and Language top tiers emit DERIVED
family names ('Hinduism', 'Indo-European') that exist on no row field,
so they narrowed the dashboard but never the people-groups grid below
-- the last two dead axes of the tree-sidebar interop bug. The grid
now runs the SAME derivations (DERIVED_AXES) from the SAME library
modules the dashboard config reads (RELIGION_FAMILIES,
canonicalFamilyName, langFamilyByIso), routed through the shared
valueMatches() by a one-line rowAxisValue() resolver in BOTH filter
loops. imb_language_family added to the grid fetch; composite maps
religion_family/language_family onto the new props; the dashboardOnly
stopgap flags are retired.

Root cause of the first failed attempt: the re-filter watcher
hand-enumerated every filter prop -- the THIRD copy of the axis list
-- so the new props changed propFilters but never triggered a
re-filter. The watcher now watches propFilters itself: one owner,
future axes cannot miss it.

Measured round-trip: Hinduism -> dashboard 693 / grid 'Showing 693';
Indo-European -> 677/677; clear -> 2,107 both.
2026-09-23 23:00:00 +00:00
doxa-maps-88655c70
7a22290fc6 Route the +40px dashboard gain to the MAP row: mapH 260 -> 300
Some checks are pending
build-gate / build (push) Waiting to run
Driver follow-up: the taller dashboard's display room belongs to the
map, not the table. shellH stays 542, so the table's derived share
returns to its prior height and the map row carries the whole gain.
Storage key v3 -> v4 so a just-persisted layout cannot mask it.
Measured live: map row + canvas 300px, table 244px, shell 663px.
2026-09-23 22:51:09 +00:00
doxa-maps-88655c70
e3a82afdc4 Choropleth: darken greyscale bin 1 so low-count countries read as data
Some checks are pending
build-gate / build (push) Waiting to run
Bangladesh (3 groups) WAS rendering -- queryRenderedFeatures returned
the BGD polygon -- but painted #e8e8e8 against the #f3f3f1 no-data
surface: a 1.06 contrast ratio at 0.78 opacity, pixel-identical to an
empty country. Bin 1 is now #c9c9c9: the ramp's first step must be
separated from NO-DATA at least as clearly as from bin 2. Legend
swatch follows automatically (derived from BIN_COLORS).
2026-09-23 22:47:14 +00:00
doxa-maps-88655c70
3ca6912381 Dashboard +40px: shellH default 502 -> 542, extra height flows to the table
Some checks are pending
build-gate / build (push) Waiting to run
One owner: DEFAULTS.shellH, CSS fallbacks kept byte-equal at 542.
Storage key v2 -> v3 so a persisted 502 layout cannot mask the new
default. Measured live: shell 623 -> 663px.
2026-09-23 22:44:23 +00:00
doxa-maps-88655c70
e2ff53cb96 Vertical legend: sticky section headers, markdown-file style
Some checks are pending
build-gate / build (push) Waiting to run
PEOPLE/PLACE/LANGUAGE/RELIGION headers stay pinned while their rows
scroll, and the next section's header pushes the previous one away --
position:sticky top:0 inside the scrolling .fr-body, the exact GitHub
markdown-preview mechanism. Opaque panel background so rows scroll
under the pinned label instead of through it.
2026-09-23 22:39:32 +00:00
doxa-maps-88655c70
9bddccbb5e The typed search is a filter, so it now looks like one
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "I searched Bhutan in the search bar and it did not switch the
toggle to show the country was selected in the filters."

MEASURED what actually happens: typing narrows the dashboard live through
bus.searchTerm (scope 2,107 -> 13) while filterSet stays empty -- the data
changed and no control anywhere said why. Invisible state, the same defect
class as the silent grey pane.

The conventional faceted-search answer: free text is its OWN chip beside
the facet chips -- "Search: Bhutan x" -- removable like any of them, and
Clear all clears it too. It deliberately does NOT masquerade as a Country
selection, because the term matches across ALL searchKeys (name, region,
language, religion...) and labelling it a country filter would lie whenever
it matches something else.

One archaeology note that cost a first attempt: the fix initially landed in
DashboardFilterChips.vue, a component the topbar dedup pass had already
UNMOUNTED -- its own comments name the filters band as the single source of
truth for chips, which is where this now lives.

Playwright round trip: type -> chip present, scope 13; Clear all -> chip
gone, scope 2,107.
2026-09-23 22:35:13 +00:00
doxa-maps-88655c70
ab83fd4ca7 Chart legends: the conventional pattern, nothing cleverer
Some checks are pending
build-gate / build (push) Waiting to run
Driver, after rejecting both experiments: "I cannot scroll right, and the
column numbers have this super white background -- it looks odd, it was to
be invisible... I am not impressed with that design. Use conventional
designs only."

He is right, and the two rejected variants earned it: scroll-to-read hid
the numbers' geometry behind a gesture, and the frozen column needed a
painted backing precisely BECAUSE something could slide beneath it. The
conventional row every chart library ships has neither problem: swatch,
label that ELLIPSIZES at the available width (full name in the row's
existing tooltip), count right-aligned in a fixed-width slot. No horizontal
scroll, no sticky, no background -- the number needs no backing because
nothing can ever slide beneath it.

Measured, both legends: transparent count background, position static,
right-aligned, ONE shared right edge, labels ellipsizing, zero horizontal
scroll. Screenshot read before pushing.
2026-09-23 22:14:33 +00:00
doxa-maps-88655c70
80d5981229 People-group links go to doxa.life from any host; stale layouts retire
Some checks are pending
build-gate / build (push) Waiting to run
THE LINK (Driver pasted localhost:5174/research/pitaguari/): the card's
"Full profile" base defaulted to root-relative /research/, which resolves
against whatever origin hosts the element -- right on doxa.life, wrong on
the dev server, an iframe, or a partner site. Same class as the ../../
child-src bug. Adopted the pattern the shared PeopleGroupDetail already
uses: window.DOXA_SITE_URL override, else the absolute canonical origin.

TWO owners had to move, and the first fix alone measured as a no-op: the
computed's fallback AND the prop's own default -- a non-empty prop default
is always truthy, so the canonical branch was unreachable until the prop
default became ''. Unset must mean unset.

Playwright on a non-doxa origin: all 24 rendered links now
https://doxa.life/research/<slug>, zero relative.

LAYOUT KEY v2: the Driver sees unequal sidebars because his browser
restores a layout saved under the OLD defaults (rail 264), and a restored
value outranks any default by design. Defaults changed materially today, so
the storage key is versioned: everyone starts once from the new symmetric
defaults; a fresh drag re-personalises from there.
2026-09-23 22:10:18 +00:00
doxa-maps-88655c70
8d41a8fdef Selection turns the table bar into a toolbar instead of a flood
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "when I click on a people group the header of the table is flooded
with buttons that overflow and don't make sense" -- and, on one of them:
"it does not need a Clear Selected button in the header because the footer
of the page already gives me that."

THE CLEAR X IS GONE from the header pill. Verified what actually clears
before repeating the premise: the thead master checkbox and the detail
drawer's close both call clearSelection, so the removal deletes a duplicate
trigger, not the capability.

THE FLOOD HAD TWO CAUSES, both measured at 1280 where the middle column is
686px: the centred hint, and -- after hiding it -- the in-flow title
squeezed to 48px and wrapped INTERNALLY ("People"/"Groups" on two lines),
growing the bar to 43px and tumbling the spacer. While a selection exists
the title and count duplicate what the pill and the section header already
say, so all three yield, state-scoped, and return on clear. The title also
gets white-space nowrap unconditionally: a title may never two-line itself.

Playwright, both 1280 and 1440, real dispatched clicks, full round trip:
select -> bar 32px, one line, zero overflowing controls, no clear x;
uncheck -> count 0, title and count return.
2026-09-23 21:46:21 +00:00
doxa-maps-88655c70
d9eca08c44 Chart counts are a real table column, on the Driver's own prescription
Some checks are pending
build-gate / build (push) Waiting to run
"The number column needs to use some type of TABLE css so the numbers
really are set apart as a column, uniform and justified right, so it does
not move when I scroll left and right."

He is right that table layout is the honest tool: display:table guarantees
every row's count cell shares ONE column edge in every browser ever
shipped. The previous subgrid attempt measured correct here but subgrid is
25 years younger than table layout; the table needs no support caveat.
Rows are table-rows, swatch/label/count are cells; the count cell keeps
sticky right:0 with an opaque background so a scrolling label slides under
a pinned column.

Measured, both legends: display table, cells right-aligned, exactly ONE
unique right edge at rest AND while scrolled. And with the rail widened to
296 in the previous commit, neither legend currently overflows at all --
the wider sidebar bought the words their room, as intended.
2026-09-23 21:35:40 +00:00
doxa-maps-88655c70
564af57c4e Symmetric sidebars: the charts rail matches the left sidebar, centring the map
Some checks are pending
build-gate / build (push) Waiting to run
Driver: 'the charts sidebar can be wider so it is the same width as the
left sidebar, which will put the map in the exact centre.' rail 264 -> 296
everywhere the number lives (inline var, both CSS fallbacks, the
composable's DEFAULTS, the doc comment) -- one quantity, every owner moved
together this time.

Measured: facet 296, rail 296, map margins 297 both sides, centred true.
2026-09-23 21:33:07 +00:00