diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md
index 83a70d43f..fdeb02027 100644
--- a/documentation/reference-library-index.md
+++ b/documentation/reference-library-index.md
@@ -121,6 +121,8 @@ and a change here can break other maps.
`import … from '@map/composables/useCsvExport.js'`
- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
`import … from '@map/composables/useDashboardLayout.js'`
+- **useDashboardSearch** — useDashboardSearch.js — the ONE free-text search the whole dashboard shares.
+ `import … from '@map/composables/useDashboardSearch.js'`
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
`import … from '@map/composables/useDocumentFont.js'`
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
diff --git a/internal/check-approx-pin-note.mjs b/internal/check-approx-pin-note.mjs
index 88f0eedee..12c0e3443 100644
--- a/internal/check-approx-pin-note.mjs
+++ b/internal/check-approx-pin-note.mjs
@@ -37,6 +37,14 @@ check('a rendered (tile-rounded) feature still finds its co-located groups', col
const twin = buildColocationIndex([...rows, { name: 'Koraga', latitude: 40, longitude: 40 }, { name: 'Other', latitude: 40, longitude: 40 }])
const renderedFar = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [40.0007, 39.9994] } }
check('a repeated name resolves to the nearest roster point', colocatedWith(twin, renderedFar).join() === 'Other', colocatedWith(twin, renderedFar))
+// The rows a map DRAWS are normalized people groups (name + latitude/longitude, no geometry), and every
+// map built on useMapData must offer them to its legends: the prayer map did not, so its legends read the
+// raw API cache and neither the shared-point sentence nor the standing note appeared (rega, row 46 at 835ec1ac).
+const drawn = [{ name: 'A', latitude: 12.85, longitude: 75.25 }, { name: 'B', latitude: '12.85', longitude: '75.25' }]
+const clickedA = { type: 'Feature', properties: { name: 'A' }, geometry: { type: 'Point', coordinates: [75.2504, 12.8497] } }
+check('normalized rows (no geometry) index and match a clicked pin', colocatedWith(buildColocationIndex(drawn), clickedA).join() === 'B', colocatedWith(buildColocationIndex(drawn), clickedA))
+const umd = readFileSync(join(ROOT, 'library/composables/useMapData.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+check('useMapData provides the rows it draws to the legends', /provide\(\s*'normalizedPeopleGroups'\s*,\s*normalizedPeopleGroups\s*\)/.test(umd), 'no provide in useMapData')
const { createServer } = await import('vite')
const { default: vue } = await import('@vitejs/plugin-vue')
diff --git a/internal/check-optional-t-equivalence.mjs b/internal/check-optional-t-equivalence.mjs
index d70b9a16c..32a95640e 100644
--- a/internal/check-optional-t-equivalence.mjs
+++ b/internal/check-optional-t-equivalence.mjs
@@ -140,8 +140,11 @@ const SITES = [
['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null],
// wave 3d — all-people-groups. Treated as UNOWNED (studio agreed), so the conservative reading wins:
// its inline EN map stays the fallback rather than switching to the catalogue, because nobody is
- // available to decide that this bundle should start honouring the locale on a bare host. An unowned
- // file is the wrong place to make a product decision by default.
+ // available to decide that this bundle should start honouring the locale on a bare host -- THEN
+ // dx-shepherd RULED the locale-honouring fallback everywhere (2026-09-27; the Driver's goal (d), a
+ // bare-host embed with ?lang=ar must render Arabic, now the embed contract in COMMON.md). So it takes
+ // the catalogue default, its duplicate EN map is deleted, and section 9 keeps the ruling's PRECONDITION
+ // true rather than measured once.
// First site on the {t, te, locale} form: it needs locale for toLocaleString.
// ⚠ null, NOT a fallback. This row came back as 'fallback: (full) =>' in a merge, because the branch it
// merged from predates 0ded1a4e — the commit that carried out the embed-contract ruling here and deleted
@@ -204,30 +207,20 @@ for (const [file, helper, fallbackFrag] of SITES) {
// catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their
// owner's decision. Saying "as it always did" for those four would be a false statement inside a
// gate message, which is the one place a reader has no reason to doubt.
- // ⚠ AND my-upg-100-list's TWO ARE ALSO "moved", added 2026-09-27. They used humanizeKey until
- // dx-shepherd took the product call (UPGHK) to delete it, so "as it always did" would have been
- // false for them the moment they were registered — the exact failure this branch already guards
- // against for gospel's four. A register that is right about the assertion and wrong in the sentence
- // it prints is still teaching the next reader something untrue.
- // ★ UNIONED ON MERGE, 2026-09-27. upg100's version listed only their two and gospel's; it omitted
- // cities-internet (exp-shepherd took the same call for that bundle) and all-people-groups (moved by
- // the ruling itself). Taking it verbatim would have printed "as it always did" about two files that
- // were moved — the very falsehood this branch exists to prevent, reintroduced by a merge.
- const MOVED_TO_CATALOGUE = ['gospel-resources', 'my-upg-100-list', 'cities-internet', 'all-people-groups']
- const moved = MOVED_TO_CATALOGUE.some((m) => file.includes(m))
- // ★ FOUR OWNERS, NOT TWO. The version merged in had a 2-branch chain, so every non-gospel file was
- // attributed to upg100-plugin — including cities-internet (exp-shepherd's call) and all-people-groups
- // (moved by the ruling, not by an owner at all). Naming the wrong agent in a gate message is the same
- // class as "as it always did": a reader has no reason to doubt it.
- const who = file.includes('gospel-resources')
- ? "gospel-plugin moved it here from enLookup's English"
- : file.includes('my-upg-100-list')
- ? 'upg100-plugin moved it here from humanizeKey, a title-cased key segment'
- : file.includes('cities-internet')
- ? "exp-shepherd moved it here from enLookup's English, for the same reason gospel did"
- : "dx-shepherd's embed-contract ruling moved it here from an inline EN map"
- ok(!/fallback:/.test(code), moved
- ? `${short}: takes the locale-honouring CATALOGUE fallback (${who})`
+ // Which sites were MOVED to the catalogue by a decision, vs which always resolved there. Saying "as it
+ // always did" about a moved file is a false statement inside a gate message, the one place a reader has
+ // no reason to doubt. gospel-resources and cities-internet moved by their owners' decisions,
+ // my-upg-100-list by dx-shepherd's product call (UPGHK) deleting humanizeKey, and all-people-groups by
+ // the embed-contract ruling (locale-honouring everywhere, 2026-09-27).
+ const MOVED_BY = {
+ 'gospel-resources': "gospel-plugin moved it here from enLookup's English",
+ 'cities-internet': "exp-shepherd moved it here from enLookup's English",
+ 'my-upg-100-list': 'upg100-plugin moved it here from humanizeKey, a title-cased key segment',
+ 'all-people-groups': "dx-shepherd's embed-contract ruling moved it here from its inline English map",
+ }
+ const mover = Object.keys(MOVED_BY).find((d) => file.includes(d))
+ ok(!/fallback:/.test(code), mover
+ ? `${short}: takes the locale-honouring CATALOGUE fallback (${MOVED_BY[mover]})`
: `${short}: takes the CATALOGUE fallback, as it always did`)
}
}
diff --git a/internal/check-universal-props.mjs b/internal/check-universal-props.mjs
index 03d95c0f9..8e1737f63 100644
--- a/internal/check-universal-props.mjs
+++ b/internal/check-universal-props.mjs
@@ -54,7 +54,7 @@ const BUNDLES = [
'dashboard-plus-grid', 'research-dashboard', 'research-map-plus-grid', 'doxa-research-map', 'people-groups-grid',
'regions-dashboard', 'my-upg-100-list', 'doxa-simple-map', 'cities-internet', 'gospel-resources',
'all-people-groups', 'country-posters', 'doxa-vision-card', 'pplr-data-maps', 'prayer-walk-planner',
- 'upg-neighbor', 'wall-of-people-groups', 'key-cities',
+ 'upg-neighbor', 'wall-of-people-groups', 'key-cities', 'gospel-internet',
]
const ALLOW = {
'prayer-walk-planner:ENG-PIN': 'keyless by design: always MapLibre, needs no token (index.js header)',
diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue
index 8ab353806..38ff2aa91 100644
--- a/library/components/legends/LegendMobile.vue
+++ b/library/components/legends/LegendMobile.vue
@@ -526,7 +526,9 @@ const showPullTab = computed(() => uiStore.showPullTab)
const legendMode = computed(() => uiStore.legendMode)
const selectedPeopleGroup = computed(() => uiStore.selectedPeopleGroup)
// APPROXPIN: the other groups on the selected pin's coordinate, from the roster THIS map draws.
+const drawnPeopleGroups = inject('normalizedPeopleGroups', null)
const colocation = computed(() => {
+ if (drawnPeopleGroups?.value?.length) return buildColocationIndex(drawnPeopleGroups.value)
const rows = []
for (const id in (dataStore?.sources || {})) { const src = dataStore.sources[id]; if (src?.features?.length) rows.push(...src.features) }
return buildColocationIndex(rows)
diff --git a/library/components/legends/SemanticTreeLegend.vue b/library/components/legends/SemanticTreeLegend.vue
index bcc089ab2..174e2d0d1 100644
--- a/library/components/legends/SemanticTreeLegend.vue
+++ b/library/components/legends/SemanticTreeLegend.vue
@@ -24,6 +24,7 @@ import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useInstance } from '../../composables/usePplrInstance.js'
import LegendProgressBar from './LegendProgressBar.vue'
import ApproxPinNote from './ApproxPinNote.vue'
+import { pointOf, nameOf } from '../../utils/colocation.js'
// .stl-export-backdrop is an invisible click-catcher ("captures the next click"), so when it is
// clipped nothing LOOKS wrong — but inside the cap-5 shell contain:layout on .vtl-content limits
// it to the content pane (measured 1024x793 @ (256,107) at 1280x900), so a click on the rail or
@@ -126,14 +127,15 @@ const progressData = computed(() => props.progress ?? unref(injectedProgress) ??
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
const stlIsDark = computed(() => (instance?.theme.value || 'dark') === 'dark')
const pinDataStore = inject('dataStore', null)
+const drawnPeopleGroups = inject('normalizedPeopleGroups', null)
+// A people-group pin is a row (or GeoJSON feature) with a name AND a position. The rows the map draws are
+// asked first; the dataStore cache is the fallback (it can hold raw API rows or GeoJSON features).
+const isPin = (r) => !!(r && nameOf(r) && pointOf(r))
const showPinNote = computed(() => {
if (props.pinNote !== null) return props.pinNote
+ if (drawnPeopleGroups?.value?.length) return drawnPeopleGroups.value.slice(0, 20).some(isPin)
const sources = pinDataStore?.sources || {}
- for (const id in sources) {
- const f = sources[id]?.features?.[0]
- const p = f?.properties || {}
- if (f?.geometry?.type === 'Point' && (p.name || p.peopleName || p.PeopNameInCountry)) return true
- }
+ for (const id in sources) if ((sources[id]?.features || []).slice(0, 20).some(isPin)) return true
return false
})
// A click on a bar span selects the matching legend row through the SAME handler
diff --git a/library/composables/useDashboardLayout.js b/library/composables/useDashboardLayout.js
index 5cf207bc4..08d3544f3 100644
--- a/library/composables/useDashboardLayout.js
+++ b/library/composables/useDashboardLayout.js
@@ -90,6 +90,10 @@ import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
// existing layout survives the change; nothing writes to it. Delete it once no reader can plausibly
// still be carrying one.
const LEGACY_SHARED_KEY = 'rd-dashboard-layout'
+// The default key, named once so the legacy read-through can ask "am I the default?" instead of
+// re-typing the literal — a fallback comparing against a copy of its own default is a fallback that
+// silently stops matching the day someone bumps the version suffix.
+const DEFAULT_STORAGE_KEY = 'rd-dashboard-layout-v6'
// Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue.
// These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them.
@@ -170,7 +174,7 @@ export function useDashboardLayout(options = {}) {
// in the commit that first trips over it.
// The caller passes its own id (the shell uses config.id). The default keeps a caller that
// passes nothing working, and still namespaces it away from any caller that does.
- storageKey = 'rd-dashboard-layout-v6',
+ storageKey = DEFAULT_STORAGE_KEY,
} = options
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }
@@ -271,7 +275,14 @@ export function useDashboardLayout(options = {}) {
// every reader's saved layout. Read-only and never written back: the new key is written on
// the next drag-end, and the old value is simply left where it is rather than migrated and
// deleted — a migration that also deletes is one that cannot be rolled back.
- const raw = localStorage.getItem(storageKey) || localStorage.getItem(LEGACY_SHARED_KEY)
+ // ★ ONLY FOR THE DEFAULT KEY (go-int, 2026-09-27) — and this was my own regression from kit
+ // landing 2. I added the read-through so namespacing would not discard a reader's saved
+ // layout, and applied it unconditionally: so a caller that passes its OWN storageKey, which
+ // is the entire point of namespacing, still inherited Big Picture's rail widths on first
+ // load. The migration meant to protect one dashboard's layout was handing it to every other
+ // dashboard. A fallback that ignores the key it is falling back FROM is not a fallback.
+ const legacyOk = storageKey === DEFAULT_STORAGE_KEY
+ const raw = localStorage.getItem(storageKey) || (legacyOk ? localStorage.getItem(LEGACY_SHARED_KEY) : null)
if (!raw) return false
const s = JSON.parse(raw)
if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW)
@@ -333,6 +344,18 @@ export function useDashboardLayout(options = {}) {
const startX = e.clientX
const startFacet = facetW.value
const startRail = railW.value
+ // ★ THE X DELTA HAS A READING DIRECTION (go-int, 2026-09-27). Under dir=rtl the grid mirrors —
+ // the facet rail sits on the RIGHT — so dragging its handle toward the map INCREASES clientX
+ // where in LTR it decreases it. The maths below was written once, in LTR, and the result in
+ // Arabic is that dragging the facet handle shrinks the CHARTS rail: both handles fight, and
+ // neither does what the cursor says. This reaches Big Picture in ar today, not only go-int.
+ // Read per drag rather than cached: a host can flip dir between gestures (our own composites
+ // set it on the custom-element host from the runtime locale), and a cached sign would be wrong
+ // for the rest of the session.
+ const rtl = (() => {
+ try { return getComputedStyle(unref(shellRef)).direction === 'rtl' } catch (_) { return false }
+ })()
+ const xSign = rtl ? -1 : 1
const handleEl = e.currentTarget
// ── PAGE-SCROLL AXIS SWITCH (BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING) ──────────────────────
// In page-scroll mode (research-dashboard.vue's pageScroll / dashboard-plus-grid.vue) the
@@ -405,11 +428,11 @@ export function useDashboardLayout(options = {}) {
pending = null
if (axis === 'facet') {
// left facet edge → dragging right widens the facet column
- facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
+ facetW.value = Math.round(clamp(startFacet + xSign * (ev.clientX - startX), clamps.facetW))
customized.value = true
} else if (axis === 'rail') {
// right rail's LEFT edge → dragging left widens the rail column
- railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW))
+ railW.value = Math.round(clamp(startRail - xSign * (ev.clientX - startX), clamps.railW))
customized.value = true
} else if (isTablePageScroll) {
// page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is
@@ -519,7 +542,16 @@ export function useDashboardLayout(options = {}) {
const K = e.key
let dir = 0
if (axis === 'table' || axis === 'shell') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 }
- else { if (K === 'ArrowLeft') dir = -1; else if (K === 'ArrowRight') dir = 1 }
+ else {
+ // ★ AND THE ARROWS FOLLOW THE SAME SIGN (go-int). The pointer path now mirrors under rtl; if
+ // the keyboard did not, the two input methods would disagree about which way widens — and a
+ // keyboard user in Arabic would find the arrows inverted relative to the drag they just
+ // watched. aria-valuenow reports the same ref for both, so a disagreement here would also
+ // make the announced value move the wrong way.
+ let kSign = 1
+ try { if (getComputedStyle(unref(shellRef)).direction === 'rtl') kSign = -1 } catch (_) { /* no element */ }
+ if (K === 'ArrowLeft') dir = -1 * kSign; else if (K === 'ArrowRight') dir = 1 * kSign
+ }
if (dir) { e.preventDefault(); stepResize(axis, dir); return }
if (K === 'Home' || K === 'End') {
e.preventDefault()
diff --git a/library/composables/useDashboardSearch.js b/library/composables/useDashboardSearch.js
new file mode 100644
index 000000000..35095b62c
--- /dev/null
+++ b/library/composables/useDashboardSearch.js
@@ -0,0 +1,100 @@
+/**
+ * useDashboardSearch.js — the ONE free-text search the whole dashboard shares.
+ *
+ * ★ EXTRACTED FROM useDashboardShell (dashboard-kit landing 4a) BECAUSE EIGHT THINGS IMPORT IT.
+ * These three functions were exported from the shell, and every panel reached into the shell to get
+ * them: the table, the map panel, the charts rail, the facet rail, the research filters, the top bar,
+ * the profile and the config. That is not a shell concern; it is a module in its own right that
+ * happened to live inside one.
+ *
+ * It also unblocks the facet rail. Landing 4 was supposed to move the rail, and the rail imports
+ * these helpers — so moving the rail first would have meant a library component importing a plugin
+ * composable, which check:no-cross-plugin-imports rightly refuses. The dependency had to come first;
+ * SPEC-DASHBOARD-KIT section 8 did not notice that, and now says so.
+ *
+ * Nothing here is domain-specific: the accessors come from `config.facets`, `config.columns` and
+ * `config.searchKeys`, and the matching comes from the shared @map/components/search/fuzzy.js. A
+ * dashboard with different facets gets its own search for free.
+ *
+ * Bodies are verbatim from the shell — this is a move, not a rewrite.
+ */
+import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
+
+// tiny lodash-free mapValues: { k: v } -> { k: fn(v, k) }
+const mapValues = (obj, fn) =>
+ Object.fromEntries(Object.entries(obj || {}).map(([k, v]) => [k, fn(v, k)]))
+
+/**
+ * Build the free-text search accessors for a dashboard config (header-1 search bar).
+ * config.searchKeys (facet keys) → those facet accessors; else the string columns' accessors.
+ * ONE declaration reused by the shell (KPIs) AND every panel (table/map/charts/facets) so the
+ * searchable text can never drift from what the panels show, and the substring match is not
+ * copy-pasted into each panel (kills the very duplication DOXA-LIB-2 removed).
+ */
+export function buildSearchAccessors(config) {
+ const FACET_ACC = mapValues(config.facets, (f) => f.get)
+ const COL_ACC = Object.fromEntries((config.columns || []).map((c) => [c.key, c.get]))
+ const keys = Array.isArray(config.searchKeys) ? config.searchKeys : null
+ if (keys) return keys.map((k) => FACET_ACC[k] || COL_ACC[k]).filter((fn) => typeof fn === 'function')
+ return (config.columns || []).filter((c) => c.type !== 'num' && typeof c.get === 'function').map((c) => c.get)
+}
+
+/**
+ * Reusable predicate: does `row` match the bus's current free-text search term?
+ * Returns true when there is no term (identical to pre-search behaviour). `accessors` is the
+ * array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
+ */
+export function makeSearchMatcher(bus, accessors) {
+ // ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must
+ // still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with
+ // { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
+ // search tolerates typos. This matcher was a plain .includes() -- the one search surface that
+ // silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
+ // It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the
+ // grid's Fuse uses -- with the same DX-life options, so every search surface agrees.
+ // Row-level predicates for the TIERED contract (see fuzzy.js exactMatches): the caller
+ // (filterRowsBySearch below) runs the exact tier over the whole row set first and falls back
+ // to the fuzzy tier only when exact yields nothing, so an exact term agrees with the facet.
+ const test = (row, pred) => {
+ const needle = (bus?.searchTerm?.value || '').trim()
+ if (!needle) return true
+ for (const acc of accessors) {
+ const v = acc(row)
+ if (v != null && pred(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true
+ }
+ return false
+ }
+ const matcher = (row) => test(row, fuzzyMatches)
+ matcher.exact = (row) => test(row, exactMatches)
+ matcher.active = () => !!(bus?.searchTerm?.value || '').trim()
+ matcher.term = () => (bus?.searchTerm?.value || '').trim()
+ matcher.accessors = accessors
+ return matcher
+}
+
+/**
+ * Apply the TIERED search to a row set:
+ * 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects.
+ * 2. facet-VALUE resolution (Driver: "Austrian or Canadian... a selection of that country --
+ * the table under the map shows the result for that country"): the term is resolved by
+ * whole-value edit distance against each accessor's DISTINCT values, and the rows returned
+ * are those carrying a resolved value -- byte-identical to picking it as a filter.
+ * 3. the generic windowed fuzzy rescue, only when nothing resolves.
+ */
+export function filterRowsBySearch(rows, matcher) {
+ if (!matcher.active()) return rows
+ const exact = rows.filter(matcher.exact)
+ if (exact.length) return exact
+ const term = matcher.term()
+ // ONE pool across every accessor's distinct values, so the GLOBAL best match wins (see
+ // resolveFuzzyValues: ties at the minimal distance stay, everything farther drops --
+ // 'Canadian' resolves to Canada, not to 47 rows of <=3-edit neighbours across facets).
+ const distinct = new Set()
+ for (const acc of matcher.accessors) for (const r of rows) { const v = acc(r); if (v != null && v !== '') distinct.add(String(v)) }
+ const winners = new Set(resolveFuzzyValues(term, [...distinct], { threshold: 0.4 }))
+ if (winners.size) {
+ const resolved = rows.filter((r) => matcher.accessors.some((acc) => { const v = acc(r); return v != null && winners.has(String(v)) }))
+ if (resolved.length) return resolved
+ }
+ return rows.filter(matcher)
+}
diff --git a/library/composables/useMapData.js b/library/composables/useMapData.js
index 22866a635..928e79a19 100644
--- a/library/composables/useMapData.js
+++ b/library/composables/useMapData.js
@@ -3,7 +3,7 @@
// Vue 3 Composition API - ES Module with NPM packages
// Import Vue functions from NPM package
-import { ref, computed } from 'vue';
+import { ref, computed, provide, getCurrentInstance } from 'vue';
import { applyLocalPrayer } from './usePrayedLocally.js';
// Static lookup: language label (e.g. "Telugu") → language family ("Dravidian").
// Used to derive `languageFamily` for pray-tools rows where the upstream returns
@@ -136,6 +136,12 @@ export function useMapData(options) {
// Data arrays (will be marked as raw for performance)
const peopleGroups = ref([]);
const normalizedPeopleGroups = ref([]);
+ // THE ROWS THE MAP DRAWS, offered to the legends below it. research-map and countries-map provided
+ // this themselves; the prayer map did not, so its legends fell back to the raw API cache, whose field
+ // names the pin-note co-location index cannot read, and the shared-point sentence and the standing
+ // pin note never showed there (rega, row 46). Providing it here covers every map built on useMapData;
+ // a component that also provides it passes this same ref.
+ if (getCurrentInstance()) provide('normalizedPeopleGroups', normalizedPeopleGroups);
const regionsData = ref(null);
const languageFamiliesData = ref([]);
const doxaRegionsData = ref([]);
diff --git a/library/composables/useSelectionBus.js b/library/composables/useSelectionBus.js
index 49f0e9a82..f885d86e7 100644
--- a/library/composables/useSelectionBus.js
+++ b/library/composables/useSelectionBus.js
@@ -19,6 +19,9 @@
* NARROWS scope — panels render excluded context by FADING, never removing.
* - selectionSet : explicit set of slugs (table checkbox / map lasso / chart pick).
* MARKS individuals — panels render with RING/OUTLINE.
+ * - hoverKey : the ONE record the pointer is over, or null (D3).
+ * POINTS at one — panels may fade or outline; they may not fly, select or
+ * filter. Hover is not a commitment, so nothing irreversible may follow it.
*
* Export priority (later phases): selectionSet (if non-empty) > filterSet result > full dataset.
* Every panel is both publisher and subscriber. All client-side over one in-memory row store
@@ -99,6 +102,24 @@ export function createSelectionBus() {
// facet filterSet: it applies a substring/label match across the config's `searchKeys` in
// useDashboardShell.filteredRows. Empty string = no text predicate (identical to before).
const searchTerm = ref('')
+ // ── hoverKey (SPEC-DASHBOARD-KIT D3) ────────────────────────────────────────────────────────
+ // THE THIRD COORDINATED CHANNEL, and the only one this bus was missing. filterSet narrows scope
+ // and selectionSet marks individuals; HOVER points at one record without committing to it, so a
+ // reader can sweep a table and watch the map answer. kepler.gl treats hover as a configured
+ // interaction alongside filters for the same reason.
+ //
+ // ★ IT IS A PLAIN ref AND NOT A SET, AND THAT IS THE WHOLE DESIGN. Hover fires on every
+ // mousemove across a 2,107-row table. selectionSet had to be a plain Set with a version counter
+ // precisely because a reactive collection fires per element (see the performance contract
+ // above); a single ref has none of that problem — one dependency, one write, no iteration. Any
+ // future "hover several things" must be argued for against that, not assumed.
+ //
+ // ★ HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. It does not enter selectionSet, does not
+ // bump selectionVersion, and therefore does not move the camera: useSelectionCamera watches the
+ // version, and a camera that chased the pointer across a table would be the resize-strobe bug
+ // wearing a new hat, on a Driver who is photosensitive. A panel may fade or outline on hover; it
+ // may not fly, select or filter.
+ const hoverKey = ref(null)
const subs = new Map() // event -> Set ${bt(f.country||"Unknown")} • ${bt(f.status||"Unknown Status")}ee&&(ee=ie),Me
- Language: ${bt(f.language||"Unknown")} + Language: ${mt(f.language||"Unknown")}
Family: - - ${bt(f.languageFamily||"Unknown")} + + ${mt(f.languageFamily||"Unknown")}
- ${f.population?`Population: ${bt(f.population)}
`:""} - ${f.description?`${bt(f.description)}
`:""} + ${f.population?`Population: ${mt(f.population)}
`:""} + ${f.description?`${mt(f.description)}
`:""}- ${bt(r("popup.country.wagfRegion"))}: ${bt(H)} + ${mt(r("popup.country.wagfRegion"))}: ${mt(j)}
- ${bt(r("popup.country.wagfBlock"))}: ${bt(U)} + ${mt(r("popup.country.wagfBlock"))}: ${mt(H)}
- ${bt(r("popup.country.country"))}: ${bt(p)} (${bt(k)}) + ${mt(r("popup.country.country"))}: ${mt(p)} (${mt(k)})
- ${bt(r("popup.country.uupgCount"))}: ${bt(Y)} + ${mt(r("popup.country.uupgCount"))}: ${mt(Y)}