fix(studio): compare the reveal request across the id-space boundary

The previous commit's store guard was silently never true. A reveal request
carries the BARE dom id — `revealElementId = runtimeAudioId(keyframeClip)`,
because the panel and the runtime speak that — while the store's ids are
`sourceFile#domId`. So `elementKey === id` compared "music-bed" against
"index.html#music-bed" and the request was still cleared by its own selection.

This is the id-space boundary the branch's own handoff names as the trap most
likely to be re-broken, and it fails exactly as documented: no error, the
feature just never matches. Caught only by driving the real UI — the store said
`selected: "index.html#music-bed"` and `revealSurvived: null` in the same read.

`revealTargetsSelection` now splits the key with the existing
`splitTimelineElementKey` and is a named function in `playerStoreSelection.ts`
so the crossing is stated once, where it happens.

**Verified in the browser, both halves, which had never been watched working:**
- clip NOT selected: clicking a lane label selects the clip, the rack opens, and
  the carve module expands (correct — that lane's node is a carve band). The
  request is consumed and retired to null by the new unmount clear.
- clip ALREADY selected: collapsing the module and clicking the same lane again
  reopens it, which the old value-equality consumption could not do.

studio player: 103 files, 1355 tests. fallow clean.
This commit is contained in:
Vance Ingalls
2026-08-20 16:41:24 -07:00
parent a4b3eb0b9e
commit a44ebd7573
2 changed files with 29 additions and 3 deletions
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { attachPlayerStoreDevHandle } from "./playerStoreDevHandle";
import { nextSelectionSet } from "./playerStoreSelection";
import { nextSelectionSet, revealTargetsSelection } from "./playerStoreSelection";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { BeatEditState } from "../../utils/beatEditing";
@@ -541,7 +541,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
// selection lands afterwards and used to clear the very request that
// caused it — the panel then read null and the section never opened.
// Any OTHER selection still drops it: a request aimed elsewhere is stale.
const revealSurvives = s.revealedAudioFxTarget?.elementKey === id;
//
// Compared across the ID-SPACE BOUNDARY, which is why this needs saying:
// a request carries the BARE dom id (`runtimeAudioId`, because the panel
// and the runtime speak that), while this store's ids are
// `sourceFile#domId`. A direct `===` was silently never true — the exact
// shape of failure the id-space split produces.
const revealSurvives = revealTargetsSelection(s.revealedAudioFxTarget, id);
return id !== s.selectedElementId
? {
selectedElementId: id,
@@ -1,9 +1,12 @@
/**
* Selection-set arithmetic for the player store.
* Selection-set arithmetic for the player store, and the reveal request's
* cross-id-space match.
*
* Its own module so `playerStore.ts` stays under the studio's 600-line cap.
*/
import { splitTimelineElementKey } from "../lib/timelineElementHelpers";
/**
* The id set a selection change leaves behind.
*
@@ -19,3 +22,20 @@ export function nextSelectionSet(
if (preserveSet) return id && current.has(id) ? new Set(current) : new Set<string>();
return id ? new Set([id]) : new Set<string>();
}
/**
* Is a pending reveal request aimed at the element being selected?
*
* Compared across the ID-SPACE BOUNDARY, which is why it is a named function:
* a request carries the BARE dom id (`runtimeAudioId` — the panel and the
* runtime speak that), while the store's ids are `sourceFile#domId`. A direct
* `===` between the two is silently never true, which is the exact shape of
* failure the split produces and how the reveal came to be dead.
*/
export function revealTargetsSelection(
request: { elementKey: string } | null,
id: string | null,
): boolean {
if (!request || id === null) return false;
return request.elementKey === splitTimelineElementKey(id).domId;
}