Commit graph

80 commits

Author SHA1 Message Date
doxa-maps-88655c70
f7cee9640e Add regions-list element: tab 1 of the Regions Dashboard plugin
Scrollable list of WAGF regions and their countries, refactored from the
DX-life /regions page into a standalone custom element. Clicking a region
or country emits a selection event instead of navigating, so a host shell
can drive a second tab from it.
2026-09-23 14:24:04 +00:00
doxa-maps-88655c70
c27d16cd4b Show the film link the list already had, for 218 languages that showed nothing
Some checks are pending
build-gate / build (push) Waiting to run
resources-list rendered links only from `resources[]`, which holds SCRIPTURE
editions (eBible entries with ot_books/nt_books/redistributable). The JESUS Film
lives in its own fields -- has_film/jesus_film plus film_url -- and was never
read. Measured over all 862 languages in resources.json:

    resources[] non-empty ............ 120
    film_url present ................. 306
    film_url but resources[] EMPTY ... 218   <- a real link the UI never offered

So 218 languages displayed no link at all while carrying a working one. Arabic,
Sudanese (apd, 66 people groups, 18.7M speakers) has
https://www.jesusfilm.org/watch/?languageId=13562 and rendered nothing.

The pill is deliberately left alone. It reads full_bible/has_scripture, so
"none found" is TRUE -- of scripture, which those 218 genuinely lack. Widening
it to mean "no resources at all" would make it say something false about a
language that has a film. The row now says both things: the pill reports the
scripture gap, the links report what exists.

Film links are marked (muted, with a leading marker) rather than styled as
another Bible, because a film is a real resource but not a text and this list is
about text availability first. Guarded on film_url itself, not on the boolean
flags: a truthy flag with no URL would render an anchor to nowhere.

Verified in a browser on the built tree: default "No scripture found" tab now
renders 218 film anchors where it previously rendered zero, all 218 on rows
whose pill reads "none found"; the sample href matches the apd row in the data.
"Has scripture" shows 120 rows with 155 scripture anchors plus 88 film; "All
languages" 862 rows, 306 film -- matching the pre-change data counts exactly.
0 failed requests.
2026-09-23 13:58:18 +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
doxa-maps-88655c70
bd2a77e82d Resolve the shared asset base per bundle, so nested tabs stop 404ing
Eleven profiles across six bundles each carried the same line:

    profileConfig?.assetsBase || './assets'

That default is right on a standalone leaf page, where the generated host passes
'../../assets' and the tree root is two segments up. It is wrong the moment the
element is nested in the cap-5 shell, because a relative path resolves against
the SHELL's page, not the child's. Measured in a browser on the built tree:

    leaf  /doxa-maps/gospel-resources/resources-map/index.html ... 0 failed
    shell /doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html ...... 404 assets/tables/gospel-by-country.json
                                                                   404 assets/boundaries/world-adm0.geojson

No build-time gate could see it: './assets' is a valid string in both hosts, and
only the host decides what it resolves to. check-composite-srcs proves a nested
src exists on disk, which is a different question.

resolveAssetsBase() in library/utils/embedBase.js now derives an absolute base
from the bundle's own script URL (or the treeBase the shell already forwards),
and deliberately REJECTS a relative assetsBase from config -- honouring it would
reintroduce the defect through the config meant to cure it.

The fix is in the child, not the shell, so a new bundle that fetches assets
works nested on day one with no edit to doxa-maps-spa.vue.

Verified: shell 404s 2 -> 0 on Gospel Resources; Cities & Internet, UPG-100 and
Big Picture also 0 asset 404s; leaf hosts unchanged at 0.
2026-09-23 12:14:52 +00:00
doxa-maps-88655c70
546916c41e Say which population the studio's count describes
The Plugin Studio header read "18 bundles · 27 elements" while the manifest
holds 19 and 28. The arithmetic was always right: rowsFromManifest skips the
studio itself, because it cannot be one of its own tabs, and 19-1 / 28-1 is
exactly what the header showed.

The problem was that nothing said so. The golden-path self-test — linked from
the golden-path doc as of this flight — reports 19 bundles and 28 profiles from
the same manifest. Two authoritative surfaces disagreeing by one, neither
explaining why, is how a reader stops trusting both; and the studio is the first
thing an admin agent opens, so a number that looks wrong there discredits
everything beside it.

"listed here" is the whole change. It names the population, so the difference
from the catalog total reads as a deliberate exclusion rather than an off-by-one.
The count stays derived from this.rows, which is the honest source — it is a
statement about what the studio shows, and now it says that.

Verified the exclusion accounts for the gap exactly before touching anything:
manifest 19/28, minus the single plugin-studio bundle and its one profile, is
18/27. Then read the rendered header at 1600px — 220px wide, sitting beside the
language selector without crowding it — and confirmed it in the regenerated
screenshot.

Nine gates pass, including check:synced-css: there are two .ps-count rules in
this file, the synced copies that must stay byte-identical, and this change is
JS rather than CSS so both are untouched.

Same shape as the self-test scoping documented earlier today: a number that is
correct for the question it answers, with nothing saying which question that is.
2026-09-23 10:49:06 +00:00
doxa-maps-88655c70
7547abb109 Keep the poster-wall header pill on one line whatever the count
The wall's header pill reads "<n> UPG · Show on map" and is a fixed 82% of the
card. A three-digit count pushed it over that width and it wrapped, so "116 UPG"
cards rendered a two-line pill beside one-line "92 UPG" cards in the same row.
Measured at 1440px on the shipped wall: the same element came out h=34 for
three-digit counts and h=22 for two-digit ones, a 12px step that reads as two
different components sitting side by side.

One property, and the correct value was already three lines away: the sibling
footer pill (.tape-l) carries white-space:nowrap and .tape-h did not. So this was
an inconsistency inside one component rather than a design question. The
.hcount rule sets font-variant-numeric:tabular-nums, which says the author
already wanted digit width not to move things — wrapping undid that intent.

Now h=22 on every card regardless of count, confirmed in the regenerated
screenshot: the three-digit pills in the India and Africa rows sit on one line
like the rest.

Found by looking at the shipped screenshot while verifying
BUG-UPG100-POSTER-PRINT-DIALOG, which the standing brief asks to check visually
and which had closed with verification_kind "state" and no recorded evidence.
The poster wall itself is sound — real posters, named people groups, PRINT
OPTIONS on every card.

Nine gates pass, including template-literal, which matters because this CSS
lives in a JS string and a backtick in a comment there has broken the build
twice before.

Not fixed, and not a product defect: the "Print poster" labels show a tofu box
on this machine. check:shot-fidelity already names these six bundles — the
markup carries the right codepoint and the shot box has no emoji font. The fix
is a font here, never removing emoji from the product.
2026-09-23 10:35:24 +00:00
doxa-maps-88655c70
94b356d2b3 Tell the user when the data source is down, not that their filter matched nothing
people-groups-grid caught a failed fetch, logged to the console and cleared its
loading flag. The template's `v-else-if="!displayedUUPGs.length"` branch then
rendered "No people groups match this filter." So when pray.doxa.life was
unreachable, the element told the reader their FILTER was wrong.

Measured by blocking that host in a browser: two console errors logged, and the
only thing on screen was the ordinary empty state. A user adjusts filters that
were never the problem; an operator glancing at the page sees normal operation.
The console held the truth and nobody opens it — least of all on a partner site
or inside the cap-4/cap-5 composites, which is exactly what this element is for.

Now distinguishes the two. A `loadFailed` ref drives its own branch: "People-group
data could not be loaded. This is a connection problem, not your filter."

Also checks response.ok, which was a second silent path. fetch() only rejects on
a NETWORK failure, so a 404, a 500 or an HTML error page all resolve — and
response.json() on them either throws into the same silent catch or yields an
object with no `posts`, which sets an empty grid and reports success. Both were
indistinguishable from "nothing matched".

Styling adds no colour: the palette is material-light only and --mut is what
this component already uses, so the failure line is separated by weight and a
hairline rather than an alarm state the design language does not have.

Verified both directions. Host blocked: the new message renders, no cards.
Host reachable: no message, the grid renders. And `bun run verify:gate1` still
passes 13/13 against 2,107 live records, including its "nonsense query -> 0
(empty-state path)" case — so genuine zero-match still works and is now a
different thing from a failed load.

Found by reading EXPLORE-dxlife-people-groups-refactor.md (2026-09-18) in the
staleness scan. Its PLAN is superseded — steps 1-5 describe editing DX-life's
own UupgsList.vue, which the standing law forbids and which this flight solved
by cloning instead. But its RISK register was still testable, and risk 3 named
this exactly: "external data dep on pray.doxa.life/api/people-groups/list". Half
of that was already mitigated — prayBaseUrl is injected three ways — and this
was the unmitigated half. Risk 6 ("FIVE dropdowns not four, confirm with the
Driver") is also settled: four are rendered.

A doc can be stale as a plan and still carry a live finding.
2026-09-23 09:31:10 +00:00
doxa-maps-88655c70
4c8f6b4a6d Share the mobile composite too, and make the url absolute so the embed works
research-map-plus-grid had the same defect fixed yesterday on its desktop
sibling: sharing the composite emitted a link to doxa-research-map, the child it
nests. The gate said it exactly — "share URL reopened <doxa-research-map>, not
<research-map-plus-grid>" — and a recipient got the map alone, losing the grid.

Checked all four cap-4 composites rather than assuming the fix generalised.
my-upg-100-list owns its Share button directly and is fine. big-picture composes
a header plus the grid, owns no map and ships no Share button at all; the gate
reports "window.mapboxgl UNDEFINED" against it, which is its map precondition,
not a share failure — that bundle is out of this gate's scope and I put it in
the probe by mistake. So one real instance, now fixed.

Tier 1 again, for the same reason: this child also hardcodes a prop
(research-map.vue:2149 `:embed-path="'doxa-research-map/research-map/index.html'"`)
and the ladder is `embedPath || cfg?.shareEmbedPath`, so a prop outranks host
config and tier 2 is unreachable. Delivered through the profile-config attribute
rather than Vue provide, because this composite mounts its children as real
custom elements via src — there is no provide chain to reach them.

The url had to be ABSOLUTE, and the first version was not. resolveShareUrl
returns a tier-1 shareUrl verbatim (embedBase.js:95, `return whole.trim()`, no
resolution), and ShareButton feeds the same string into its embed snippet:
<iframe src="{shareUrl}">. A relative '../../…' would have resolved correctly in
the share LINK, because the browser resolves it against this page, and then
404'd in the EMBED, because it resolves against the partner's page instead. That
would have passed the gate — the gate navigates the link — while breaking the
deployment method the link exists for. Tier 1 is documented as "a WHOLE url" for
precisely this reason. Now computed with resolveEmbedBase() so it stays correct
on doxa.life, on nuxtbase.app and in a dropped tree.

Verified the desktop sibling did not have the same latent flaw: it produces a
fully absolute url, confirmed in the live gate output.

smoke:share-all now carries eleven targets. Full suite 11/11 pass · 0 fail.
Eleven gates pass, including sri, composite, composite-srcs and synced-css.
2026-09-23 08:32:50 +00:00
doxa-maps-88655c70
fe8e785c96 Share the composite, not the child it nests
Sharing from dashboard-plus-grid produced a link to
research-dashboard/research-dashboard/index.html — its own nested child. A
recipient opened the inner dashboard alone and silently lost the grid half of
the composite. The gate said it precisely: "share URL reopened
<research-dashboard-map>, not <dashboard-plus-grid>".

The nested dashboard's DashboardTopBar owns the Share button, and inside the
composite that button had no idea it was a passenger, so it shared its own
address.

Two attempts, and the first one is worth keeping in the record because it built
and shipped and changed nothing. I set profileConfig.shareEmbedPath — tier 2 of
ShareButton's documented ladder, and the natural-looking channel. The ladder is
`embedPath || cfg?.shareEmbedPath`, and DashboardTopBar passes an explicit
prop (`<ShareButton :embed-path="SHARE_EMBED_PATH" />`), so a child that has
already made up its mind always wins. The value was baked into the bundle,
resolveShareUrl reads that key, and the rebuilt bundle failed the gate
identically — a fix that is present in the artifact and unreachable at runtime.
Tier 1, `shareUrl`, is the only rung above a prop, and its docs say exactly what
it is for: "a WHOLE url the HOST supplies wins".

Tier 1 takes a whole url, so it is computed rather than hardcoded, via the same
resolveEmbedBase() helper ShareButton itself uses: profileConfig.embedBaseUrl →
window.MAP_EMBED_BASE_URL → the root the built bundle derives from its own
script url. The link therefore stays correct on doxa.life, on nuxtbase.app and
in a dropped tree with no configuration. An absolute url would have pinned every
share to whichever host happened to build it.

Also adds the two uncovered bundles to smoke:share-all. Nine bundles ship a
Share affordance and the hardcoded --url list covered seven; research-dashboard
and dashboard-plus-grid were absent, which is why this survived. The list is now
ten targets over nine bundles. Note the structural difference from
smoke:embeds, which enumerates from manifest.json and therefore cannot omit a
bundle that ships later — a hardcoded list silently omits whatever arrives after
it was written, and that is what happened here.

This is the two-host defect class exactly: research-dashboard standalone PASSES
this gate, and the identical component inside the composite FAILED. No
single-host gate can tell those apart.

Verified: the composite now reopens <dashboard-plus-grid> with a canvas, and the
full suite is 10/10 pass · 0 fail, so nothing regressed. Eleven gates pass,
including sri, composite, composite-srcs, synced-css and one-palette. The share
gate aborts rather than reporting a false failure when handed Firefox, which has
no WebGL here — it told me to re-run with chromium, and it was right.
2026-09-23 08:16:18 +00:00
doxa-maps-88655c70
34ef223619 Name the UPG-100 legend's tabs, instead of shipping the placeholder
The legend read "GENERATION 1 / 2 / 3" above a list of world regions. A UUPG
list has no notion of a generation, so the label described nothing and invited
the reading that the tabs filter the map by some cohort. They do not.

The cause is a fallback, not a typo. SemanticTreeLegend derives its tab strip
from the `tabs` prop and, where an entry is missing, substitutes
`{ id: 'gen'+(i+1), label: 'Generation '+(i+1) }` so the strip has something to
render. This bundle passed no `tabs` at all, so all three were placeholders.
country-posters and doxa-research-map both pass real tab sets; doxa-simple-map
passes an explicit null. This was the only bundle relying on the fallback.

What a tab actually is: a DEPTH LEVEL of the tree, which is why selecting a node
jumps the strip to that node's depth. The three levels were read out of
data/legend-tree.js rather than guessed — regions are assembled by grouping
leaves under regionFor(), zones are the middle nodes (Frontier Asia (uncovered),
India, Deaf (affinity bloc A017)), and the leaves are the individual groups each
dot belongs to. Hence Region / Zone / People Group, each with an `info` note in
the shape AFFINITY_TABS and REGIONS_TABS already use.

The ids are bundle-scoped, and that was measured rather than chosen on style.
tabLabel() resolves `tabs.<id>` against the shared i18n catalog FIRST and only
falls back to the English label when no entry exists, so a tab id is a global
namespace. The first version used id:'region' and the legend rendered "WAGF
Region" — library/i18n/locales/en/tabs.json already maps that key to the
country-posters taxonomy. id:'group' collides the same way and would have been
right here only by coincidence, since the catalog means the ROP-2.5 sense. The
prefixed ids own no catalog entry, so the labels in this file are what render.

Desktop only, deliberately. LegendMobile computes
`hideTabs = nodes ? !tabs : _isFlatTabMobile`, so with nodes supplied and no
tabs the mobile sheet hides the strip entirely — it never showed the
placeholder. Passing tabs there would reveal a strip that is currently hidden,
which is a design change rather than a bug fix.

Verified by driving the built bundle in Chromium and reading the three chips
back: Region / Zone / People Group, each carrying its info tooltip, where the
same probe returned "WAGF Regioni / Zonei / People Groupi" before the id fix.
Then the README shot was regenerated and read as an image — the strip renders
REGION / ZONE / PEOPLE GROUP and the column header now says REGION where it said
GENERATION 1. Element count moved 311 -> 314, the three info buttons.

Eleven gates pass, including sfc-parse, template-literal, sri, composite,
one-palette and synced-css.

Found while verifying BUG-UPG100-DOTS-HARD-TO-SEE, which is genuinely fixed: the
dots carry a solid dark stroke that frames them against both the pale zone fills
and the white basemap, confirmed by reading the image rather than the card field.
2026-09-23 06:52:18 +00:00
doxa-maps-88655c70
ad223d3b1a Read the pin colour and stroke from config instead of hardcoding them
The selection ring beside these already read cfg.map.selectedColor while the pin
fill and its stroke were literals, so half the pin styling was parameterised and
half was not, for no reason. Both now follow the same shape: a config read with
the current value as the fallback.

This matters beyond tidiness. The heartbeat threshold was made a variable so the
dashboard can point at a dataset that is not these 2107 people groups, and a
different corpus can want a different pin -- the orange was chosen against THIS
choropleth, and its legibility is a property of the pair, not of the colour.
Leaving the pin fixed would have made the dashboard reusable in its data and
fixed in its palette.

Verified: with no config the built map reports circle-color #ef6c00 and
circle-stroke-color #ffffff, byte-identical to the literals they replaced.

Not verified: that an override takes effect. Two runtime injections left the
defaults in place, which is expected rather than broken -- like SEL_COLOR, this
is read once when the element upgrades, and I could not prove my injection beat
that moment. The config channel is proven by check:heartbeat, but statically,
never through a live attribute. The comment says so rather than implying a
capability nobody has exercised.

The white stroke is noted as load-bearing: it carries the pin over the dark bins
where the orange falls to about 1.03 contrast, and it is what stopped the pins
reading as eyes. A host that sets it to the fill colour brings that back.
2026-09-23 05:19:44 +00:00
doxa-maps-88655c70
9fff7c4af2 Let the poster print-preview window actually open
The preview branch was unreachable for every user. window.open with `noopener`
returns null by specification -- severing the opener relationship is precisely
what the flag does, so there is no handle to hand back. The document.write that
builds the print sheet ran against that null, so every click fell through to the
fallback: the raw .webp in a tab, no print dialog. The card claimed it opened an
in-browser print preview with a dialog.

Measured in a default browser, no headless flags: window.open('','_blank',
'noopener') returns null, the identical call without the flag returns a usable
handle. I had attributed the null to this headless box last tick, and that was
wrong -- it is what the flag means, everywhere.

noopener stays on the fallback, which only navigates to a URL and never needs a
handle, so the protection is kept where it costs nothing. The window we write
into carries same-origin markup we generate, so the opener reference it regains
is our own.

Verified after rebuilding: the click now opens ONE window titled "Doxa UPG-100
poster — Northwest India" carrying the @page rule, where it previously opened two
and wrote into neither. That window then shows "Poster not available yet." from
the img onerror, which is correct and documented: the full-size posters are 42MB
and deliberately not in the default tree, so the preview degrades honestly
instead of hanging. Set bundlePosters or point assetsBase at a host serving
posters/ to see the image.
2026-09-23 04:31:43 +00:00
doxa-maps-88655c70
2b5ff1328b Show the eighth filter, and wrap the row instead of scrolling it
The Driver named eight filters -- people, region, block, country, language,
religion, prayer, engagement -- and the rail rendered seven. engagement_status
was already fully built: a label and getter, the Status section in the tree, and
the Engagement column in the table. It had simply never been registered in the
dropdown list, so the one piece missing was a single entry.

Adding it exposed the constraint that goes with the count. The dropdowns are a
fixed 168px on a 200px pitch, so eight need about 1428px. At a 1440 viewport that
just fits; at 1280 the row reported scrollWidth 1414 against clientWidth 1252 --
a horizontal scrollbar, which is exactly what the Driver asked not to see.

The row now wraps rather than scrolls. Wrapping is the right lever because the
fixed 168px width is itself a Driver decision: a flexible width was what made the
dropdowns jump as a selection changed. The justify-content pair is kept, not
simplified -- its own comment records that `safe` degrades space-evenly to
flex-start rather than pushing the first item to an offset a scroller can never
reach. That hazard belonged to the scroller and costs nothing now, so it stays.

Verified on a settled tree at three widths. 1440, 1280 and 1024 all render eight
filters across two rows at identical offsets, with the row reporting no
horizontal overflow and every filter fully inside the viewport.
2026-09-23 02:49:38 +00:00
doxa-maps-88655c70
d4a8184ad7 Match the tab radius to the rest of the app, and open on the tab you were last on
The vertical tabs were a 999px stadium on a 48px row. That was a real earlier
instruction -- imitate the YouTube active row -- and it shipped, but measured
against the product it is the only thing shaped that way: the collapse button is
8px, the filter toggles are 8px, and 8px is already the most common radius in
the layout file. They now read var(--vtl-radius,8px), so a consumer has one knob
and the three surfaces cannot drift apart. The 48px Material row height is
untouched. The YouTube rationale stays in the comment as history with an explicit
note not to restore the pill from it, because a superseded rationale that still
reads as current is how a reversed decision gets quietly undone.

The .vtl-cap badge keeps its pill. The complaint was about the ratio of radius to
size -- a 16px chip at 999px is a chip, a 48px row at 999px is a stadium.

The studio opened on nothing and said "Choose an element from the list." It now
resolves a tab in priority order: the URL hash, since an explicit shareable
request should beat a remembered one; then localStorage, so a reload is not a
fresh start; then the first row, so the pane is never empty. This lives in the
studio rather than the layout because VerticalTabsLayout is controlled via
v-model:active and deliberately holds no selection state.

Storage is keyed by pathname so two shells on one origin do not fight over one
key, every access is wrapped because localStorage throws in private mode and an
exception in setup would blank the whole subtree, and the key is only written
after the row is confirmed to exist so a bad key cannot poison the next load.

Verified in the built shell on a settled tree: first load auto-selects with no
hint and stores the key; clicking big-picture moves the selection and updates
storage; a reload restores big-picture; and a deliberately poisoned key falls
back to the first tab and rewrites itself rather than rendering an empty pane.
2026-09-23 01:03:15 +00:00
doxa-maps-88655c70
3c594386b4 Give the dashboard a page-scroll layout so the grid beneath it is reachable
The Driver could not scroll down to the people-groups grid. The cause was a
layout mismatch rather than any one property: research-dashboard is a
fixed-viewport app whose table scrolls internally, and the cap-4 composite
stacked it above a grid and expected the page to scroll, pinning the band to one
screen so height:100% would resolve. At a 900px viewport that left the table a
269px window over 54834px of rows, so a wheel anywhere in the lower third landed
on the table and never reached the page.

research-dashboard now accepts layout:page-scroll through profileConfig, the
channel ProfileLoader already exposes as the profile-config attribute. A prop
would not work here: the registered custom element is ProfileLoader, which
renders the profile with no props bound, so a declared prop type-checks, builds
clean and never fires. Default is unset, so every existing embed is unchanged.

In that mode the shell height is auto, the map row carries an explicit height so
it cannot collapse, and the table keeps an internal scroll bounded at 62svh.
Both extremes were measured and both are wrong: at 34% of a screen the table is
a keyhole that captures every wheel event, and released to overflow:visible it
renders all 54834px and puts the grid at y=57777 -- reachable in principle,
useless in practice. A tall bounded panel is neither.

Verified in the cap-5 shell on a settled tree: wheeling over the map and
wheeling below it now both move the page 1500px, which is its entire scroll
range, and the grid lands on screen at top 851. The two gestures agreeing is the
real proof -- the broken state read 300 against 2311, the signature of something
consuming the wheel. The standalone leaf is unchanged: no page-scroll class,
900px shell, internal table scroll intact.
2026-09-23 00:53:29 +00:00
doxa-maps-88655c70
b40fd5b48d Take overscroll-behavior back off the table, and record what the wheel actually hits
The previous commit set overscroll-behavior:contain on .rd-grid to stop it
swallowing the wheel. That is backwards. contain prevents a scroll which bottoms
out in an element from chaining to its parent, and chaining to the parent is the
behaviour that would let the page keep moving once the table is exhausted.
Measured before and after: the symptom did not move at all, because a table with
54834px of content never bottoms out in the first place.

It is left at the default.

The diagnosis that produced it was reached by geometry, and geometry was the
wrong instrument. getBoundingClientRect placed this element at y=673-880, well
below the y=420 gesture, which appeared to exonerate it; a deep shadow walk had
earlier reported its scrollTop moving, which appeared to convict it. Both are
readings of state at some moment, not of what the browser dispatched. A
capture-phase wheel listener reading event.composedPath() settles it: at y=420
the path is rd-td, rd-row, rd-grid, rd-table-wrap. Use the event path when
geometry and behaviour disagree.

The underlying bug is still open and is a layout mismatch rather than a CSS
property: the research dashboard is a fixed-viewport grid that gives its table
34% of the band and legitimately owns the wheel inside it, hosted in a composite
that expects the page to scroll past it. That needs a design decision, not a
fourth attempt at a one-line fix.
2026-09-23 00:40:33 +00:00
doxa-maps-88655c70
285ba39c31 Stop the data table from swallowing the wheel, and grade the gesture honestly
The Driver could not scroll down to the people-groups grid. Three separate
diagnoses blamed the map, including one of mine that shipped. Measured in the
cap-5 shell: a ten-tick wheel with the cursor over the dashboard moved the outer
pane 300px while .rd-grid's own scrollTop went 0 to 2606. The wheel was being
consumed by the table -- a virtualized grid whose scrollHeight is 54834px inside
a ~207px window. That is ordinary scroll-chaining: a nested scrollport eats wheel
events until it reaches its own end, and with 54 thousand pixels of table it
never reaches the end. The Mapbox canvas sits above the cursor and was never
involved.

overscroll-behavior:contain does not stop the table scrolling; it stops it
chaining, so the remainder now reaches the pane.

The gate that should have caught this had two defects of its own. It measured the
gesture the Driver makes and then asserted on a different number, so it passed at
2611px while his gesture moved 300. When that was fixed the threshold was written
as "moved less than 200px" -- a figure picked without comparing it to the value
already measured as broken, which was 300, so it still passed. The threshold is
now derived from a measured baseline: the same gesture performed lower down the
page, where it is known to work. Two identical gestures on one surface should
move it comparably, and there is no constant left to rot.
2026-09-23 00:31:35 +00:00
doxa-maps-88655c70
92fae9937d Stop the pin comments claiming a navy pin and a 7:1 floor that no longer ship
Three comment sites still described the pre-heartbeat product in the present
tense, which is the confidently-wrong failure this file warns about elsewhere:

- the measured contrast table cited the old pale-coral hexes
- 'WHAT IT IS NOW. Fill #1a237e ... under a white ring' described a pin that
  no longer exists, and listed 'reverting to orange' as a live rejection when
  the Driver has since asked for orange
- the constraints list asserted 'navy pin >= 7:1 on every bin. PASS', which
  reads as an acceptance criterion someone would try to restore

An orange pin cannot reach 7:1 against a red ramp at any tuning, so the
criterion is replaced rather than quietly lowered: effective visibility is
max(orange disc, white stroke) per bin, worst 1.83.

Also answers the standing 'MEASURE the pale end' note: the two bands swapped
roles. The disc is strongest on the pale bins where the ring is weak, and
collapses on the dark bins where the ring takes over, so both must stay.
2026-09-22 22:49:22 +00:00
doxa-maps-88655c70
34cbb39a5c Make the heartbeat-zone threshold a variable, not a constant
The choropleth's bin edges were a 5-stop literal (5/20/60/150/Infinity) in
DashboardMapPanel.vue. Those numbers are not a property of choropleths; they
are a property of THIS dataset — ~2000 people groups in focus. Pointed at a
200-group dataset, a 150 cutoff puts every country in the bottom bin and the
map renders uniformly pale: no crash, no error, correct code, meaningless map.
A dataset assumption wearing the costume of a constant.

The top band is the HEARTBEAT ZONE — the countries holding the most people
groups. Its edge now comes from dashboard.config.js as map.heartbeatThreshold,
alongside the other per-dataset facts (facets, columns, kpis). Re-pointing the
dashboard at a new dataset is a config edit, not a source edit.

Splits the old fused BINS array into the two independent facts it always was:
BIN_COLORS (measured against the navy pin, dataset-independent) and the edges
(pure dataset assumption). Fused, moving a threshold meant restating every hex
— silently reverting the contrast work — and restyling meant restating every
edge. Set either alone now.

Setting heartbeatThreshold alone rescales the three lower edges by their
original ratios, so the ramp keeps its shape (long tail of small counts, short
head of large ones) at any dataset size. Legend labels derive from the edges,
so a moved threshold cannot leave a stale "150+" on the legend. Opt-in
'quantile' mode derives the edges from the data actually loaded; it is NOT the
default, because 150 is a deliberate number for this dataset and a derived
default would silently change today's map.

Two bugs found and fixed while measuring:
- Edges could land at 1, creating a bin no count can enter (colorFor tests
  `count < upTo`): the ramp painted 4 of its 5 colours while the legend
  advertised 5. Appeared at threshold 40 and again in quantile mode.
- profileConfig merged shallowly, so a host passing the single key it cares
  about — {map:{heartbeatThreshold:40}} — replaced the whole map object and
  dropped mapStyle:'paper'. The one override a host is most likely to write
  was the one that broke the basemap. `map` now merges one level deep; every
  other key stays shallow.

No defineProps: the registered custom element is ProfileLoader, which renders
the profile with no props bound, so a prop declared on the profile would read
as configurable and never fire. The reachable attribute path is ProfileLoader's
existing profile-config JSON, verified end-to-end.

Default rendering is unchanged, verified by measurement rather than eyeball:
bin assignment over the 114-row committed fixture (59 countries) plus a 17-point
count sweep is identical before and after, with and without config supplied.

internal/check-heartbeat-threshold.mjs executes the resolver from the live SFC
and asserts the bins MOVE when the config moves — a grep for "150" would both
false-positive (it is the legitimate default) and false-negative (a
re-hard-coding at 200 sails through). Its --self-test re-hard-codes the ramp in
memory and requires the gate to reject it.
2026-09-22 22:25:38 +00:00
doxa-maps-88655c70
2ec6c00e68 Stop the map from swallowing the wheel, and grade the gesture the user makes
syncScrollZoomToHost decided whether Mapbox should keep scrollZoom by sampling
the layout at 0, 1200 and 4000ms and then never asking again. That is a race,
not a sync: the cap-5 dashboard tab lazy-mounts on activation, so at every one
of those instants the scrollport ancestor had not reached its final height. The
map kept scrollZoom enabled and ate the wheel. Wheeling over the map moved the
grid 300px; wheeling below it moved 2611px. The cursor is over the map, so the
people-groups grid under the dashboard was unreachable.

The condition is layout-dependent, so observe layout instead of guessing when it
settles: a ResizeObserver on the map container, the scrollport that owns the page
scroll, and body, plus a resize listener. Every event that can flip the answer now
re-asks it.

verify-shell-scroll already measured this. It computed movedByMap for the gesture
over the map, then asserted only on movedTotal, which sums in a second gesture
below the map -- so it reported PASS at 2611px while the real gesture moved 300px.
It now asserts on the number it was built to measure. Worth remembering as its own
failure family: a gate that measures the right thing and grades a different
variable, which fails honestly but never on the condition it exists for.
2026-09-22 22:06:59 +00:00
doxa-maps-88655c70
9533cca050 Land the prayer facet and the scrim helper
Some checks are pending
build-gate / build (push) Waiting to run
The prayer filter the Driver asked for, first in the dropdown order as he
specified. Keyed on people_praying, a live per-row pray-tools field already
read by three shipping consumers, and it reuses getPrayerLevel plus its LABELS
from the prayer-progress colour strategy rather than re-deriving the split --
so the filter can never disagree with the colour of the pin it filters to.

It is a real facet in config.facets with a get() accessor, which is the part
that matters: useDashboardShell builds FACET_ACC from config.facets alone and
filteredRows FAILS OPEN on an unknown key, so a facet declared only in an
adapter would render a working dropdown, land a chip, and filter nothing,
silently.

Also lands library/utils/viewportScrim.js, shared by DataTable,
SemanticTreeLegend and PosterDialog for the modal-scrim fix -- one helper
rather than six copies of the same workaround.

Committed by the coordinator rather than the authoring agents, same reason as
ad6da54: verified work sitting in the working tree is how the shell-scroll fix
was lost earlier today.
2026-09-22 21:50:32 +00:00
doxa-maps-88655c70
ad6da54674 Checkpoint the swarm's in-flight work
Some checks are pending
build-gate / build (push) Waiting to run
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.

Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.

Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.

Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
2026-09-22 21:42:18 +00:00
doxa-maps-88655c70
f33f7afb1c The map must not make lostness look good — green ramp -> ashen clay
The choropleth ran the product ACCENT up the lostness axis. Bins went pale green ->
saturated green as the count of UNREACHED PEOPLE GROUPS rises, so the deepest, richest
green marked the places with the MOST LOST PEOPLE. And #92b195 is not an arbitrary hue:
it is --brand in 48 places, the selected-tab underline, the active-row marker — the
colour this product uses to mean GOOD/SELECTED.

DRIVER: "it does not make sense for the tiles to be green because green means good while
it is not good to be lost, and the darkest spots are where the most lost people are at...
it should not make it look green and nice that these nations are so lost."

The vision is explicit that FAR means "the farthest, 99.9%-lost places... eyes to see
where the Kingdom isn't, and be SENT there". The dark end of this ramp is not a high
value — it is where people are who have never heard. A palette tuned for comfort
anaesthetises the exact response the map exists to provoke. Beautiful is still the goal:
the artwork of a LAMENT, not of a garden.

=== TWO MEASUREMENT FINDINGS, both of which changed the answer ===

1. THE HANDED-OVER CANDIDATES FAIL. EMBER (said 7.04) and CLAY (said 7.03) were
   composited over PURE WHITE. This panel sets style `paper` -> OpenFreeMap Positron,
   whose background layer paints rgb(242,243,240) = #f2f3f0. On the ground that really
   paints: EMBER 6.86, CLAY 6.84 — both BELOW the 7:1 floor; old green 7.09, only just
   over. Confirmed rather than guessed: compositing over #ffffff reproduces 7.04 / 7.03
   / 7.29 exactly.

2. THE LEVER WAS NEVER DARKNESS. The brief framed this as structural — "a hue that reads
   as grave must go DARK, and darkness is what kills contrast". But WCAG contrast follows
   relative luminance, which LIGHTNESS dominates; CHROMA is nearly free. Solved across
   hue 30-50 deg at chroma 0.06-0.12, every restrained candidate lands at 7.15-7.20 — the
   muted ones measure the SAME as the saturated ones. "Lament, not fire alarm" cost
   nothing in contrast. Nobody had separated the two variables.

=== WHAT SHIPS: ASHEN CLAY ===
OKLCh hue ~36-41 deg (the deficit family), chroma held deliberately low (0.013 -> 0.060)
so it reads as ash and dried earth, not terracotta or alarm-orange. The 6-step scale runs
from the product SURFACE #f3f3f1 (= NO_DATA, so the ground is unchanged and lightness
still carries the ORDER) down to the darkest endpoint the pin floor allows on the real
basemap; the 5 DATA bins are the steps below the surface. Interpolated in OKLab so the
lightness intervals are perceptually even.

  NO_DATA #f3f3f1 11.91
  #eee3df 10.82 · #e8d4cd 9.82 · #e2c5bc 8.90 · #dcb5aa 7.99 · #d6a699 7.20

THE THREE CONSTRAINTS, each verified not asserted:
  1. navy pin #1a237e >= 7:1 on every bin at the real fill-opacity 0.78 — worst 7.20.
     PASS, and an IMPROVEMENT on the green it replaces (7.09) and on both candidates.
  2. no red-vs-green dependence. Simulated deuteranopia and protanopia: bin luminances
     stay strictly decreasing, so ORDER survives — read by lightness, never by
     discriminating a hue from green.
  3. accent #92b195 absent, and not even NEAR: it sits at OKLCh hue 148 deg and the
     nearest chromatic bin is 106 deg away. Checked as a hue DISTANCE, not an exact-hex
     match — a near-miss reads as a rendering artefact, which is worse than an obvious
     clash because nobody files it.

Also measured, and never checked before: buildPaint() repaints at 0.92 for in-filter
countries and 0.12 for dimmed ones. Worst pin at 0.92 is 6.53 (old green 6.40 on the same
path) and at 0.12 is 11.06. The 0.92 path is the true worst case for EVERY ramp including
the old one and sits below 7 — a PRE-EXISTING condition this change improves rather than
introduces, recorded so it is measured rather than rediscovered and blamed on the hue.

Second-order numbers re-measured rather than left stale: border #6d766e now 4.23 / 3.84 /
3.49 / 3.16 / 2.84 / 2.56; white ring vs ground 1.11-1.84 (its real job, dot-from-dot
separation vs the navy pin, is unchanged at 13.24); legend swatches 1.26-2.15 on white.

BUG-PINS-READ-AS-EYES-CREEPY verified still held: exactly one circle layer on rd-bubbles,
two colour bands (navy fill + white stroke), no third band, rd-bubble-halo absent. Not
touched, and no band reintroduced.

Repro: _qa-cycle/01-ideation-research/scratch-lament-ramp-contrast.mjs (all ramps, three
targets, both CVD sims), scratch-lament-ramp-solve.mjs (the solve), and
scratch-lament-ramp-swatch.mjs (renders the swatches so the choice can be looked at).
2026-09-22 21:26:24 +00:00
doxa-maps-88655c70
5d12841fe7 Make the charts rail a tab strip, with Gospel Resources disabled
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked "a long time ago" that the chart become a tab so Gospel
Resources could sit beside it, with Charts selected by default. The rail had a
plain "Charts" heading.

Builds the half that is buildable. The container needs no gospel data and no
ruling; what the second tab ENCODES does -- he described it as the same facet
breakdown re-measured on a different axis, "people groups by their gospel
resources availability", and three decisions are still open: the official
lostness number, key-less versus a registered Joshua Project key, and the UI
shape. Guessing any of them ships a wrong picture that looks right, which is
this codebase's most expensive failure mode.

So the second tab renders DISABLED rather than hidden, carrying its reason in
the title attribute. A hidden tab is indistinguishable from an unbuilt one; a
disabled tab that names what it waits on is a status report the Driver sees
every time he looks at the rail.

The strip inherits .rd-h's exact typography -- weight 700, --fs-md, uppercase,
.5px tracking -- so the rail's visual weight is unchanged and this reads as the
same header gaining an option, not as new chrome. The selected tab uses the
accent underline the facet rail already uses for an active row, so one visual
language covers both rails. .rd-h is kept but was verified unused in this file
and everywhere else under research-dashboard/.

Verified the backtick rule that has broken this build three times: zero
backticks inside the useShadowStyles literal (chars 5459-18249); the one in a
JSDoc comment sits outside it. check:template-literal and check:sfc-parse pass.
2026-09-22 20:19:32 +00:00
doxa-maps-88655c70
c959ed6323 Load the RTL text plugin from one place, and in the composite too
Some checks are pending
build-gate / build (push) Waiting to run
The cap-4 composite bakes research-dashboard's .vue directly, so it never runs
that bundle's entry file and has to repeat everything the entry does. It
repeated Pinia, i18n and the colour strategies. It did not repeat this one.

Measured in the shipped bundles:
  research-dashboard.js ..... 1 occurrence of mapbox-gl-rtl-text
  doxa-research-map.js ...... 1
  dashboard-plus-grid.js .... 0

So Arabic, Hebrew and Persian labels rendered reversed inside the cap-5 shell
and correctly standalone. Not cosmetic here: Arab World has 99 rows and
Persian-Median 49, and a reversed label is a wrong NAME for exactly the groups
this map is about. It failed silently -- no error, no warning, just mirrored
text that reads as a font problem to anyone who does not read the script.

Fixed as a shared library function rather than a fourth hand-copy, which is
what the composite-registration card asks for: one definition, two callers,
nothing to drift. Idempotent by construction, since Mapbox throws on a second
setRTLTextPlugin in a session and that throw is swallowed.

Four of the five entry side effects fail silently; only provide('profileModules')
throws, and that is the one safe to omit because a baked composite does not use
ProfileLoader. So "it renders" is no evidence the entry's work was done.

Found by the map-beauty agent, which I verified independently before acting
rather than taking the bundle counts on report.
2026-09-22 19:55:58 +00:00
doxa-maps-88655c70
00bf294d41 Compact the pane-scroll comments, keeping both declarations
Some checks are pending
build-gate / build (push) Waiting to run
An agent collapsed the multi-line notes on .ps-pane and .vtl-host into
one-liners. Both declarations survive in both synced copies -- overflow-y:auto,
overscroll-behavior:contain, and --profile-min-h:0 -- and check:shell-scroll
still passes on the built tree: 2611px scrolled, grid on screen, no map-tab
scrollbar.

Committing straight away rather than leaving it in the working tree. These are
the exact two files where the scroll fix was silently lost last tick: it
existed only uncommitted, a gate passed against those bytes, and a second
agent's edit overwrote them before anything reached git.

Worth noting a false alarm in my own check: grepping for a formatting-
sensitive string reported one copy where there are two, because the compaction
left them with different whitespace. The semantics are identical, which is why
check:synced-css compares property maps rather than text.
2026-09-22 19:18:11 +00:00
doxa-maps-88655c70
35de2f443a Neutralise the dvh floor on hosted panes so a map tab keeps no scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
Restoring the pane scrollport exposed the other half of the same fix, also
lost: check:shell-scroll passed the grid assertion and then failed with "a MAP
tab grew a scrollbar (131px overflow: 900/769)".

131 is exactly 900 minus 769. A hosted map ships
.profile-loader { height:100%; min-height:100dvh } and the two resolve against
different bases -- height against the pane, which sits inside a .ps-stage inset
by the studio chrome, and the dvh floor against the viewport. The floor wins,
the element is taller than its container, and overflow-y:auto faithfully draws
a scrollbar on a tab that should never have one. Making a pane scrollable is
only correct if it stays a no-op for fixed-viewport elements.

The floor is deliberate and must stay for the standalone leaf, where it fixes
the opposite bug: min-height:100% against a body with no definite height
collapses to 0. So it is neutralised on the hosted subtree instead.

Sizing the host is not enough, and this is the part that had gone missing.
.profile-loader lives inside the map's SHADOW ROOT, where a host-page selector
cannot reach it. Inherited properties can, so ProfileLoader reads
min-height:var(--profile-min-h, 100dvh) -- already in the file at line 282 --
and the pane now actually sets it to 0. Three comments described this
declaration; none of them was the declaration.

Not overflow:clip, which bounds the map correctly and also bounds the
scroll-stack composite whose content is 3380px against a pane-height host, so
the grid goes unreachable and the gate returns to 0px scrolled. One blanket
rule cannot serve both.

Both synced copies. Committed before re-verifying.
2026-09-22 19:09:38 +00:00
doxa-maps-88655c70
043b40ddbd Give the shell pane its scrollport back
check:shell-scroll went back to "wheel moved 0px, grid UNREACHABLE" with
2586px of grid mounted. The cause is not a later edit undoing a committed fix:
git history shows .vtl-host never had overflow-y:auto in any commit. Seven
commits, all `position:absolute; inset:0`.

The fix only ever existed in a working tree. An agent edited the file live,
the gate passed against those uncommitted bytes, and a second agent editing
the same file overwrote them before anything was committed. Every observation
was true and none of it was durable. Worse, 762953a was committed while the
tree was in the reverted state and described as landing the scroll fix.

Restored on .vtl-host and .ps-pane, both synced copies of each, and the reason
is now in the file rather than in a session transcript: the composite reports
scrollHeight 2838 against clientHeight 769 here, 2069px of mounted grid with
nowhere to go, while the document measures 900 === 900 so a wheel anywhere has
nothing to move. It is a host gap, not a plugin bug -- the same composite is
perfect standalone because its generated leaf gives the document the scroll.

The comment says the line has been lost once and points at the gate, because
`inset:0` looks like the simpler form and someone will reach for it again.

Committing before re-verifying, deliberately. A gate result is only as durable
as the bytes it ran against, and this bug is the proof.
2026-09-22 19:04:21 +00:00
doxa-maps-88655c70
762953a054 Land the swarm's dashboard work: colour, rails, cascade, scroll
Some checks are pending
build-gate / build (push) Waiting to run
Sixteen source files from five agents, all verified against source gates
before commit: template-literal, sfc-parse, kpi-unit, composite-reg,
facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and
quoted-scripts all pass. public-hygiene reports 0 findings.

Colour. The choropleth ramp is now derived by interpolating the product's own
tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and
that fixes a pincer the earlier research had not named: on the old grey-to-black
ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin
colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order
breaks fixed with it -- the country outline would have measured 1.08-1.82 as
white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch
border.

Religion colours no longer key by array index. colorFor takes no index at all;
colour comes from the registry by facet plus value. All 31 distinct live
religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0
against the live 2,107-row API.

Cascade. Clearing a parent facet now clears its descendants, in the selection
bus rather than in any component, because every clear path already funnels
through setFilter/clearFilters -- a component fix would have fixed one button
and left four. The hierarchy is derived from config.facetRail[].drill, so it
cannot drift from the tiers the rail renders. Place turned out to be three deep
(region, block, country), not two.

Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the
two at 296 vs 264, with the map cell unchanged because both pairs sum to 560.

Also lands the shell scroll fix and the facet-cascade gate with its committed
fixture -- 73 sampled rows, no network, so the assertion that reproduces the
empty map runs on a fresh clone instead of silently skipping.
2026-09-22 18:57:39 +00:00
doxa-maps-88655c70
2f5cf655f4 Draw the pin as one circle with a white stroke, not three colour bands
Some checks are pending
build-gate / build (push) Waiting to run
The pins read as eyes. The cause is structural: two stacked circle layers
produced, outward from the centre at z1, navy disc r=1.6, white field r=3.4,
navy stroke 0.6px. Dark centre, light middle, dark rim -- pupil, sclera, iris
outline. A comment in the file already called it "a navy/white/navy sandwich"
without noticing what a sandwich of those bands draws.

Concentric rings sharing a centre are the pattern the visual system fuses into
one object, which is why a bullseye is named after an eye. The inner disc sat
at 47% of the halo radius at z1, the proportion that reads as a pupil rather
than a dot with a border, and worst when zoomed out -- where a world map lives.

The outer navy band is the specific culprit: it closes the iris. Removing it
leaves the same colours reading as an ordinary pin, so this deletes a band
rather than changing a colour. Both Driver rulings survive: blue dots, white
rings, one fixed colour at full opacity.

One layer with a stroke is also the documented Mapbox idiom --
circle-stroke-width draws outside circle-radius, so a single layer already
gives disc-plus-ring, and the old code hand-rolled that and collected a third
band for the trouble. Stroke now scales with zoom, the documented way to stop a
ring swallowing the fill at low zoom. Cartographic practice agrees: Axis Maps
and the ICA Commission both put casings on symbols that OVERLAP rather than a
global opaque halo, and Esri names halos the biggest cost in marker drawing
performance -- paid here on a second full circle layer per point.

Research and sources in
_qa-cycle/01-ideation-research/RESEARCH-pin-design-why-they-read-as-eyes.md

Not yet verified: no hardware WebGL here, so the appearance is unproven. The
pale end of the ramp needs measuring, since the outer band was what gave the
white ring a hard boundary on any ground, and a perception complaint needs the
Driver's eye rather than a contrast ratio.

Also removes the table header caption "Filtering or selecting above updates
this list". Its centring history is kept in a comment: it records a reversed
judgement, not a mistake -- v1 put it in a second header, it was moved to the
existing bar on request, and centred by absolute positioning because flex
spacers landed it 55px off centre. All correct, and it still had to go: it
narrates the one behaviour on this surface that needs no narration.
2026-09-22 18:46:16 +00:00
doxa-maps-88655c70
a11ec14852 Print each KPI percent sign once
Some checks are pending
build-gate / build (push) Waiting to run
The dashboard rendered "99%  % UNREACHED" and "1%  ADOPTED %". kpiText returns
k.value unchanged for kind:'pct' and that value already carries its percent
sign, while the labels in dashboard.config.js carried a second one.

Each half is correct alone. A label reading '% unreached' is a reasonable
label; a formatter returning a ready-formatted percentage is a reasonable
formatter. Only the composition is wrong, and neither file mentions the other,
which is why it was found by reading the rendered screenshot rather than by
reading either source.

check:kpi-unit asserts no kind:'pct' label contains a '%'. The value owns the
unit because it is produced by the formatter that knows the number; the label
says what is being measured.

Parses the config as text rather than importing it -- the research-dashboard
module graph reaches library/colors/_registry.js, which uses import.meta.glob
and throws under plain node. Text parsing keeps it hermetic enough for
build:unlocked. Comments are stripped first, because the config now documents
this defect right above the fixed line and a naive scan fails on the
explanation of the fix.

It also fails when it finds no pct entries at all, rather than printing a
green PASS: if the config is refactored so the parse stops matching, silence
would read as success forever.

Negative-tested: restoring either label fails by name with exit 1.
2026-09-22 18:28:53 +00:00
doxa-maps-88655c70
5e6bed7d7a Hand the wheel back when a scrollable ANCESTOR owns the scroll
Some checks are pending
build-gate / build (push) Waiting to run
Mapbox calls preventDefault on every wheel over its canvas. The panel already
knew that and disabled scroll-zoom when the page could scroll -- but it asked
whether the DOCUMENT scrolls:

    canPageScroll = document.documentElement.scrollHeight > innerHeight + 4

Inside the cap-5 shell the document is pinned to one screen and an ancestor
ELEMENT owns the scroll. Measured on the dashboard-plus-grid tab at 1600x900,
walking the composed tree from .pgg-root upward:

    div.stl          oy=visible  ch=769  sh=2838  over=2069
    section.ps-pane  oy=auto     ch=769  sh=2838  over=2069   <- real scrollport
    div.ps-stage     oy=hidden   ch=769  sh=769
    document         sh=900      ch=900                       <- "nothing scrolls"

So the condition computed false, scroll-zoom stayed enabled, and the map ate
every wheel with 2069px of grid mounted below the fold.

Proven at the event level rather than inferred: the wheel at (800,600) lands
on canvas.mapboxgl-canvas, and .ps-pane.scrollTop stays 0 after a real
mouse.wheel at both y=600 and y=860, while a programmatic scrollTop = 800
moves it to 800. The scroller was never broken; the gesture was consumed
before it arrived.

That is why three earlier fixes to the containers were each correct and
changed nothing -- they made the page scrollable, and the wheel still never
reached it. The honest question is "is there a scrollable ancestor", not "does
the document scroll"; the document is one possible answer and in an embedded
host it is the wrong one.

The ancestor walk hops shadow boundaries via parentNode.host, because
parentElement stops at one and every plugin here is a compiled custom element.

Browser verification still pending: the build queue is draining several
agent-requested rebuilds, so check:shell-scroll has not yet run against a
settled tree carrying this change. The fix is confirmed present in the
minified bundle.
2026-09-22 18:18:22 +00:00
doxa-maps-88655c70
e1d27cb476 Record which kind of verification closed each card
Some checks are pending
build-gate / build (push) Waiting to run
A card was closed on an agent measurement while the bug was still visible to
the Driver, and the card had no field in which that disagreement could live.
The board said done and the product said broken, and nothing in the data knew.

Adds fields.verification (agent|human|both) plus verification_kind
(visual|state) and a conflict flag, back-filled across 179 closed cards, and
a gate that refuses an anonymous close.

The gate does not require human sign-off. Gating the board on human review was
tried and it stalled. What it forbids is losing the distinction, so the
question "which of these did a human actually look at?" stays answerable.

The visual/state split encodes the asymmetry the Driver named: human
verification is more reliable on visual bugs, agent verification on non-visual
ones. A headless probe reads geometry, not perception -- it can measure a menu
at dTop=4 and still be blind to it feeling wrong. So a visual card closed on
agent evidence is a hypothesis; a state card closed on agent evidence is a
result. The counts are reported separately.

Negative-tested three ways: stripping a tag, claiming human verification with
no quote, and marking a conflict card done each exit 1 by name.
2026-09-22 17:00:26 +00:00
doxa-maps-88655c70
9f1f657209 Expand a branch under its own parent, even when a filter is active
Some checks are pending
build-gate / build (push) Waiting to run
Expanding a node that had just been selected pushed its children to the bottom of the
tree instead of under the node. The Driver isolated it precisely: clicking only the
arrow always worked, and clicking the row first and then the arrow did not — which
pointed at the filtered path rather than at the expand logic.

The re-admission of filtered-out rows walked the unfiltered baseline and appended
anything the baseline did not recognise. A child revealed by expanding a selected node
is exactly that: it never existed in the unfiltered snapshot, so it had nowhere to go
but the end. With no filter active that path is never taken, which is why the arrow
alone behaved.

It now walks the current rows and splices the missing ones back at their anchors, so
live order is preserved and the baseline is only consulted for what is absent.

Verified three ways: expanding alone puts the first child at index one; selecting then
expanding also puts it at index one, one level below its parent; and a filtered rail
still keeps its shape, going from sixty-six rows to sixty-seven with thirty-five dimmed
in place and none lost. A ten-step random walk left one hundred and fifteen selectable
rows and no page errors, and Clear All returned the rail to sixty-five.
2026-09-22 16:45:03 +00:00
doxa-maps-88655c70
d79f01590f Put the CSV header on the first line so the export opens as a table
Some checks are pending
build-gate / build (push) Waiting to run
The export carried its provenance comment above the header, and every spreadsheet
importer treats line one as the header — so the scope sentence became the column names
and the real header became the first data row. A single-column line above a sixty-eight
column table is what "glitched" looks like on screen.

Three other explanations were plausible and all three are wrong, measured before
anything changed: the file is comma-separated, not tab-separated, so the tabs in the
report were terminal rendering rather than bytes; the escaper is genuinely RFC-4180, and
embedded commas, quotes and newlines all survive a strict parser round trip; and
injection-prefixed cells were already neutralised. Only the position was wrong, and
chasing the other three would have produced churn and no fix.

The provenance line moves to the end rather than away. It records which filters produced
the file, which is the difference between a dataset and an anecdote, and a trailing
single-column row lands as one populated cell instead of destroying the column mapping.
The field counter that read line two moved with it — left alone it would have counted
the first data row, usually the same number, so it would have looked right and been
wrong.

A new check parses the export the way a spreadsheet would, which no existing gate did:
forty-six checks and not one read the file. It extracts the shipped escaper rather than
restating the rule, so it cannot agree with itself while the product disagrees.
Negative-tested by putting the comment back on top.
2026-09-22 16:30:36 +00:00
doxa-maps-88655c70
6b29e146df Let the deepest two tree tiers reach the grid
Some checks are pending
build-gate / build (push) Waiting to run
Cluster and People Group changed the dashboard correctly and could never move the grid,
because the fields behind them were not in the grid's fetch. They are real fields, unlike
the two family axes, which are derived and have no row field to filter against at all —
those stay unmapped rather than being aimed at a lookalike and quietly showing the wrong
rows.

Verified against the live data before mapping, since a wrong mapping is worse than no
mapping: both fields are present on 2,106 of 2,107 rows in the same value-and-label shape
as the axes already wired, with 194 and 1,736 distinct values, no collisions once
normalised, and no case where one entry's label matches another's code. Codes and names
never overlap, so either vocabulary can be accepted safely.

Measured end to end: selecting the Telugu cluster narrows the grid to 93, which is
exactly the number of rows the API holds for it.
2026-09-22 16:22:21 +00:00
doxa-maps-88655c70
56dbb6770d Let the wheel reach the page when a map is only part of it
Some checks are pending
build-gate / build (push) Waiting to run
The people-groups grid under the dashboard could not be reached by scrolling. Every
earlier investigation looked at section heights and overflow, because that is what the
documented fix addressed — but the cause was the map itself: Mapbox binds the wheel for
zoom, so the document never saw the event. Measured before: fourteen wheel ticks left
scrollY at zero and the first grid card exactly where it started.

Scroll-zoom is now disabled only while the page has somewhere to scroll, so a map that
is the whole page still zooms on wheel and a map stacked above a grid gets out of the
way. Measured after: scrollY 1549 and the first card moved from 897 to -652.
2026-09-22 16:03:33 +00:00
doxa-maps-88655c70
ec012f45b6 White rings on the pins, one place for selections, and two gates that told the truth
Some checks are pending
build-gate / build (push) Waiting to run
Pins keep their navy but gain a white ring and a radius that shrinks as you zoom out, so
neighbouring dots separate instead of merging into one blue mass. The Driver asked for
rings rather than clustering, explicitly to avoid more logic.

The top header no longer carries selection state. That belongs under the filter toggles,
in the second header, and nowhere else.

The filter menu is anchored to its trigger again. It had been moved to fixed positioning
to escape a scroller that was clipping it out of existence, and viewport coordinates go
stale in ways that put the menu somewhere unrelated to the box that opened it.

A gallery row now carries its identity as an attribute. A Vue key is a reconciliation
hint and is never rendered, so the smoke check read undefined and reported a broken deep
link on a page that works — measured zero occurrences of that attribute in both the
generator and the built page. Asserting on the visible label instead would have coupled
a machine check to display text.

That repaired gate immediately earned its keep: it caught the gallery asking for assets
one directory too deep. The relative asset base was correct while the gallery was the
root page and silently wrong once it moved aside for the studio, and the failure surfaced
as a JSON parse error rather than a 404, because an unknown path answers with HTML.
2026-09-22 16:01:16 +00:00
doxa-maps-88655c70
5725f5b9d9 Let a stacked composite find its siblings in either tree
Some checks are pending
build-gate / build (push) Waiting to run
stack-demo rendered two empty sections in dev. Its sibling bundles were addressed with
a tree-relative path, which the browser resolves against the document URL, so no host
and no attribute can redirect it — correct in the built tree, a 404 in dev. Measured:
the dev entry answers 200 while the path the relative form resolves to answers 404. The
sources now resolve at runtime, so the same file is right in both trees, and the
production branch compiles away to the string it replaces.

That was the third place this defect appeared. Both capability-5 shells carried the same
hardcoded paths and were cured the same way, and each instance was found separately
after the previous one was fixed — because a literal parent-directory path reads as
correct in review and is invisible to a gate that only ever checks the built tree.

Removing the 404 was not enough: the sections were still empty with zero failed
requests. The shared layout injected each bundle with a classic script, and a classic
script pointed at a dev ES module fires its load handler successfully and only then
throws, so the promise resolved while the tag never registered and the wait for
definition quietly timed out. A load handler cannot detect that.

The module rule now lives in the shared layout rather than in a fourth private copy of
the same loader, so the two other composites that inject through it are covered too.
Verified after: both elements define, sixty-six facet rows and twenty-four cards on
stack-demo and on dashboard-plus-grid, zero failed requests on each.
2026-09-22 15:37:41 +00:00
doxa-maps-88655c70
8b1e3d3f5d Stop the capability-5 shells claiming a native element property
Some checks are pending
build-gate / build (push) Waiting to run
Both shells declared a `title` prop. defineCustomElement maps every prop to an
attribute, and `title` already exists on every HTMLElement as the tooltip, so Vue warned
on every single mount that it would overwrite the native property. Harmless in effect —
nothing here wants a tooltip — but a warning repeated on every mount is where a real
error goes to hide, and this repo has already paid for that once when a thrown setup()
silently removed three buttons behind noise that looked ignorable.

Renaming the prop was safe because nothing outside ever set it: no host passes title=,
no profile-config reads it, and the value is only forwarded to the shared layout, which
is an ordinary Vue component rather than a custom element and so has no collision of its
own. Both shells carried the identical declaration — the same duplicate-implementation
pattern that earlier turned one resolver assumption into two separate outages — so both
were fixed together.

The studio smoke check now samples several tabs instead of only the first, and measures
node depth inside the mounted element rather than counting the slot's children. The old
check read one child for every pane whether the element rendered a full dashboard or
nothing at all, which is the "mounts is not works" trap its own header warns about. It
polls for content rather than sleeping a fixed interval, because a fixed wait either runs
too slow to use on every edit or marks a slow tab broken, and a gate that cries wolf gets
switched off.

Measured after: zero Vue warnings in the browser console.
2026-09-22 15:03:52 +00:00
doxa-maps-88655c70
38a7dbb939 Land the swarm's work: no iframes anywhere, a tree that keeps its shape, deeper facets
Some checks are pending
build-gate / build (push) Waiting to run
Checkpointing thirteen files of agent work that were sitting uncommitted while three
agents wrote to a live tree the Driver was watching through hot reload. Uncommitted work
is work at risk, and a tree being edited under an observer oscillates between states that
look like regressions and are really just partial saves.

The second capability-5 shell now mounts its tabs as real elements too, so no iframe
remains in either shell. It was the last holdout: eight tabs, all iframe mode, each
pointing at a hardcoded built-tree leaf that does not exist under the dev server.

The facet rail keeps its shape when a filter narrows it. Selecting one place took the
rail from sixty-six rows to twenty-one — forty-six rows deleted, none dimmed — so the row
a user was about to click vanished under the cursor, which is what "the buttons went
dead" actually was. Rows whose count falls to zero are now re-admitted at their original
slot and depth, dimmed and inert, which also restores the component's own promise to lose
nothing against the tree it replaced.

Expanding a branch and filtering by it are now separate, visible affordances: the whole
row filters, and expand is a real button with its own hit area and label. Rows that were
never clickable are unmistakably inert rather than silently ignored.

Three more facet axes reach the grid — affinity bloc, engagement status and primary
language — each verified to share a field with an existing prop rather than being mapped
on name resemblance. Two derived facets remain unmapped on purpose: they have no matching
row field, and mapping them would filter confidently to the wrong rows.
2026-09-22 14:51:52 +00:00
doxa-maps-88655c70
9424b86d99 Hand the tree base to nested shells so they stop guessing it
Some checks are pending
build-gate / build (push) Waiting to run
A capability-5 shell mounted inside the studio carries its own tab list and resolves
it against its own tree base, which it derives from its script URL when nobody supplies
one. Nested here that guess was empty, so the resolver took its parent-directory
fallback and requested paths above the leaf at the server root — three bundles 404'd
that way. The requests were non-fatal, because each tab still rendered from its
fallback list, which is exactly why they survived: a silent 404 behind a working
screen. The studio already knows the correct base, since it is the one it resolved
against, so it now passes it down through the existing profile-config path rather than
letting the child re-derive it.
2026-09-22 14:13:49 +00:00
doxa-maps-88655c70
50937cf766 Plugin studio embeds real elements, and a rooted URL is no longer double-prefixed
Some checks are pending
build-gate / build (push) Waiting to run
EVERY TAB FAILED TO LOAD. Introducing tree-base for the dev server prefixed it onto
URLs that were already rooted, producing /__plugin-gallery//plugin-designs/<b>/index.js
and a 404 on all 29 tabs. Both capability-5 shells had byte-identical resolve()
implementations, so both carried the same defect; both now return an already-absolute
ref untouched and join through exactly one slash. The built tree hands relative refs
and keeps its base, so this is one rule for both trees rather than a dev-only branch.

THE STUDIO NO LONGER FRAMES ANYTHING. Map elements were nested in iframes pointing at
their standalone leaf pages, to dodge a one-shot customElements.define collision. That
collision cannot occur here: 19 bundles claim 19 distinct tags, none shared, and two map
elements were proven to coexist in one document with the first unaffected by the second.
Every pane now mounts the plugin's real custom element, which is what makes a row both
reviewable and nestable.

Panes keep two lifetimes, and the difference is the WebGL context budget rather than
taste. Light rows stay mounted so switching back is instant and state survives. Map rows
are destroyed on deactivate, because a hidden map holds its GPU context: the browser
caps live contexts, then silently reclaims the OLDEST, so a map visited ten tabs ago
would go blank with no error anywhere. getContext never returns null and contextlost
never fires on the new canvas, which is what makes that failure so hard to attribute.

The dev server serves a leaf route for one element with no chrome, and passes the tree
base to nested shells so they discover the live tree instead of a baked fallback.
2026-09-22 14:02:20 +00:00
doxa-maps-88655c70
012c79328a Free the filter menu from its clipping ancestor; square the people-group images
FILTER DROPDOWNS WERE INVISIBLE. Opening a filter rendered its options underneath
the surrounding content, making the dashboard's primary control unusable.

The cause was CLIPPING, not stacking order, and that distinction is why an earlier
attempt failed. The filter band is a one-row horizontal scroller authored as
overflow-x:auto with overflow-y:visible — but CSS Overflow 3 coerces a visible axis
to auto when the other axis is not visible, so it computes to auto/auto and clips
vertically. Clipping happens during painting, before stacking order is consulted, so
raising the band to z-index 55 changed nothing: it won a stacking fight that was never
the problem. You cannot re-order pixels that were never generated.

The menu is now position:fixed, anchored to the trigger's viewport rect, which takes it
out of the clip chain entirely. Fixed coordinates go stale whenever anything scrolls or
resizes, so they are recomputed on open and while open, listening in the capture phase
so the dashboard's inner scroll regions are seen too.

PEOPLE-GROUP IMAGES WERE LETTERBOXED. A fixed 130px height against a 220px column
cropped each person to a band. The image is now a square via aspect-ratio, and the
column floor rises to 240px, roughly doubling the visible area. object-fit:cover is
kept deliberately — stretching to a square would distort faces, which is worse than
the letterbox it replaces.
2026-09-22 13:31:11 +00:00
doxa-maps-88655c70
44dd2d01f3 Make dashboard selections actually filter the grid, and fit the camera to the data
Some checks are pending
build-gate / build (push) Waiting to run
Four fixes to the research dashboard and the cap-4 composite, all Driver-reported.

GRID SHOWED ZERO ON EVERY SELECTION. Picking a region showed "1,309" in the chart
above an empty grid below. The two halves spell the same value differently: the
dashboard emits the human LABEL ("Asia", "North America & non-Spanish Caribbean")
while the API stores the machine VALUE ("asia", "north_america_&_non-spanish_caribbean"),
and the comparison was a strict ===, so it never matched. Measured over the 2,107
live records. Adds a shared valueMatches() normaliser used by BOTH applyDropdownFilters
and applyWith, so facet counts and shown rows cannot disagree. Compares value OR label,
case- and separator-insensitively, chosen over a label-to-value lookup table that would
need maintaining per axis and would rot when the API adds a region.

The grid's own dropdowns were never affected, because they build their options from the
data itself; only the host-prop path could inject a label into the comparison. That is
why the standalone grid is fine and the gate passes 13/13 on the same records.

COUNTRY NOW REACHES THE GRID. Clicking India in Top Countries did nothing because
country_code had no matching prop and was deliberately dropped rather than guessed at.
Adds a real countryCode prop and maps it. Country is prop-only, not a fifth dropdown.

LONG FACET LABELS HID THE COUNTS. white-space:nowrap let a long label eat the row, and
the rail scrolls vertically only, so there was no way to recover the number. Labels now
wrap; the bar yields; the count never shrinks.

CAMERA FLEW TO THE NORTH POLE on globally dispersed selections. Fits now trim outliers,
reject invalid and null-island coordinates, and unwrap longitudes across the antimeridian,
so the view lands on the data instead of a pole.
2026-09-22 12:50:37 +00:00
doxa-maps
d085a541ca Let a host reach the dashboard's own settings, so the provider is really swappable
Some checks are pending
build-gate / build (push) Waiting to run
The provider option landed last change but nothing a host passed could reach it:
the dashboard's config is a static import, so the setting existed in source and
was unreachable by an embedder. Real and unusable is the same trap as a nav
control that exists off-canvas. Hot-swappable has to mean a partner can choose
without a rebuild.

profileConfig is the channel every other plugin already uses for this, so this
adds no new mechanism — it stops the dashboard being the one plugin that ignores
it. The merge is shallow and the host wins. Shallow is deliberate: the static
config's nested objects are a coherent set that a partial override would corrupt,
and a deep merge would invite half-replaced facet maps that fail far from the
edit. What a host legitimately sets is top-level scalars.

With no host config the behaviour is byte-for-byte what it was, verified against
the baseline captured before any of this work: same node count, same canvas, same
row count, same engine, same ten tile responses.
2026-09-22 11:53:26 +00:00
doxa-maps
6a239201c0 Let the research dashboard choose its map provider, Mapbox still the default
Some checks are pending
build-gate / build (push) Waiting to run
The Driver wants MapLibre available without breaking Mapbox: keep every Mapbox
component working, add the option beside it, start with this dashboard.

The change is deliberately additive. provider defaults to mapbox, so with no
config the behaviour is what it was and nothing Mapbox-shaped can regress. A host
opts in through dashboardConfig, which is how every other dashboard setting
already arrives, so the cap-4 composites can pass it down with no new mechanism.

The plumbing already existed and was simply unreachable from here: useMapInstance
takes a provider and loads the matching engine, and the style registry maps every
named style to both engines with the MapLibre side pointing at keyless URLs. This
call passed neither provider nor style, so it silently took the default.

The token stays wired when MapLibre is selected. MapLibre ignores it, and keeping
it means switching back is one word with nothing else to restore, which is what
hot-swappable has to mean to be worth anything.
2026-09-22 11:39:10 +00:00
doxa-maps
236fef5682 Give nested ProfileLoader children the profile-config they require
Some checks are pending
build-gate / build (push) Waiting to run
Two composites nested a child by tag and URL without a profile-config. Those
children do not render a component directly — their bundle entry mounts
ProfileLoader, which reads profile-config.profile to choose which view to load.
With none, the element still mounts and still reports its tag, so every
structural check said both children were present, while the loader rendered
"missing or invalid profile-config prop" where the map should be. The grid half
of each page worked, which is how it survived.

Found by reading a regenerated README screenshot, not by any gate. The mobile
screenshot showed an error card and an empty pane; the fixed one shows the real
research map with its data, controls and progress figures.

research-map-plus-grid was mine, shipped that way. stack-demo predates it and had
the same defect against research-dashboard. Both now pass the shape their own
generated leaf pages use, so nested and standalone mounts ask for the same thing.

check:composite-srcs now requires a profile-config on any child whose bundle
mounts ProfileLoader, derived from which entries import it rather than a list.
The first version of that check tested for the word and passed on its own
explanatory comment; it now matches the quoted attribute key, and a negative test
that mutates only the key fails as it should.

Also regenerates three derived artifacts that had drifted behind this flight's
changes: the library index, the capability exemplars table, and all twelve README
screenshots.
2026-09-22 09:37:35 +00:00