diff --git a/library/composables/useSelectionBus.js b/library/composables/useSelectionBus.js index 49f0e9a82..f885d86e7 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 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 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, } diff --git a/package.json b/package.json index 4634b022e..fb35fb396 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,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", @@ -332,6 +333,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 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.", diff --git a/plugin-designs/research-map-plus-grid/scripts/check-hover-channel.mjs b/plugin-designs/research-map-plus-grid/scripts/check-hover-channel.mjs new file mode 100644 index 000000000..b1cb65686 --- /dev/null +++ b/plugin-designs/research-map-plus-grid/scripts/check-hover-channel.mjs @@ -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.`)