doxa-maps/library/dashboards/README.md
Doxa Maps 70ea389db3 Docs: "reusable by default" in the library guides, and Deep Research by its name
- library/components/WARNING.md, library/dashboards/README.md and
  documentation/contributing/README.md taught "graduate into the library at the second
  consumer". They now teach the plugin-designer skill's rule (section 3): a component that
  could serve more than one map is built once in library/, driven by props, and landed through
  the build owner; one-off or experimental pieces stay in a map's folder; a new pattern is a
  pilot, then approved, then rolled out. The contributing guide's "full reasoning" link pointed
  to an index without it; it now points to the skill.
- The app shell doxa-maps-spa is labelled Deep Research. Ten docs still called it the Research
  Gallery (15 mentions): DEPLOY.md, the desktop how-to, embeddability, develop-a-plugin,
  reference-universal-props, reference.md, the shell's EMBED.md, and the plugin-designer skill
  with two of its references. Identifiers (doxa-maps-spa, branch and route names) are unchanged;
  README.md is not touched.
2026-10-01 08:27:04 +00:00

4.5 KiB

The dashboard kit contract

Four panels — a map, a data table, a charts rail and a detail drawer — behave as one dashboard without importing each other. This file is the contract they share. It is documentation of what exists today, verified against development, not a proposal.

The components still live in plugin-designs/research-dashboard/src/components/dashboards/. They are not in library/ yet. Under the repo's "reusable by default" rule (skills/plugin-designer, section 3), a component another dashboard could use belongs in library/, driven by props; moving these is a library change for the build owner.

The shape

One composable provides; every panel injects. Nothing is passed down as props, and no panel imports another.

plugin-designs/research-dashboard/src/composables/useDashboardShell.js provides five keys:

key what it is
selectionBus the shared event bus and selection/filter state (library/composables/useSelectionBus.js)
dashboardData the row store the panels read
dashboardConfig per-profile configuration — labels, colours, which column is the photo
dataStore the underlying data source
searchSourceId identifies whose search a result belongs to

A panel takes what it needs and tolerates absence:

const dash = inject('dashboardData', null)
const bus  = inject('selectionBus', null)
const cfg  = inject('dashboardConfig', {})

The null defaults are load-bearing. A panel rendered outside a shell must degrade, not throw — a thrown setup() removes the subtree with an empty error and no build failure, which is expensive to diagnose.

The bus carries two different things

on(event, cb) and emit(event, payload) are generic: any string is a valid event. That makes the bus easy to extend and easy to misread, so the distinction matters.

State events — owned by useSelectionBus, emitted only by it, in response to its own mutators:

event payload emitted when
filter filterSet setFilter, clearFilters, toggleFilterValue
select selectionSet toggleSelect, selectMany, deselectMany, setSelection, clearSelection
search the term setSearch
hover the key, or null setHover

UI events — passed through the same bus by panels, with no composable behind them: focusRow, mapFocus, toggleTable, toggleRail, toggleFacet, resetLayout, layoutResizing.

★ Do not look for a state event's emitter in the panels. Two panels listen for select and no panel emits it; the emitter is useSelectionBus.js:202. Grepping components for the bus's traffic cannot see the bus itself, and reading that absence as a dead contract is a mistake worth naming here because it has already been made twice while auditing this kit.

Selection does not travel by emit

selectionSet is a plain Set<slug>, deliberately not reactive — a per-row toggleSelect() loop firing a synchronous emit('select') per row is why. selectionVersion is the reactive handle: watch that, read the Set.

So a panel reacting to selection watches selectionVersion (or subscribes to select) and then reads bus.selectionSet. The table writes into it; the map frames its camera off the full row store rather than the filtered rows, so a record selected and then filtered out of scope still shows.

What each panel does with it today

panel reads writes
data table selectionSet, filter selectionSet, toggleTable
map panel select, focusRow, toggleTable, layoutResizing focusRow, mapFocus
detail drawer select, focusRow —
charts rail filterSet via isFilterActive / rowMatchesFilters setFilter

★ The charts rail participates in filtering only. Clicking a slice narrows the map; selecting a pin does not highlight its slice, because the rail neither emits nor listens for select. That is the current contract, not an oversight to fix casually — whether selection should reach the charts is a product decision, and it is open.

Adding a panel

Inject the three keys with null/{} defaults, subscribe in setup, and unsubscribe on unmount — on() returns its own unsubscribe function. Read state from bus, never from another panel. If a new cross-panel signal is needed, prefer a state event with a mutator on the composable over a new ad-hoc UI event: the ad-hoc ones are invisible to anyone reading useSelectionBus.js, which is where a maintainer looks first.