diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md
index 83a70d43f..8533ebabd 100644
--- a/documentation/reference-library-index.md
+++ b/documentation/reference-library-index.md
@@ -117,10 +117,10 @@ and a change here can break other maps.
`import … from '@map/composables/useAvatarGenerator.js'`
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
`import … from '@map/composables/useCountryOutline.js'`
-- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
- `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.
@@ -149,8 +149,6 @@ and a change here can break other maps.
`import … from '@map/composables/useMapSelection.js'`
- **useMapTheme** — useMapTheme.js — shared light/dark theme for every map profile.
`import … from '@map/composables/useMapTheme.js'`
-- **useOptionalT** — ★ NO .-SHAPED EXAMPLES IN THIS FILE'S COMMENTS. check:i18n-keys-exist reads any such
- `import … from '@map/composables/useOptionalT.js'`
- **usePosterPrints** — usePosterPrints.js — THE print actions every poster wall shares.
`import … from '@map/composables/usePosterPrints.js'`
- **usePplrInstance** — usePplrInstance.js — per-map "instance store" mediator.
@@ -165,14 +163,10 @@ and a change here can break other maps.
`import … from '@map/composables/useSelectedPin.js'`
- **useSelectionBus** — useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus.
`import … from '@map/composables/useSelectionBus.js'`
-- **useSelectionCamera** — useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
- `import … from '@map/composables/useSelectionCamera.js'`
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
`import … from '@map/composables/useShadowStyles.js'`
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
`import … from '@map/composables/useSharedMapToken.js'`
-- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
- `import … from '@map/composables/useTextHighlight.js'`
### Utilities (pure helper functions)
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/plugin-designs/research-dashboard/src/composables/useDashboardShell.js b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js
index 843ff8d54..e9f828e7c 100644
--- a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js
+++ b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js
@@ -23,11 +23,11 @@
* responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync.
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
*/
-import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from '@map/composables/useSelectionBus.js'
+import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
import { useDashboardData } from './useDashboardData.js'
-import { useCsvExport } from '@map/composables/useCsvExport.js'
+import { useCsvExport } from './useCsvExport.js'
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json'
@@ -40,80 +40,12 @@ const mapValues = (obj, fn) =>
// number formatter (same as the old inline `fmt`); exported for the profile's KPI strip.
export const fmt = (n) => (n === null || n === undefined ? '—' : Number(n).toLocaleString('en-US'))
-/**
- * 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)
-}
+// ★ THE SEARCH MOVED OUT (kit landing 4a) AND IS RE-EXPORTED FROM HERE FOR ONE CYCLE.
+// Eight files import these three names from this module; re-exporting means the move and its
+// consumers do not have to land together, exactly as the composable shims did in landings 1-3.
+// Unlike those, this one is a barrel line rather than a file, so there is nothing to delete later —
+// only a line to remove once the eight importers point at the library directly.
+export { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
/**
* Generic KPI computation driven by config.kpis. Each kpi def declares a `kind`: