diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md
index 83a70d43..fdeb0202 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 88f0eede..12c0e344 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-i18n-stopped-scope.mjs b/internal/check-i18n-stopped-scope.mjs
new file mode 100644
index 00000000..c5b394d6
--- /dev/null
+++ b/internal/check-i18n-stopped-scope.mjs
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+/**
+ * check-i18n-stopped-scope.mjs — createAppI18n() must succeed while a STOPPED effect scope is current.
+ *
+ * WHY (P0, the agent's console, 2026-09-27): in Plugin Studio's gallery every tab after the first threw
+ * "SyntaxError: Unexpected error" from createI18n inside createAppI18n. vue-i18n's createGlobal() runs in
+ * scope.run() and throws UNEXPECTED_ERROR when that returns undefined; Vue 3.5 creates a new EffectScope
+ * INACTIVE when the currently active one is stopped; and a custom element's connectedCallback ->
+ * configureApp -> createAppI18n can run while the released tab's stopped scope is still current.
+ *
+ * The CONTROL comes first: a bare createI18n() inside the same stopped scope must THROW, or this harness
+ * does not reproduce the failure and a green result would prove nothing. Then createAppI18n() inside it
+ * must succeed, translate, and keep reacting to a locale change.
+ */
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
+let bad = 0
+const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + got}`) }
+
+const { createServer } = await import('vite')
+const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
+ server: { middlewareMode: true, hmr: false, ws: false, watch: null }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
+try {
+ globalThis.__INTLIFY_JIT_COMPILATION__ = true
+ const { effectScope } = await import('vue')
+ const { createI18n } = await import('vue-i18n')
+ const mod = await server.ssrLoadModule('/library/i18n/index.js')
+ // Set AFTER vue loads (runtime-dom probes `document` at import). These are the two page facts
+ // createAppI18n reads, the URL and , as a plain English page would give them.
+ globalThis.window ??= { location: { search: '' } }
+ globalThis.document ??= { documentElement: { lang: 'en', dir: '' } }
+
+ // A scope that ran, was stopped, and is still the current one — the released tab's.
+ const stopped = effectScope()
+ stopped.run(() => {})
+ stopped.stop()
+ const inStopped = (fn) => { stopped.on(); try { return fn() } finally { stopped.off() } }
+
+ let controlThrew = false
+ try { inStopped(() => createI18n({ legacy: false, locale: 'en', messages: { en: { a: 'b' } } })) } catch { controlThrew = true }
+ check('CONTROL: a bare createI18n() inside a stopped scope throws (the harness reproduces the bug)', controlThrew, 'it did not throw')
+
+ let i18n = null, err = null
+ try { i18n = inStopped(() => mod.createAppI18n()) } catch (e) { err = e }
+ check('createAppI18n() succeeds inside a stopped scope', !!i18n && !err, err && String(err))
+ if (i18n) {
+ check('and it translates', i18n.global.t('buttons.close') !== 'buttons.close', i18n.global.t('buttons.close'))
+ i18n.global.locale.value = 'ar'
+ await new Promise((r) => setTimeout(r, 0))
+ check('and its locale watcher is live (the static reader follows a runtime switch)', mod.getRuntimeLocale() === 'ar', mod.getRuntimeLocale())
+ // The bundle entries that still provide appI18n themselves after app.use(i18n) must not make Vue warn.
+ const { createApp } = await import('vue')
+ const warns = []; const warn = console.warn; console.warn = (...a) => warns.push(a.join(' '))
+ try { const app = createApp({}); app.use(i18n); app.provide('appI18n', i18n) } finally { console.warn = warn }
+ check('a bundle providing appI18n again after app.use(i18n) raises no Vue warning', !warns.some((w) => /appI18n/.test(w)), warns.join(' | '))
+ }
+} finally {
+ await server.close()
+}
+
+if (bad) { console.error(`\nI18N-STOPPED-SCOPE: FAIL — ${bad} assertion(s)`); process.exit(1) }
+console.log('\nI18N-STOPPED-SCOPE: PASS — the app i18n is created live even while a stopped effect scope is current.')
diff --git a/internal/check-optional-t-equivalence.mjs b/internal/check-optional-t-equivalence.mjs
index 92a8d780..976e73e2 100644
--- a/internal/check-optional-t-equivalence.mjs
+++ b/internal/check-optional-t-equivalence.mjs
@@ -101,7 +101,7 @@ ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area En
// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the
// catalogue. The second matters more than it looks: routing these through tStatic would change what
// seven bare-host bundles render, and would start translating strings that are English today.
-import { readFileSync as rf } from 'node:fs'
+import { readFileSync as rf, readdirSync } from 'node:fs'
const SITES = [
// file, helper it must use, a fragment of the fallback it must still pass
['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'],
@@ -140,10 +140,13 @@ 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 agent'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.
- ['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', 'fallback: (full) =>'],
+ ['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', null],
// wave 3c — my-upg-100-list (owner: upg100-plugin). No namespace, since callers pass full keys.
// ⚠ THE THIRD COLUMN IS null, NOT 'humanizeKey(k)', and that is a DEPARTURE from 4c05e589, which
// kept the fallback. Correct as regions-plugin wrote it; wrong the moment dx-shepherd took the
@@ -200,18 +203,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.
- const MOVED_TO_CATALOGUE = ['gospel-resources', 'my-upg-100-list']
- const moved = MOVED_TO_CATALOGUE.some((m) => file.includes(m))
- const who = file.includes('gospel-resources')
- ? "gospel-plugin moved it here from enLookup's English"
- : 'upg100-plugin moved it here from humanizeKey, a title-cased key segment'
- 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`)
}
}
@@ -248,6 +253,52 @@ for (const [file, helper, fallbackFrag] of SITES) {
'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required')
}
+// ── 9. THE RULING'S PRECONDITION, kept true rather than measured once ───────────────────────────
+// Dropping a site's own English fallback is only safe while the catalogue HAS every key that site asks
+// for. Before the switch I measured it by hand: 11 keys, 11 locales, 0 gaps. A hand measurement protects
+// the day it is taken; this protects every day after, which is the whole difference between a check and
+// a note. If a key is added to the source and not catalogued, a bare host now renders the raw dotted
+// key -- the exact failure upg100 described, and the reason they had advised keeping a fallback at all.
+{
+ const src = rf(new URL('../plugin-designs/all-people-groups/all-people-groups.vue', import.meta.url), 'utf8')
+ // ★ RAW SOURCE, NOT codeOnly, AND THE ASYMMETRY IS THE REASON. For "does the catalogue hold every key
+ // this file asks for", over-including a key (one that only appears in a comment) makes the check
+ // STRICTER and can only cause a false red that a human resolves in seconds. UNDER-including one makes
+ // it claim completeness it does not have, which is the failure this section exists to prevent.
+ // It matters concretely: codeOnly loses `tr('pray')` in this file. That call lives inside a template
+ // literal whose `https://` was read as a line comment, because the scanner had already lost template
+ // state further up a 1000-line SFC. See the limitation note in strip-code.mjs. Six keys became seven
+ // the moment the census stopped stripping.
+ const code = src
+ const literal = [...new Set([...code.matchAll(/\btr\(\s*'([A-Za-z][\w.]*)'/g)].map((m) => m[1]))]
+ // ★ LITERALS ARE NOT ALL OF THEM, and a census that stopped here would have claimed completeness while
+ // blind to a third of the keys. This file also calls tr(cat.id) and tr(c.id) -- the category ids from
+ // the CATS array -- so four keys (doxa, frontier, unreached, reached) never appear as a literal
+ // argument. Extract those ids too, and assert BOTH that they were found and that the dynamic call
+ // sites still look the way this extraction assumes.
+ const catIds = [...new Set([...code.matchAll(/\{\s*id:\s*'([a-z]+)'\s*,\s*color:/g)].map((m) => m[1]))]
+ const dynamic = [...new Set([...code.matchAll(/\btr\(\s*([A-Za-z_$][\w.$]*)/g)].map((m) => m[1]))]
+ ok(dynamic.length === 0 || catIds.length > 0,
+ `the dynamic tr() arguments (${dynamic.join(', ') || 'none'}) are covered by the CATS ids (${catIds.join(', ') || 'NONE FOUND'})`)
+ ok(catIds.length === 4,
+ `found ${catIds.length} category ids -- if this changes, the dynamic keys this census covers have changed with it`)
+ const used = [...new Set([...literal, ...catIds])]
+ ok(used.length > literal.length,
+ `all-people-groups: ${used.length} keys verified = ${literal.length} literal + ${catIds.length} reached only through tr(cat.id)`)
+ const locales = readdirSync(new URL('../library/i18n/locales', import.meta.url))
+ const gaps = []
+ for (const k of used) for (const loc of locales) {
+ let v
+ try { v = JSON.parse(rf(new URL(`../library/i18n/locales/${loc}/allPeopleGroups.json`, import.meta.url), 'utf8'))[k] } catch (_) { v = undefined }
+ if (v === undefined || v === '') gaps.push(`${loc}:${k}`)
+ }
+ ok(gaps.length === 0,
+ `every key it asks for exists in all ${locales.length} locales${gaps.length ? ` -- MISSING ${gaps.slice(0, 6).join(', ')}` : ''}`)
+ let invented
+ try { invented = JSON.parse(rf(new URL('../library/i18n/locales/en/allPeopleGroups.json', import.meta.url), 'utf8')).definitelyNotACataloguedKey } catch (_) {}
+ ok(invented === undefined, 'NEG CONTROL: an invented key is absent, so the lookup above can actually fail')
+}
+
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
{
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)
diff --git a/internal/check-universal-props.mjs b/internal/check-universal-props.mjs
index add94bb7..993026df 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 87509893..398873e6 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 9db99b8a..cc176070 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 9c7dd361..547de006 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 00000000..815e7f58
--- /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 (the agent 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 (the agent: "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 22866a63..928e79a1 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 d69424a3..ff70d561 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 the agent 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)}