doxa-maps/plugin-designs/research-map-plus-grid/scripts/check-hover-channel.mjs
Doxa Maps bad6991b03 D3: the bus can point at a record without choosing it
SPEC-DASHBOARD-KIT D3, and the last of the three decided-not-built items. The bus
carried two coordinated channels — filterSet NARROWS scope, selectionSet MARKS
individuals — and was missing the third: hoverKey POINTS at the one record under
the pointer. It is how a reader sweeps a table and watches the map answer without
choosing anything, and kepler.gl treats hover as a configured interaction beside
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 become a plain Set
with a version counter precisely because a reactive collection fires per element;
a single ref has none of that problem — one dependency, one write, no iteration.
A repeat is a no-op, so an unchanged value never wakes a panel.

HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. The cheap implementation is to
put the key in selectionSet "just while hovering", and every consequence of that
is bad while none is obvious at the call site: useSelectionCamera watches
selectionVersion, so a hover would fly the camera at every mousemove — the
resize-strobe bug wearing a new hat, on a Driver who is photosensitive. The CSV
exporter reads selection > filter > all, so a hover would silently change what an
export contains. The table's selection pill and the shell's live region both
report selection counts, and both would narrate the pointer. So a panel may fade
or outline on hover; it may not fly, select or filter.

NO PANEL IS WIRED, deliberately, and I want that on the record rather than left
to be noticed. D3 says panels opt in, and the first opt-in belongs with a
consumer that has a reason: wiring one into Big Picture speculatively would be a
visual change to the flagship that no reader asked for, landed while the kit
stack is paused. That does leave a channel nothing uses — the same criticism I
made of the unmounted filter chips — so the difference has to be earned rather
than asserted: this is a documented, gated contract with its first consumer
named, not a component that was quietly orphaned. check:hover-channel is what
makes it a contract instead of dead code; the first panel to use it inherits the
rules instead of inventing them.

Three mutants, all caught: routing setHover through selectionSet, dropping the
equality guard, and removing hoverKey from the returned surface.

SOURCE ONLY. Stacked on landing 2. No rebuild needed — nothing renders it yet.
2026-09-27 02:17:35 +00:00

85 lines
5 KiB
JavaScript

#!/usr/bin/env node
/**
* check-hover-channel.mjs — hover points at a record; it must never commit to one.
*
* SPEC-DASHBOARD-KIT D3. The bus carries three coordinated channels: filterSet NARROWS scope,
* selectionSet MARKS individuals, and hoverKey POINTS at the one record under the pointer.
* kepler.gl treats hover as a configured interaction alongside filters for the same reason — it is
* how a reader sweeps a table and watches the map answer without choosing anything.
*
* WHY THIS IS GATED RATHER THAN REVIEWED. Hover looks like a weaker selection, and the cheap way to
* implement it is to put the key in selectionSet "just while hovering". Every consequence of that
* is bad and none of them are obvious at the call site:
* - selectionSet is a plain Set with a version counter, and useSelectionCamera watches that
* counter. A hover that bumped it would fly the camera at every mousemove — the resize-strobe
* bug wearing a new hat, on a Driver who is photosensitive.
* - the CSV exporter reads selection > filter > all, so a hover would silently change what an
* export contains.
* - the table's selection pill and the shell's live region both report selection counts, and
* both would narrate the pointer.
* So the contract is asserted: hover is its own ref, it does not touch the selection, and a repeat
* is a no-op (hover fires on every mousemove across 2,107 rows; a channel that woke every panel on
* an unchanged value would need a debounce it should not need).
*
* NOT CHECKED: which panels light up on hover. D3 says panels opt in, and the first opt-in belongs
* with a consumer that has a reason — wiring one into Big Picture speculatively would be a visual
* change to the flagship with no reader asking for it. This gate guards the CHANNEL so the first
* panel to use it inherits the rules rather than inventing them.
*
* NEGATIVE-TESTED: routing setHover through selectionSet fails A; dropping the equality guard
* fails B; removing hoverKey from the returned surface fails the import check.
*/
import { existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'HOVER-CHANNEL'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const BUS = join(ROOT, 'library/composables/useSelectionBus.js')
if (!existsSync(BUS)) { console.log(`${TAG}: SKIP — no ${BUS.replace(ROOT + '/', '')} (kit landing 1 not applied).`); process.exit(0) }
const { createSelectionBus } = await import(BUS)
const fails = []
const notes = []
const eq = (what, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
const bus = createSelectionBus()
if (!bus.hoverKey || typeof bus.setHover !== 'function') {
fails.push('the bus exposes no hoverKey/setHover — D3\'s channel is missing, and a panel wanting hover would have to invent its own, which is how selectionSet gets abused for it.')
} else {
// ── A. hover is not selection ──────────────────────────────────────────────────────────────
bus.selectMany(['keep-me'])
const versionBefore = bus.selectionVersion.value
bus.setHover('abkhaz')
eq('A hoverKey holds the record', bus.hoverKey.value, 'abkhaz')
eq('A hover does NOT enter the selection', [...bus.selectionSet], ['keep-me'])
eq('A hover does NOT bump selectionVersion (the camera watches it)', bus.selectionVersion.value, versionBefore)
eq('A hover does NOT touch the filters', Object.keys(bus.filterSet), [])
// ── B. a repeat is a no-op, and clearing is explicit ───────────────────────────────────────
let emitted = 0
bus.on('hover', () => { emitted++ })
bus.setHover('same'); bus.setHover('same'); bus.setHover('same')
eq('B three identical hovers emit once', emitted, 1)
bus.setHover(null)
eq('B null clears', bus.hoverKey.value, null)
eq('B clearing emits', emitted, 2)
bus.setHover()
eq('B a bare call means nothing hovered', bus.hoverKey.value, null)
eq('B clearing an already-clear hover is a no-op', emitted, 2)
// ── C. clearing the selection leaves hover alone, and vice versa ────────────────────────────
bus.setHover('x'); bus.clearSelection()
eq('C clearing the selection does not clear hover', bus.hoverKey.value, 'x')
bus.selectMany(['y']); bus.setHover(null)
eq('C clearing hover does not clear the selection', [...bus.selectionSet], ['y'])
if (!fails.length) notes.push('hover: its own ref, never the selection, no-op on repeat, independent of both other channels')
}
for (const n of notes) console.log(' · ' + n)
if (fails.length) {
console.error(`\n${TAG}: FAIL`)
for (const f of fails) console.error(' ✗ ' + f)
process.exit(1)
}
console.log(`${TAG}: PASS — hover points without committing.`)