Commit graph

88 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
doxa-maps-88655c70
e315487e2b Reclaim the fold: dashboard 849 -> 623, and the people are visible
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "it is a priority to reclaim height... so I can see half of the
first row of the people groups grid." Measured after: shell bottom 623,
first card top 640, half the card inside a 900px viewport -- the faces show.

THE HEIGHT: map 360 -> 260 and shell follows the same row arithmetic
(260 + 32 + 7x26 + 28 = 502); headers slim again (topbar 44 -> 38, stats
26 -> 22). Seven data rows unchanged.

THE ZOOM: a shorter map now shows MORE world, not less. Default zoom
1.45 -> 1.1 opens on the whole sphere, minZoom 1 -> 0.5, and the mercator
"no duplicate world" floor is skipped on the globe -- a sphere has no
second world, so the floor only stole zoom-out range.

TWO MORE SECOND-OWNER BUGS FOUND ON THE WAY, both now dead:
- research-dashboard.vue passed its own defaults {mapH:360, shellH:720} to
  useDashboardLayout, silently overriding the composable's DEFAULTS -- the
  bundle carried BOTH and the stale copy won, so two reclaim passes changed
  nothing. The override is deleted; one owner.
- one grid-template-rows fallback spelled 40svh WITHOUT a space and
  survived the fallback sweep -- at 900px that IS 360, re-pinning the row.

ALSO: the chart count column is now a real GRID column (label 1fr, count
auto, rows joined via subgrid), because per-row flex put each count at its
own row's right edge the moment the legend scrolled -- "less like a column,
more like cards". Measured: one shared right edge before and after scroll.
2026-09-23 21:31:05 +00:00
doxa-maps-88655c70
11bd2bcd5c Religion chart shows high-level religions once; count columns align
Some checks are pending
build-gate / build (push) Waiting to run
RELIGION ROLLUP. "It should not mention Ethnoreligion and Islam multiple
times -- just the high-level religions." The rollup already existed as the
religion_family facet; the donut was simply pointed at the fine-grained
religion facet. One word moves it. Measured after: Hinduism 693, Islam 650,
Ethnoreligion 610, Buddhism 83 -- each named once. The full Sunni/Folk
breakdown still lives in the facet rail's Religion tree. religion_family is
dashboardOnly by design, so a slice click narrows the dashboard but not the
grid below -- documented, and the right trade for a glanceable donut.

COUNT COLUMN. Uniform, right-justified, immovable, in BOTH donut legends:
min-width 3.4em + text-align right on the shared .rd-li rule, on top of the
existing sticky right:0 and tabular-nums. Measured: every count 42px wide,
right-aligned, digits in one scannable column.

Known nit, deliberately left: Religion can show two "Other" rows -- one is
a real family label, one is the topN overflow bucket. Worth a rename of the
overflow bucket later, not a silent merge of different things.
2026-09-23 21:20:59 +00:00
doxa-maps-88655c70
64f1f2bf7b Table fullscreen: native Fullscreen API, so exit and Export CSV are reachable
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "there is no way to exit full screen or export csv when you enter
full screen on the table."

The bar carrying both controls IS inside the wrap, but the overlay used
position:fixed, which inside a shadow root resolves against any
transformed or contained ANCESTOR rather than the viewport. In the cap-5
shell that pinned the wrap to a pane whose top sat above the visible area,
so the bar -- and its exit and export controls -- rendered off screen. The
leaf host has no such ancestor, which is why every leaf measurement passed
while the shell was broken: the two-host rule again.

element.requestFullscreen() is the wheel the platform ships: guaranteed
viewport coverage regardless of ancestor transforms, native ESC, and a
fullscreenchange event so the button state follows every exit path. Two
shadow-boundary details mattered and are documented in the code:
document.fullscreenElement retargets to the shadow HOST (and
host.contains(inner) is false across the boundary), so the sync reads
getRootNode().fullscreenElement; and our ESC handler delegates to
document.exitFullscreen() so all consumers stay in sync. The .is-full
overlay stays as styling hook and API-less fallback.

Verified in the shell with a screenshot read before pushing: native
fullscreen engages, "Exit full screen" and "Export CSV" both visible
top-right, full column headers, ESC exits.
2026-09-23 21:14:29 +00:00
doxa-maps-88655c70
df1ce0feed Collapsing the table gives its space to the map, in page-scroll too
Some checks are pending
build-gate / build (push) Waiting to run
Driver: collapsing the table is supposed to increase the size of the map;
instead it introduced white space under the collapsed bar.

The collapsed template still used 1fr for the map row, and 1fr cannot grow
in an auto-height shell -- no fixed total to divide -- so the freed space
pooled under the bar. The page-scroll collapsed state now derives from the
same shell budget as the open state: map = shell minus the bar, on both the
row and the element. One owner, every state.
2026-09-23 21:06:00 +00:00
doxa-maps-88655c70
a9655e319e One fallback, one owner: the sidebar cap now agrees with the table about the shell
Some checks are pending
build-gate / build (push) Waiting to run
Two rules read --rd-shell-h with DIFFERENT fallbacks: the table keyhole fell
back to 602px (seven rows) while the sidebar cap fell back to 80svh = 720px.
Nothing sets the var at rest, so the sidebars believed the shell was 119px
taller than the table did. The spanning side columns stretched the auto
table row to fit, and the 118px surplus painted as the grey band under and
around the table -- and made divider drags look broken, because the row
would not follow; the slack just moved.

The one-quantity-one-owner rule applies to FALLBACKS too: same var, same
fallback, everywhere. Cap unified at 602px.

Measured through real drags, screenshot read before pushing:
  at rest        gap 1px, 7 rows, table dead-space 118 -> 2px, sidebars -2px of table bottom
  divider up 80  gap 1px, 10 rows, sidebars still flush
  shell +120     gap 1px, 15 rows, shell 733 -> 855, sidebars follow
2026-09-23 21:02:15 +00:00
doxa-maps-88655c70
510af26fb5 Close the grey gap: pin the map ROW to the same var as the map element
Some checks are pending
build-gate / build (push) Waiting to run
In page-scroll the map row was 1fr, which stretches to the implied content
height while the map element stays pinned at --rd-map-h -- the row grew,
the element did not, and the difference painted as dead space above the
table. Reported three times. The row now uses the same var as the element,
so they are one quantity and the slack cannot exist.

This exact rule was applied earlier today and reverted on a false signal --
a probe said no shell, but the real cause was a dead dev server, not the
CSS. Re-verified this time against a live server: map bottom 488, table top
489, gap 1px, seven data rows, with a screenshot read before pushing.
2026-09-23 20:56:41 +00:00
doxa-maps-88655c70
ae01086662 The galaxy the way Mapbox does it: engine fog with stars, black space
Some checks are pending
build-gate / build (push) Waiting to run
Mapbox renders space and the starfield engine-side through the fog API
(documented in their globe guide: stars and atmosphere are customized with
the fog property). Applied their documented example values with one change:
space-color is pure black on the Driver's instruction, star-intensity 0.6.

The container behind the sphere is plain black now, no gradient -- on
Mapbox the engine paints its own space so it never shows; on an engine
without fog (MapLibre) black is the honest fallback.

Verified by reading the rendered screenshot: stars in black space,
atmosphere glow at the limb, vivid blue oceans, the choropleth legible.
2026-09-23 20:54:57 +00:00
doxa-maps-88655c70
3bd2a7bebf Dashboard globe runs on Mapbox Standard: the look the Driver kept naming
Some checks are pending
build-gate / build (push) Waiting to run
Every wording of the ask described Mapbox itself -- the globe space style
with stars, the atmosphere, water like the Mapbox style. Mapbox Standard
renders all of that natively; no found clone matches the original. The
working baked token is already accepted as public by explicit ruling.

One key changed. The MapLibre path (VersaTiles Colorful, keyless) stays
fully built and reversible by flipping provider back.
2026-09-23 20:51:22 +00:00
doxa-maps-88655c70
1fbc29e6e1 Galaxy corrected: day-side Earth in space, not a dark basemap
Some checks are pending
build-gate / build (push) Waiting to run
The Driver, correcting the first attempt: "I don't want a dark basemap, I
didn't ask for one. I asked for a spherical globe with a black background --
the galaxy, like the Mapbox map -- with blue oceans like the Mapbox map, and
the land was not to be black."

The first galaxy tinted dark tiles: black land, recoloured water -- the
night side of Earth. What was asked for is the DAY side seen from space.

So the style entry is now a normal-coloured basemap with no paint overrides
at all (mapbox standard / OpenFreeMap liberty), and the blackness lives
where it belongs: the map CONTAINER behind the sphere, a deep radial space
gradient scoped to [data-projection="globe"] so flat mercator keeps its
neutral ground.

Verified by reading the rendered screenshot before pushing: a real sphere,
blue oceans, normal land, the greyscale choropleth legible on top, black
void behind the globe, and the old caption gone.
2026-09-23 20:42:26 +00:00
doxa-maps-88655c70
1dec28b2d8 Remove the map caption, and make the globe projection reach MapLibre
Some checks are pending
build-gate / build (push) Waiting to run
The caption "UPG choropleth - click a country to filter - click a pin to
select" is gone, along with its now-dead .rd-maptag rule and a comment that
claimed the bottom-left corner was occupied.

GLOBE: the projection default was already 'globe', but the request was not
arriving. Mapbox GL takes setProjection('globe') -- a bare string. MapLibre
v5 takes setProjection({type:'globe'}) -- an object. Passing a string to
MapLibre throws, an empty catch swallowed it, and the map silently fell back
to flat mercator with no console error. That is why it appeared exactly when
the dashboard moved to provider maplibre.

Now tries the object form first, then the string, so one call serves both
engines. The catch stays so an engine with no projection support degrades
rather than breaking the panel -- but it now catches a real unsupported case
instead of masking a shape mismatch.
2026-09-23 20:39:18 +00:00
doxa-maps-88655c70
d9fbff14bb Galaxy style on MapLibre, and restore both dashboard resizes
Some checks are pending
build-gate / build (push) Waiting to run
GALAXY + MAPLIBRE. New 'galaxy' style in the shared registry -- deep-space
ground, blue oceans -- with both a mapbox and a keyless MapLibre URL, and a
paint override that is applied only if the loaded style actually has the
layer, so a miss is a silent no-op rather than a break. The dashboard now
runs provider 'maplibre' and mapStyle 'galaxy'. Reversible by two config
keys; the Mapbox path is untouched.

BOTH RESIZES RESTORED. My seven-row change had hard-capped the table's
keyhole at 7 x 26px, which read like a default and was not: nothing writes
--rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h.
So the cap was unreachable by any gesture and the Driver lost both resizes
at once, plus a grey gap where the freed pixels had no consumer.

Fixed by restoring the derivation and moving the default instead: the
keyhole is once again shell - map - bar - foot, and DEFAULTS.shellH drops
720 -> 602 (map 360 + bar 32 + 7 rows x 26 + foot 28) so the table OPENS at
seven rows while staying fully draggable. CSS fallback matched to 602.

Measured before the revert: shell handle 849 -> 731 with rows 7, table
divider map 360 -> 280 with rows 10, gap 1px after each.

Also carries the charts' frozen count column (sticky right:0 with an opaque
background, so a scrolled label cannot slide under the number) and the
ViewSelect library-index entry.
2026-09-23 20:35:38 +00:00
doxa-maps-88655c70
1d4fe185e3 Full table headers via horizontal scroll, and a full-screen table
Some checks are pending
build-gate / build (push) Waiting to run
Driver, on the people-groups table under the map: "I would like to add a
horizontal scroll bar so that instead of ABBREVIATING the headers, we can have
the FULL headers in the table", and "I would also like to add a FULL SCREEN
button to the table, so that if you want you can open up the table to be full
screen."

These ship together on purpose. The table now shows seven data rows by default
because we are reclaiming vertical space; a deliberately small table is only an
honest trade if the reader can get a big one on demand. The cap and its release
are one change.

HEADERS ARE NO LONGER ABBREVIATED. text-overflow:ellipsis is gone from .rd-th.
Ellipsis destroys information with no way to recover it -- "Engageme..." is not
a column name, and unlike a truncated VALUE it has no menu behind it. Horizontal
scroll preserves it: the full text always exists and is always reachable. This
is the same mechanism already applied to the chart legends (nowrap +
overflow-x:auto), deliberately reused rather than a second invention.

THE FLOOR IS MEASURED, NOT ESTIMATED. Columns are flex:<w> 1 0, which can never
overflow -- flex-basis 0 plus shrink means every column collapses to whatever
the container offers, so scrollWidth always equalled clientWidth and overflow-x
had nothing to scroll. Each column now carries a min-width derived from its own
header. A first pass guessed 7.1px/char, which put the summed floor at 675px
against an 878px scrollport, so it never bound and no scrollbar could appear at
any width. Measured live by cloning each header out of flex flow: worst case is
9.75px/char (Language 78px/8ch), so 9.9 covers every label, +12px reserves the
sort indicator. Summed floor 742px + 30px checkbox.

That floor sits BELOW the 1440px scrollport on purpose. At 1440 the eight
columns genuinely fit and there is nothing to scroll; forcing a scrollbar there
would invent a defect. It binds at 1280 and below, which is exactly where the
truncation being complained about was happening.

ROW WIDTH IS SHARED, NEVER INTRINSIC. An intermediate pass used width:max-content
to create the overflow. It did -- and sized every row to ITS OWN content, so a
row holding "Lao People's Democratic Republic" laid out wider than the row above
it. That is not a table but a stack of independent flex lines: screenshot-
verified ragged, with Country/Region/Language stepping left and right on
consecutive rows while the header, correctly aligned, disagreed with all of
them. The measurement said so too and was read past: scrollWidth was 969 at the
top of the list and 1051 after scrolling, i.e. the table's width depended on
which rows happened to be rendered. Head, rows and footer now share one explicit
width (summed floor as min-width, width:100%), so nothing is intrinsic and the
column grid holds at every scroll offset.

The sticky header stays pinned on the vertical axis only, which is correct: it
must travel sideways with the body or the labels detach from the columns they
name. The footer is a SIBLING of the scrollport, so it is translated in lockstep
from the scroll handler -- otherwise the population total slides out from under
its own column.

FULL SCREEN is CSS pseudo-fullscreen, not requestFullscreen(). The native API
promotes the element to the browser's top layer, detaching it from the cascade
this table is built on, and needs allow="fullscreen" to work in the iframe embed
this dashboard actually ships in. Pinning the wrapper with position:fixed keeps
the element where it is in the tree, so inherited tokens and ancestor rules keep
applying and only the two rules we mean to override are overridden. Esc exits.

The seven-row cap is released with max-height:none !important, and !important is
the honest tool rather than a specificity war: the first attempt lost 0-2-0 to
the shell's 0-3-0 selector and rendered a 900px box containing a 182px table --
the exact failure its own comment warned about. That shell rule is also being
rewritten concurrently, so matching its selector would break on the next edit.

library/components/map-controls/FullscreenButton.vue was read first and does not
fit: it resolves its target as the SHADOW HOST, so pointed here it would
fullscreen the whole dashboard rather than the table; it renders MapControlButton,
a circular control built to float over a canvas; and it depends on vue-i18n,
which this bundle does not use. Making it fit means adding a target prop and a
chromeless variant to a component with seven map consumers to serve one non-map
caller, so this stays local until a second table wants it.

Verified at 1280 and 1440, real browser, live data: headers complete with
text-overflow clip and no cell clipped; scrollWidth 867 vs clientWidth 718 with
the last column's left edge moving 1045 -> 896; rows recycle (abai-sungai ->
deaf-comorans) with the rendered count bounded at 14; zero ragged columns and
zero head-vs-row drift at every scroll offset; fullscreen 244 -> 900px with the
keyhole 182 -> 838 and 45 rows rendered, restoring exactly on exit.
check:fits-screen 17/17.
2026-09-23 20:25:08 +00:00
doxa-maps-88655c70
a69386582c Reclaim height: seven data rows, slimmer headers, charts scroll sideways
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "we are trying to reclaim height."

TABLE = SEVEN DATA ROWS, stated in ROWS rather than pixels. The old formula
derived the keyhole by subtraction (shell - map - bar - foot), so the row
count was whatever fell out -- measured 300px = 11 rows. Now
calc(var(--rd-table-rows,7) * var(--rd-table-row-h,26px)), so the promise is
the declaration: change the row height and seven rows stays seven rows.
His own arithmetic settles an earlier ambiguity (he had said both 77 and 7):
seven is DATA rows, excluding field, footer and table header.

HEADERS 137px -> 125px: topbar 52 -> 44, stats 30 -> 26, both now vars so a
host tunes them in one place instead of three grid-template-rows that must
agree.

CHARTS scroll sideways instead of wrapping. This is the third pass on that
trade and it resolves rather than reverses it: pass 1 was nowrap + ellipsis +
overflow-x HIDDEN, so labels were truncated and unrecoverable -- the defect
he rejected. This is nowrap + overflow-x AUTO, so the full label still exists
and is reachable. Legend cap 176 -> 120.

Measured at 1440x900: grid-template-rows 44/55/26/476/244, keyhole 182px,
exactly 7 data rows, shell 863 -> 849 with the map gaining 116px.
2026-09-23 20:03:52 +00:00
doxa-maps-88655c70
e5c66272c2 Tighten the vertical legend's row spacing without touching the type
Some checks are pending
build-gate / build (push) Waiting to run
Driver: "the spacing between the rows is too thick, they take up way too much
height -- the padding should be decreased per row."

Row vertical padding 3px -> 1px, min-height 22px -> 18px, section header
padding 8px -> 5px.

Font-size is deliberately untouched at 12px. It is not a lever here: the
Driver previously rejected shrinking it ("super tiny text that cannot be
read"), and the note beside .fr-lbl records that the width for the count came
from the bar and the gutters for the same reason. Only whitespace moved.
min-height 18px still clears a 12px glyph at line-height 1.3, so no descender
is clipped.

Measured live at 1440x900 with four sections open, 69 rows rendered:
  row height 24px -> 20px
  computed padding 1px/1px, font-size 12px unchanged
  rail content height 1966px -> 1424px (542px saved)
2026-09-23 19:58:39 +00:00
doxa-maps-88655c70
02baa2d42b Switch the choropleth from heartbeat-red to the greyscale ramp
Some checks are pending
build-gate / build (push) Waiting to run
Driver ruling: "instead of the red, we need to use the grey scale from grey
to black." The greyscale preset was built earlier at his request as a
swappable alternative; this is him taking the swap.

One config key moves both the choropleth and its legend together, which is
why it was built as config rather than scattered hex. Ramp is
#e8e8e8 / #a9a9a9 / #6f6f6f / #3a3a3a / #060606.

Verified in the shipped bundle: colorRamp "greyscale", the five stops
present, sfc-parse passes.

Worth knowing for the follow-up: the colour research measured the ramp
change as roughly neutral for pin visibility (worst case 1.96 greyscale vs
1.84 red). The real fix for pin contrast is the lightness-conditional stroke
-- dark ring under a light pin, white under a dark one -- which lifts the
worst case to 4.51 and works on either ramp. Not done here; that is its own
change.
2026-09-23 19:57:08 +00:00
doxa-maps-88655c70
5008a6ff5f Bound the cities and gospel legends so the map controls stay clear
Some checks are pending
build-gate / build (push) Waiting to run
Both maps drew their legend as a full-width strip (left:8px right:8px --
measured 1264px of a 1280px map, 98.75%), which ran underneath the floating
control rail. MapToolbar sits at inset-inline-end:3px with 32px buttons, so
any box reaching right:8px intersects it at every viewport; the rail's higher
z-index kept the buttons clickable, which is why the defect survived three
reports as "looks broken but works". Gospel ran under four of five buttons,
cities under two, the difference being only legend height.

The legend is now a bounded, left-anchored panel imitating the shared
SemanticTreeLegend: left:8px with width var(--map-legend-width,380px). Being
bounded on the left, it cannot reach the right rail at all -- a geometric
guarantee rather than a tuned z-index. Below 480px it keeps a 46px gap so the
rail still clears. Measured after: legend 380px of 1280 (29.7%), zero
intersection with all five buttons, 857px clearance, and a dispatched click on
the top control landing on button.mcb via composedPath. Mobile at 390px: 336px
wide, 11px clearance, zero intersections. Both files kept byte-identical.

Both maps also showed the shared "Tap a coloured dot" hint while shading
country polygons and drawing no dots at all -- a hint pointing at something the
reader cannot see. PinHint documents a pinHint:false opt-out, but the
standalone leaf page built its config from a hardcoded object, so no profile
could reach that key. <profile>.meta.json now carries an optional `config` map
merged into the leaf's BASE_CONFIG, with generator-owned keys reserved and the
carrier stripped before the manifest is written so its published shape is
unchanged. Both maps opt out; the hint is gone from both.
2026-09-23 19:07:13 +00:00
doxa-maps-88655c70
1bf42e2110 Let the dashboard element size to its content when it is page-scrolling
Some checks are pending
build-gate / build (push) Waiting to run
:host carries height:100%, which the fixed-viewport host needs and a
page-scroll host must not have: in a tall scroller, 100% is thousands of px,
so the element claimed a large empty tail below its own content. The shell
inside already switched to height:auto for this reason; the host never did.

A host cannot be matched by a class on its own descendant, so the pageScroll
flag is reflected outward as a data attribute and :host([data-page-scroll])
switches to height:auto with the min-height dropped.

Labelled UNPROVEN in the file, deliberately: dashboard-plus-grid imports this
as a plain Vue component rather than mounting the custom element, so the rule
is not reachable from there -- and that composite was measured stacking
correctly regardless (dashboard 0..864, grid 864..3031, gap 0). The rule
applies where research-dashboard is mounted as its own element.
2026-09-23 18:41:07 +00:00
doxa-maps-88655c70
6ba3ad76aa Legend rises into the removed search bar's space -- but only where it was removed
Some checks are pending
build-gate / build (push) Waiting to run
The UPG-100 legend was pinned 58px below the map top by a profile !important
rule whose job was to clear the geocoder pill. Measured with a real token in
both hosts: the standalone leaf DOES mount the geocoder (top 12, h 35), so 58
is correct there; the cap-5 shell mounts NO geocoder at all, so the same rule
stranded the legend below a pill that was not there -- the dead gap reported
repeatedly.

One host must not be fixed by breaking the other, so the offset is now
conditional on the pill existing: default 8px, pushed to 58px only when
:has(.mapboxgl-ctrl-geocoder) finds a rendered pill in the tree.

Also parameterizes the shared SemanticTreeLegend's offset as
var(--stl-top, 60px) with the max-height mirroring it, so any profile that
drops its search bar can raise its legend without an !important arms race.

Measured after: leaf legendOffsetInMap=58 with geocoder present; shell
legendOffsetInMap=8 with no geocoder. Full authoritative rebuild included --
composite freshness PASS, SRI PASS 45/45.
2026-09-23 18:30:00 +00:00
doxa-maps-88655c70
eaa2c35286 Give the dashboard its own height handle, separate from the map/table split
Some checks are pending
build-gate / build (push) Waiting to run
Dragging the map/table divider in page-scroll mode was also resizing the
whole element, because --rd-map-h alone answered both "how is the map/table
space split" and "how tall is the element" -- Driver: "you improperly bound
that control to the separator line between the map and the table... that's
not the request I was making to resize the height of the page."

--rd-shell-h is now the independent total-height budget; the new bottom
handle (.rd-rz-shell) is the only thing that writes it. The existing
map/table divider still drives --rd-map-h, but only as the map's share of
that fixed budget, so it redistributes between map and table without
changing the sum. Table/facets/rail max-height all re-derive from
--rd-shell-h so the sidebars keep spanning exactly map+table at every drag.

Also closes a 28-30px sidebar gap the re-derivation exposed: the table
budget only ever subtracted the header bar's height, never the summary
footer's, so facets/rail undershot the table's real bottom edge.
2026-09-23 18:16:30 +00:00
doxa-maps-88655c70
2a8318433f Remove a second stray backtick from research-dashboard shadow-CSS
Some checks are pending
build-gate / build (push) Waiting to run
Shadow CSS is emitted inside a JS template literal, so a backtick in a comment
terminates the string and the bundle stops compiling. Second instance in this
file; the first one already cost a broken build this session.
2026-09-23 17:25:55 +00:00
doxa-maps-88655c70
0ebb3e34ac Sidebars span map AND table; facet rail opens with its own scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
The side columns stopped at the map's bottom edge, leaving the people-groups
table outside them. Cause was a single rule capping both side columns at the
map's height in page-scroll mode:

  .rd-shell.rd-page-scroll > [data-region="facets"],
  .rd-shell.rd-page-scroll > [data-region="rail"]{ max-height:var(--rd-map-h); }

The grid already spanned both rows correctly ("facets map rail" / "facets table
rail") -- this max-height was overriding it, which is why earlier passes that
adjusted the rail's CONTENTS never moved the bound. Replaced with min-height:0,
letting the grid do the bounding it was already doing.

Facet rail sections people/place/language/religion open by default again, with
.fr-body's own overflow-y:auto providing the scrollbar.

Measured, both hosts, 1440x900, real browser:
  dashboard-plus-grid (page-scroll): facets 140..2158, table bottom 2158, delta 0
  research-dashboard (fixed-viewport): facets 140..900, table bottom 900, delta 0
  facet rail scrolls: scrollHeight 1966 in clientHeight 708
2026-09-23 17:24:43 +00:00
doxa-maps-88655c70
4bed933001 Keep map scroll-zoom enabled now that the page-scroll map row is bounded
Driver: "I can't zoom in and out on the map. The scrolling is causing that
to change." Scroll-zoom was force-disabled whenever an ancestor scrolled, a
fix from when the dashboard band had no bounded height and a wheel over the
map was the only gesture available in the lower two-thirds of the page.
--rd-map-h changed that precondition: the map row is now a fixed, bounded
height, so the cursor naturally leaves it once a user scrolls past. Verified
on the live mapboxgl.Map instance (not a pixel-diff proxy): zoom now moves
1.45 -> 1.95 on a wheel over the map in the cap-5 shell, standalone
dashboard-plus-grid leaf, and standalone research-dashboard leaf, with no
change to pane scroll position in any of them.
2026-09-23 16:57:36 +00:00
doxa-maps-88655c70
3ac4175e98 Land four agents' dashboard work: resize handle, slimmer stats row, closed rails
Some checks are pending
build-gate / build (push) Waiting to run
Four delegated agents left verified work uncommitted in shared files. Committing
it together because it is interdependent and all of it passes the gates; each
agent's own measurements are in _qa-cycle/02-builder-cycle/delegation/.

RESIZE HANDLE (resize-sections). A draggable separator between the dashboard
band and the grid, so the band's height is the user's choice rather than a fixed
compromise. Driver: "if I were to open this on a large monitor and I wanted to
resize the height of the dashboard compared to the grid, being able to do that
would be really cool." Verified with real pointer events: 522px -> 672px on a
150px drag, stable before/during/after, no snap-back; keyboard-operable with
aria-valuenow tracking the live value. A real bug was caught pre-ship: 200
localStorage writes on a single keypress.

TABLE/MAP DRAG FIX (table-resize). The handle did nothing in page-scroll mode --
measured 522px before AND after a real 200px drag, every frame. The shell is
height:auto there and grows with content, so the percent-of-shell formula was
meaningless. It now measures against the map element's own top edge captured at
drag start, re-deriving each frame rather than summing a delta. Four drags both
directions with persistence: 417.6->336, 336->283, 283->548, and 548->470 after
a reload that restored 548.

SLIMMER STATS ROW (slim-headers). The KPI strip's content is an 18px line inside
a row fixed at 42px -- 24px of empty space. Tokenized as --rd-stats-h:30px,
recovering 12px with no information loss, and tokenized rather than inlined
because 42px repeated in three row templates that must stay in sync.

CLOSED FACET SECTIONS (sidebar-collapse). Driver: "once the data fills all the
sidebars it causes the dashboard to become really tall... without data it looks
fine." Measured: five open sections rendered 69 rows, the rail's own scroller
never engaged (clientHeight === scrollHeight === 2117px), band 2325px against a
900px viewport. All five now start closed -- the default changes, not the
mechanism; per-section state and toggle() already existed.

Build exit 0, 21 bundles / 30 profiles. check:sri, check:settled, sfc-parse,
template-literal, one-palette, synced-css all pass.
2026-09-23 16:27:39 +00:00
doxa-maps-88655c70
4a8b872891 Slim the research-dashboard's KPI stats row from 42px to 30px
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for the three dashboard headers to read slimmer so the
people-groups grid peeks below the fold as a scroll hint. Measured each
header's actual content height: the KPI strip's content is 18px tall
(a single baseline row) inside a grid row hardcoded at 42px, so 24px was
dead space around it — the largest untouched margin of the three. The
topbar and research-filters bands are already at a Driver-fought floor
from prior cards, so they're left alone. Tokenized as --rd-stats-h since
the literal appeared in three grid-template-rows declarations that must
stay in sync.
2026-09-23 16:21:47 +00:00
doxa-maps-88655c70
873a99d1a6 Pay the agent discovery tax once, in the repo
Some checks are pending
build-gate / build (push) Waiting to run
MEASURED: agents produced 21 report docs totalling 2,079 lines of prose and 27
scratch probe files against a handful of product commits. The cause is the spawn
prompts, which is to say me: each carried ~15 environment rules, several cited
lessons, a three-clause invariant, a mandatory screenshot read and a
what-I-could-not-verify section. Per report that is 7-12 headings and 10-16
verification mentions. The prompt made PROVING the job and BUILDING the side
effect.

Two costs compounded. Every agent re-derived the same environment from scratch
-- which port, is the tree settled, does stale SRI block the script, does this
box have WebGL. And several spent that discovery budget, hit a build queue they
could not control, and died on a session limit before committing; their work
survived only because it was found untracked (one 511-line gate was a `git
clean` away from being lost).

This file pays that tax once. Spawn prompts can now be short: what to build,
which files you own, and the one assertion that would falsify it.

It also states the rule that was missing: ship a commit and PUSH, then write two
sentences -- not the reverse. A long honest report attached to no commit is the
failure mode. And it sizes verification to blast radius explicitly, because
over-verifying is a real cost rather than a virtue.

check:doc-scripts passes, so every command quoted here exists in package.json.
2026-09-23 16:05:47 +00:00
doxa-maps-88655c70
c48d1d401a Make the legend show the colour the map actually paints
Some checks are pending
build-gate / build (push) Waiting to run
The swatch rendered the raw bin hex. The map never paints that colour: the
choropleth composites at fill-opacity 0.78 over Positron's #f2f3f0, so every
swatch showed a colour that appears nowhere on the map.

Measured, chip vs actual fill:

    #f4cec4 -> #f4d6ce   dE  4.07
    #ecaea1 -> #edbdb2   dE  7.53
    #e08678 -> #e49e92   dE 11.93
    #ce544b -> #d6776f   dE 17.13
    #b10010 -> #bf3541   dE 21.14

The error grows with the bin, and at the top it is the size of a whole bin step:
the gap between the top two bins is 25.45 as painted, while the legend's own
error on the heartbeat bin is 21.14. A reader comparing the two darkest swatches
was comparing two colours neither of which was on the map -- a strong candidate
for the report that "60-149 red is the same red as the heartbeat zone",
independent of the ramp itself.

Composited in the legend rather than dropping the map's opacity: 0.78 is
deliberate, it lets country borders and labels read through the choropleth, and
this file's own contrast notes are measured at 0.78. The legend was the half
that was lying.

Verified: all five bins produce the measured target values, and non-hex and null
inputs pass through untouched. check:one-palette, check:synced-css,
check:template-literal, check:sfc-parse all pass.
2026-09-23 15:20:30 +00:00
doxa-maps-88655c70
f48cad8dbb Widen the red ramp, add a greyscale ramp, and gate cap-4 interoperability
Some checks are pending
build-gate / build (push) Waiting to run
THE RAMP. The Driver reported that 60-149 reads as the same red as the 150+
heartbeat bin. The old ramp was mathematically even and he was still right --
that tension was the finding, not a contradiction. Measured in plain sRGB
distance the top step (60.0) looked like the LARGEST in the ramp; that
measurement was lying, because RGB distance is not perceptual. Re-measured as
deltaE the old steps were 15.5 / 15.8 / 16.7 / 17.7 -- nearly flat, while chroma
collapses at the dark end so hue and saturation stop carrying information there.

The new ramp deliberately ACCELERATES toward the dark end: 14.1 / 17.9 / 21.4 /
24.9. Top-bin separation 17.67 -> 24.94, a 41% improvement on exactly the pair
he named, and total lightness range widens from 37.2 to 49.0 L*.

A named greyscale ramp ships alongside it, grey to black, as he asked ("just in
case i need to swap from red to black scale"). Its steps measure 22.8 / 22.4 /
22.4 / 22.8 -- near-perfectly even, every step well above the ~10 threshold for
clear distinguishability. It is an OPTION, selected by name; the red ramp
remains the default, so no prior decision is silently reversed.

Bin EDGES are untouched -- check:heartbeat passes, which replays the bin
assignment and fails if a default moves.

THE INTEROP GATE. verify:interop drives the actual compiled cap-4 composites in
a real browser against live data, closing the gap verify:gate1 leaves open. On
its first run it PASSED both desktop directions: a facet-rail click narrowed the
grid to "Showing 1,309", and selecting three rows moved the camera from
28.000,18.000@1.45 to 73.666,13.162@2.48 -- read back off the live map, not
inferred. It asserts the grid's live count line rather than card count, because
card count is a proven false-negative (always 24).

It ships with verify:interop:self-test, a two-mode negative test -- grid-prop
freeze and camera freeze -- each required to fire its own combination-named
failure. A harness that has only ever reported PASS is unproven.

Gates: sfc-parse, template-literal, one-palette, synced-css, mjs-parse,
heartbeat, doc-scripts all pass.
2026-09-23 14:53:51 +00:00
doxa-maps-88655c70
1a782109c3 Give the shell pane a scroll, and drop the engagement filter
TWO CHANGES, both Driver-reported.

1. THE SHELL WOULD NOT SCROLL, so the grid under the Big Picture dashboard was
unreachable. Measured before: window.scrollY stayed 0 and .vtl-content reported
scrollTop 0 against max 4155 -- it HELD 4155px of content and would not move; 0
grid cards reachable. The same composite on its own leaf page scrolled fine
(scrollY 3712, 9 cards), so this was a HOST defect, not broken interop.

Cause: every mounted tab got position:absolute;inset:0;height:100%
unconditionally. That is right for a fixed-viewport map, which fills the pane
exactly. It traps a StackLayout composite -- dashboard-plus-grid and
research-map-plus-grid are natural-height by design, and pinning them to inset:0
forces ~2600px of real content back into one viewport with nowhere to go, while
BOTH the element and its container were absolutely positioned with no scroller.

Fix follows the bounded-middle shape already proven here: the PANE stays bounded
and gains overflow-y:auto (plus overscroll-behavior:contain so a bottomed-out
wheel does not chain to the document); .dms-slot becomes position:relative with
min-height:100% so it can grow past the pane. Removing the pane's bounds instead
was measured previously and produced a worse defect -- the grid landed far below
the fold.

Verified in the shell on the built tree: pane overflow-y auto, scrollTop 0 ->
4155 of a 4155 max, grid cards visible 0 -> 8, .rd-grid present, 0 failed
requests. Read the screenshot as well as the numbers: the people-groups grid
renders with photos, country and affinity-bloc labels, Full-profile buttons and
Load more. Fixed-viewport map tabs are unaffected -- they fill the pane exactly,
so scrollHeight equals clientHeight and overflow:auto paints no scrollbar.

2. ENGAGEMENT AND STATUS FILTERS REMOVED on instruction. engagement_status is
gone from the dropdown row (seven remain, all fitting the first row on the
measured 178px pitch) and the Status section is gone from the facet rail. The
facet accessor, the table column and the FACET_TO_PROP mapping all stay, so the
grid can still filter on it and nothing downstream breaks -- only the two
offered controls are withdrawn.

Gospel Access stays. It is measurably a different question: over 2,108 live rows
the two fields disagree on 25 -- 12 groups are Engaged at gsec rung 1 and 13 are
Unengaged at rung 2.

Gates: sfc-parse, template-literal, one-palette, synced-css, facet-cascade,
facet-one-field all pass.
2026-09-23 14:41:58 +00:00
doxa-maps-88655c70
8b3d638de7 Correct two measured claims in the filter-row note
Both were assumptions written as measurements, and one of them kept a P0 card
shut.

(1) "GSEC is the underlying field ... engagement_status is the label derived
from it." They are independent fields. Over 2,108 live rows they disagree: 12
groups are "Engaged" at gsec rung 1, and 13 are "Unengaged" at rung 2. Because
the note claimed the dropdown already covered "the phases of engagement", the
phases looked built when they were not.

(2) "168px on a 200px pitch, so eight occupy 1600px and MUST wrap." The pitch
was never measured. It is 178px: seven dropdowns fit the first row ending at
x=1250 and the eighth wraps, at both 1440 and 1280, with no horizontal
scrollbar. The conclusion held; the arithmetic behind it did not, which would
have misled the next author sizing this row.

Gospel Access stays out of the dropdown list because the Driver named eight, not
because a ninth would overflow -- the layout would tolerate one. A five-rung
ordinal reads better in the tree rail regardless.

Comments only; no behaviour change.
2026-09-23 13:24:06 +00:00
doxa-maps-88655c70
1449d47573 Add the Gospel Access filter, the ladder the binary flag was flattening
The Driver asked for "the phases of engagement, measured in the live data."
engagement_status cannot express them: over 2,108 live rows it is 2,081
Unengaged / 27 Engaged, so filtering on it separates 1.3% of the corpus and
tells a researcher almost nothing about where a group actually sits.

imb_gsec is the ordinal ladder underneath that flag, present on 2,106/2,108:

    0   No Evangelicals, Churches or Resources ........... 225
    1   Less than 2% Evangelical, No Active CP Activity . 1,853
    2   Less than 2% Evangelical, Concentrated CP ......... 23
    4   2% or Greater but Less than 5% Evangelical ......... 4
    6   10% or Greater Evangelical ......................... 1

Cross-tabbed against the flag, ALL 225 groups with no evangelicals, churches or
resources -- the most lost category in the data -- are merely "Unengaged",
indistinguishable from the 1,841 groups that have some evangelical presence.
That distinction is the one the map exists to show.

Verified safe to filter on before wiring, the same way cluster/people_group
were: 5 distinct {value,label} pairs and NO value-vs-label cross-collision, so
valueMatches() selects the same rows whichever vocabulary arrives. It is a real
row field, unlike the derived language_family/religion_family facets, so it is
mapped in FACET_TO_PROP from the day it lands rather than joining the
dashboard-only class.

Wired through every layer that would otherwise silently drop it: the shared
sources.json defaultFields (the dashboard reads rows through that seam), the
grid's own fetch field list, the prop, the API-field-name filter key (imb_gsec,
not gospelAccess), the re-filter watch list, and the composite's prop binding.

Verified in a browser on the built tree with live data: the section renders all
five rungs with counts matching the API exactly; clicking "No Evangelicals"
takes dashboard scope 2,108 -> 225 and the grid to "Showing 225"; clicking
"Concentrated CP" gives 23 and "Showing 23"; deselecting returns both to
baseline. 0 failed requests.

Gates: facet-one-field, facet-cascade, facet-changes-map, csv-shape,
label-joins, one-palette, sfc-parse, parse all pass. verify:gate1 still 13/13
against 2,108 live records.
2026-09-23 13:19:22 +00:00
doxa-maps-88655c70
9076ec1d61 Mark the two dashboard-only facets, so they stop looking like broken buttons
language_family and religion_family are clickable, they highlight, and they
narrow every chart above them -- and they cannot move the grid below. Measured
2026-09-23: dashboard 2107 -> 677/306/222/151 and 2107 -> 693/649/610/83, grid
unchanged every time. To a user that is indistinguishable from a dead control;
the only way to learn it was to click and squint.

Wiring them is not the fix and would be worse. Both values are DERIVED -- a
language family resolved via langFamilyByIso for ~96% of rows, a religion family
computed from the first letter of a religion code -- so they exist on no row
field. Mapping religion_family onto `religion` would compare "Hinduism" against
a CODE and filter the grid to zero.

So the honest fix is a label, not a wire. A facet now declares
`dashboardOnly: true` in dashboard.config.js; DashboardFacetRail reads that from
config rather than hardcoding facet names, and renders a muted half-circle plus
a title and an extended aria-label saying the row updates the charts only.

These rows stay fully clickable, deliberately unlike `zero` and `other` rows,
which are inert because they would do nothing at all. Dimming them the same way
would understate what they really do.

Verified in a browser on the built tree: 41 of 66 rows marked, 41 glyphs
rendered, marked facets are exactly language_family and religion_family and
nothing else, 40 still clickable (the one that is not is a zero row that
correctly keeps its mark). Behaviour unchanged -- a marked row still takes the
dashboard 2,108 -> 677, an unmarked row still filters at 548.

Gates: facet-cascade, facet-one-field, facet-changes-map, one-palette,
synced-css, composite-srcs, template-literal, sfc-parse all pass;
a11y-button-names passes at 769/769 buttons named.
2026-09-23 13:04:52 +00:00