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.
This commit is contained in:
parent
756eb67920
commit
bbd7eaa1f0
3 changed files with 122 additions and 2 deletions
|
|
@ -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<cb>
|
||||
|
||||
function on(event, cb) {
|
||||
|
|
@ -156,6 +177,18 @@ export function createSelectionBus() {
|
|||
}
|
||||
|
||||
// ── free-text search (header-1) ──
|
||||
/** Point at one record, or `null` for nothing. The value is whatever the profile's primary key
|
||||
* is (`config.data.primaryKey`, a slug here) so every panel is pointing at the same thing.
|
||||
* Deliberately NOT debounced in the bus: what a hover costs depends on what the panel does with
|
||||
* it, so the panel that repaints owns that decision. The bus's job is to be cheap enough that
|
||||
* it never needs one. */
|
||||
function setHover(key) {
|
||||
const next = key === undefined ? null : key
|
||||
if (hoverKey.value === next) return // a no-op must not wake every panel watching the ref
|
||||
hoverKey.value = next
|
||||
emit('hover', next)
|
||||
}
|
||||
|
||||
function setSearch(term) {
|
||||
searchTerm.value = term == null ? '' : String(term)
|
||||
emit('search', searchTerm.value)
|
||||
|
|
@ -220,8 +253,8 @@ export function createSelectionBus() {
|
|||
}
|
||||
|
||||
return {
|
||||
filterSet, selectionSet, selectionVersion, searchTerm, on, emit,
|
||||
setFilter, clearFilters, setSearch, configureHierarchy,
|
||||
filterSet, selectionSet, selectionVersion, searchTerm, hoverKey, on, emit,
|
||||
setFilter, clearFilters, setSearch, setHover, configureHierarchy,
|
||||
toggleSelect, clearSelection, selectMany, deselectMany, setSelection,
|
||||
toggleFilterValue, facetMatches, isFilterActive, rowMatchesFilters,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@
|
|||
"check:translator-contacts:poison": "node internal/check-translator-contacts.mjs --poison",
|
||||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs",
|
||||
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test",
|
||||
"check:hover-channel": "node plugin-designs/research-map-plus-grid/scripts/check-hover-channel.mjs",
|
||||
"check:host-locale-contract": "node internal/check-host-locale-contract.mjs",
|
||||
"check:i18n-keys-exist": "node plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs",
|
||||
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
|
||||
|
|
@ -327,6 +328,7 @@
|
|||
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
|
||||
"check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"check:hover-channel": "SPEC-DASHBOARD-KIT D3: the bus's third coordinated channel points at a record without committing to one. Hover looks like a weaker selection, and the cheap implementation is to put the key in selectionSet 'just while hovering' - every consequence of which is bad and none obvious at the call site: useSelectionCamera watches selectionVersion, so a hover would fly the camera at every mousemove (the resize-strobe class, on a photosensitive Driver); the CSV exporter reads selection > filter > all, so a hover would change what an export contains; the table's selection pill and the shell's live region would both narrate the pointer. Runs the real bus: hover holds its own ref, never enters the selection, never bumps the version, never touches the filters, a repeat emits once (hover fires on every mousemove across 2,107 rows), and the three channels clear independently. Does NOT check which panels light up - D3 says panels opt in, and the first opt-in belongs with a consumer that has a reason. No browser.",
|
||||
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
|
||||
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
|
||||
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
#!/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.`)
|
||||
Loading…
Reference in a new issue