feat(studio,core): reach presets and the rack from the timeline

C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).

The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).

The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).

Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
  (data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
  (timelineElementFxAttribute.ts), addressed by the TimelineElement
  itself rather than the current selection. This is the one real
  architectural gap the step doc's assumption didn't survive: the
  property panel's onSetAttributeQuiet closes over domEditSelection,
  so writing a clip that isn't already selected has no synchronous
  path through it. Extracted the shared live-patch-then-persist core
  (persistElementAttribute, timelineEditingHelpers.ts) out of both
  this new path and the existing setAudioGroupAttribute, which the
  fallow duplication gate flagged as a 66-line clone on first pass —
  now a single ~50-line core parameterized by patchLive/readLive, with
  each caller a ~15-line wrapper resolving its own patch target
  (buildPatchTarget({domId}) for a group, buildPatchTarget(element)
  for an arbitrary clip) and live-DOM lookup.

Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.

Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.

Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:31 -07:00
co-authored by Claude Sonnet 5
parent 301cb8b51f
commit 1d5405e3e0
22 changed files with 963 additions and 82 deletions
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
import { TimelineFxButton } from "./TimelineFxButton.js";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
}
function mount(node: React.ReactElement) {
const host = document.createElement("div");
document.body.append(host);
act(() => createRoot(host).render(node));
return host;
}
afterEach(() => {
document.body.innerHTML = "";
});
describe("TimelineFxButton", () => {
it("reads FX with no count when the chain is empty", () => {
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={undefined}
onChainChange={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
expect(byTextButton(host, "FX")?.textContent).toBe("FX");
});
it("counts only enabled nodes", () => {
const chain = {
version: 1 as const,
nodes: [
{ type: "peaking", params: {}, enabled: true },
{ type: "gain", params: {}, enabled: false },
],
};
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={serializeAudioFxChain(chain)}
onChainChange={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
expect(byTextButton(host, "FX 1")).toBeDefined();
});
it("opens the popover on click, anchored off the button", () => {
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={undefined}
onChainChange={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
expect(host.querySelector('[role="dialog"]')).toBeNull();
act(() => byTextButton(host, "FX")?.click());
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
});
it("group-pointer variant offers Group instead of a popover", () => {
const onGroupClips = vi.fn();
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
act(() => byTextButton(host, "FX")?.click());
const groupButton = document.body.querySelectorAll("button");
const group = Array.from(groupButton).find((b) => b.textContent === "Group");
expect(group).toBeDefined();
act(() => group?.click());
expect(onGroupClips).toHaveBeenCalledTimes(1);
});
});