A reader who picks a religion family on the map now sees the grid narrow to the
same peoples, and a language selection travels the same way. Region already
linked; these two did not, because the two halves do not name a religion the
same way and nothing yet proved what the honest translation was.
The map filters religion through its legend's flat tab, keyed by the FIRST
LETTER of the religion code ('B'); the grid filters through a derived axis that
runs library/colors/religion.js forward per row ('BLA' -> 'Buddhism'). One
shared table, read in opposite directions, copied by neither half — so the wire
carries the family NAME and each side resolves it against its own data.
Two measurements changed the design on the way:
- mapStore.filters.religions, which the map's filter panel writes, is read by
NOTHING: the mapLayers.applyFilters() it was meant to reach is called and
never implemented anywhere in the tree. A link routed through it would have
set state, passed a smoke test and dimmed no pin.
- the map's legend selections are mutually exclusive by design, so it can
light exactly one of region/religion/language. The composite mirrors that
rather than letting the grid hold three facets the map cannot show.
Religion links at the FAMILY and not at the code, deliberately: a map cannot
distinguish 'BLA' from 'BMA' — both dim as Buddhism — so linking at the code
would promise a precision one half cannot keep.
check:linked-vocabulary proves it by measurement rather than by reading: it
compares the total reached over the composite's wire against the total the grid
gives for the same facet on its own (83 of 2,107 both ways), and runs the MUTANT
first — the legend key 'B' instead of 'Buddhism' — failing as NO-TEETH if the
wrong mapping scores the same as the right one. Measured: mutant 0, real 83.
The CLI doc's apk:debug quote was realigned in passing; package.json had gained
app:start-page and the doc still described the older chain.
The composite showed a map and a grid that ignored each other: filtering the grid
to Africa moved its own count 2,107 → 315 while the map beside it kept every pin.
The composite now owns ONE filter state; the children report theirs upward as DOM
events and accept ours downward as attributes, so each stays independently
embeddable — a child mounted alone hears nothing and is handed nothing.
MEASURED on a served built tree, region facet:
grid → map 2,107 → 315, and the map's own state follows (externalRegion and
selectedRegion both 'africa'); clearing returns both
map → grid the map's map-filtered contract → the grid's list 2,107 → 315;
clearing returns it
standalone grid alone 24 rows / Total 2,107 / 0 errors; map alone canvas 1 /
0 errors — unchanged
MUTATION disabling the composite's push leaves the grid filtering itself
(315) while the map stays empty, and kills the reverse direction:
the check fails when the link is broken
Four things this cost, each of which first looked like it worked:
1. StackLayout applies `attrs` ONCE at create — its documented contract. The
first version recomputed `sections`; the composite adopted the filter and the
map's attribute in the DOM never changed. A computed nobody re-reads is not a
channel, so the state is now PUSHED onto the mounted children.
2. The echo cancelled itself. Composite pushes → the grid re-filters → it emits
groups-filtered with activeFilters still EMPTY → the composite adopts that as
"the reader cleared it" and wipes what it just applied. Both handlers were
correct; the round trip undid itself in under a second. A push is not a user
action, so the push window ignores children.
3. The grid ignored host filters BY DESIGN when its own dropdowns are visible:
activeFilters picks local OR props, never both. Right for the desktop
composite, which hides them; silently wrong here. A host facet now seeds the
LOCAL state, so the map's selection appears as a chip in the grid's own bar
and the reader can clear it there.
4. That seeding called lbl(), which is local to another computed — "lbl is not
defined", watcher dead, attribute set, list unchanged. Visible only after the
probe was widened to console.error. A silent watcher failure looks exactly
like a feature that was never wired.
SCOPE, DELIBERATE: region only. The map filters religion and language by the
canonical strings its own data carries; the grid filters them by slugs from a
different table. Linking those means inventing a mapping, and a wrong link is
worse than an honest gap.
The map also gains a verification handle (window.__researchMapFilterState), the
same contract as __rdashMap and __grMap: a linked filter is a STATE claim, and
every DOM proxy for it was either tab-specific or a selector guess.
Moved out (kept outside the repo, in the team's internal tracker):
- AGENT-BRIEF.md: operating rules for the team's own agents
- documentation/slide-deck-outline.md, documentation/README-longform.md:
internal pitch material; the README and why.md cover the same ground
- plugin-designs/gospel-resources/SOURCES-CHECKED-AND-REJECTED.md: research log
- .claude/ (an editor status line), .forgejo/ (a CI workflow that never ran),
_archive/ (a retired staging page; already gitignored but still tracked)
Seven code comments that named these files now describe the rule instead.
Fixed five dead links left from the docs/ -> documentation/ move, including
the one in template-bundle that every new plugin inherits.
The delegation diagram now shows the whole model: the Plugin Studio with the
shared library and the merge queue into main, seven production plugins with
capability badges and the Experimental shelf, one agent per plugin (a roaming
agent for the shelf), the shepherd's oversight ring, a legend, and the five
ways a build ships.
On mount the map centred its active tab with
btn.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
and 'nearest' does not mean "do not scroll the page". It means "scroll every
scrollable ancestor by the least amount needed", and on a partner page the HOST
is an ancestor. Measured by the embed sweep: a page carrying this map below the
fold jumps to y≈640 the moment it mounts — on a phone, where Simple Research is
the default tab. The reader asked for nothing and the page moved under them.
The tab strip now scrolls ITSELF: compute the offset that centres the active
button inside its own box, clamp it to the strip's own scroll range, and write
scrollLeft. Same shape as SemanticTreeLegend's bringRowToTop, which scrolls the
container it found rather than asking the platform to walk ancestors. An element
that only ever writes its own scrollLeft cannot move anybody's page.
The exemption goes in the SAME commit, per that gate's contract — main never
carries a stale exemption or a red gate on someone else's bug. Both
NEIGHBOUR_KNOWN entries (research-map, research-map-plus-grid) are deleted here.
MEASURED after, with the exemption gone: check:gallery-embed PASS, and the
neighbour phase now reports "research-map leaves the host page alone" and
"research-map-plus-grid leaves the host page alone" on its own evidence — js 13
tabs, iframe 13 tabs, neighbour 13 tabs. The two KNOWN lines still printed belong
to regions-dashboard and are theirs to clear.
Driver: "two finger scrolling on the map should allow me to interact with
the map, but instead it just scrolls down past the map and does not
interact with the map -- that's what the scroll side bars are there for, to
let me interact with the map AND have a way to scroll down past the map."
REPRODUCED before touching anything: wheel over the centre of the map in
research-map-plus-grid moved scrollY 0 -> 480 and the camera never moved.
The map was inert exactly where it is the main control.
CAUSE: _syncScrollZoomToHost disabled scroll-zoom whenever the document or
an ancestor could scroll -- which is ALWAYS true in a composite that stacks
a grid under a map. It was not careless: it fixed
BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, where the map ate every wheel and the
grid below could not be reached. But that bug already had a second fix --
the .map-scroll-edge gutters, a dedicated page-scroll lane down both sides,
which the Map constructor's own note calls "the single-finger page-scroll
escape". Two fixes for one bug, and the second silently undid the feature
while leaving the first redundant.
So scroll-zoom now stays enabled and the strips carry the page. The
docScrolls/ancestorScrolls probe stays, because the edge affordance keys its
gray indicator off the same question.
MEASURED on the built tree, 1280x900:
wheel x4 over map centre scrollY 0 -> 0 map takes the gesture
wheel x4 over edge strip scrollY 0 -> 480 escape still works
(two strips, 46px wide each)
And the repo's own fitness gate, which exists for this property:
check:fits-screen 17/17 PASS -- including "the grid is reachable" and
"the wheel is targeted", so the bug this replaced has not returned.
DashboardMapPanel carries the same helper and was deliberately NOT changed:
different layout contract (map inside a fixed shell with facet/table/rail
columns), and fits-screen passes it today as "wheel escapes every column".
Flagged in the ledger for the Driver rather than altered on the strength of
a report about a different surface.
gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix.
Second plugin in the MapLibre rollout, copying doxa-research-map's pattern
rather than inventing a second one: hasGeocoder gates the search bar on
provider, MapToolbar collapses its own reserved gap, and the legend rises
into the vacated space.
The geocoder stays Mapbox-only by decision, not omission: its maplibre path
still calls makeMapboxForwardGeocode(accessToken), so the search REQUEST
goes to Mapbox whichever engine draws the tiles — a keyless map with a
search bar is not keyless and would bill the very key the option exists to
avoid.
★ RAISING THE LEGEND COLLIDED WITH THE FLY/CLUSTERS TOOLBAR, and only the
screenshot showed it. LegendTools is absolutely positioned at top:14px,
centred on the legend column — the SEARCH BAR's slot. With the bar gone and
the legend raised to 14, the toolbar printed straight across the legend's
"WAGF REGIONS" header. Every number was green while the panel was unusable.
A collision is worse than the gap this work exists to close.
So LegendTools now EXPORTS showsFlyTools(legendType), and hosts ask it
instead of re-typing its condition — two copies of one rule is how they
drift. A host with no search bar stops at 56px when the toolbar renders and
rises to 14px when it does not. doxa-research-map picked up the same fix.
MEASURED on the built tree, both engines:
country-posters mapbox search@12 legend@60 (unchanged)
country-posters maplibre search@NONE legend@56 (no gap, no overlap)
doxa-research-map maplibre search@NONE legend@58
doxa-simple-map needed NOTHING and was not touched: FEATURES.geocoder has
been false on both engines since 2026-05-04 and its legend already sits
flush at 10px. Recorded as a no-op so the next pass does not "fix" it.
22/22 bundles, 7/7 pre, 18/18 post, browser sweep of five StackLayout
consumers and all seven studio dashboards.
Simple Research showed a Fly + Clusters toolbar where the search bar used
to be, and the map offered no way to pick an engine without writing a
second map.
FLY + CLUSTERS ARE NOW LEAF-ONLY. LegendTools renders at top:14px centred
on the legend column — the search bar's exact slot — so on the Regions and
Languages tabs (the legendTypes its predicate allows) it reads as having
REPLACED the search bar. MEASURED on the leaf: fly toolbar at y=58 against
the search bar at y=56, the two on top of each other. In a stack whose
bottom half is a grid the reader is scanning, a fly-tour that moves the
camera underneath fights the other half, so the composite passes
legendTools:false. The standalone leaf keeps both.
THE ENGINE IS ONE PROP. resolveMapProvider already read profileConfig
.provider, so research-map.instances.json publishes the maplibre instance
and a partner running thousands of embeds changes one value instead of
maintaining a second map. Mapbox stays the default: it carries the richer
feature set. MapLibre is the keyless option for volume.
MAPLIBRE SHIPS NO SEARCH BAR, ON PURPOSE. GeocoderComponent's maplibre path
still calls makeMapboxForwardGeocode(accessToken) (:526) — the geocoding
REQUEST goes to Mapbox whichever engine draws the tiles. Shipping the bar
under maplibre would keep billing the exact key the option exists to avoid
while LOOKING free. Under mapbox it stays, unchanged.
AND THE LEGEND RISES INTO THE VACATED SPACE. SemanticTreeLegend's
--stl-top defaults to 60px that exists only to reserve room for a search
bar; leaving it when the bar is gone is a dead band. MapToolbar's
has-geocoder collapses its own reserved gap the same way.
MEASURED against the built tree:
leaf mapbox search@56 legend@104 fly@NONE (Prayer) / @58 (Regions)
leaf maplibre search@NONE legend@58 — legend rose 46px
composite search@NONE legend@104 fly@NONE on Regions
7/7 pre, 18/18 post.
KNOWN GAP, pre-existing and NOT introduced here: the mapbox composite still
shows no search bar. mapboxToken is injected per element and the composite
passes neither tk nor tokenUrl to its child, so the child's geocoder gets
an empty token and bails while the tiles render from another path. Isolated
but not fixed.
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.
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.
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.
More than half the catalog had an empty description in manifest.json, so the
plugin-studio — the surface a Doxa inheritor browses to learn what exists —
showed flagship plugins as a bare "bundle - <tag>" with no explanation, while
a demo bundle explained itself fully.
Cause: the generator reads a JSDoc @description TAG, and these files carried
only prose. Every description added here is a summary of what that file's own
header already documented, not new invention. Claims were checked against the
artifacts: doxa-simple-map's "three presets" was verified against
simple-map.instances.json (prayer, engagement, adoption).
The two cap-5 shells needed a second pass. Their headers are HTML comments
with no leading asterisk, and the real TAG_RE requires `* @tag value`, so the
first attempt extracted nothing.
Result: 26/26 profiles described, 0 empty; the built gallery went from 15
empty / 11 populated to 0 / 26. Verified in a browser, not just in the
manifest. Embed suite 40/40.
Driver 2026-09-21: 'a folder called deployment which will store the capacitor
folder, wordpress folder and tauri folder', and 'a folder called documentation
to store the contributing folder so docs are in one place'.
docs/ + contributing/ + CONTRIBUTING.md -> documentation/ (three doc homes into
one). capacitor/ + wordpress/ + src-tauri/ -> deployment/.
The public/ -> dist/ rename the Driver asked about is NOT here: after seeing the
measured blast radius (855 tracked files, 54 source refs, and public/ is the
served web-root four consumers point at) the Driver withdrew it - 'keep the
public folder the same'.
Path references were rewritten mechanically (258 across 87 files), but three
classes needed hand fixes because a prefix regex cannot see them:
- join(ROOT,'docs',...) in 10 internal scripts - a quoted SEGMENT, not a prefix
- tauri frontendDist '../public' -> '../../public', since src-tauri is now one
level deeper; check:tauri caught this exactly as the card predicted
- the WordPress contract test's dirname(__DIR__,2) -> 3, same reason
Also reverted one over-reach: the rewrite renamed a FINDING-CATEGORY label
'docs' in public-hygiene-check.mjs, which is a rule name in its own header, not
a path.
Verified: full build ALL GUARDS PASS, SRI consistent; check:tauri, check:mobile
(cap sync + webDir OK), check:wp-embed, quoted-scripts, capability-citations,
capability-exemplars, library-index, public-hygiene, parse and drop all pass.
A previous sweep found six dark matches, correctly ruled five of them unreachable, and
fixed the one real violation in doxa-simple-map. Re-running it with a luminance test
rather than a hex prefix found more, because the earlier pattern also matched the green
accent #10b981 and so buried the genuine surfaces in noise.
Three further unconditional violations, all the same shape as the one already fixed: the
loading overlays in all-people-groups, country-posters and doxa-research-map each render
on every load via v-if="!appReady", so a near-black flash was the first thing a visitor
saw. All four now use the palette surface with muted text.
The template-bundle starter was also dark in both profiles. That one matters more than a
leaf: the documented recipe is to copy template-bundle and rename it, so every future
plugin would have inherited a surface the house style forbids. The two profiles stay
visibly distinct — a tinted surface with an accent border, b white with a neutral one.
Verified by reading the shipped rule out of each built bundle: 4 of 4 found, all
#f3f3f1 at luminance 243. The found-count is reported so a run that matches nothing
cannot read as a pass — an earlier version of that check found zero rules and printed
ALL LIGHT.