@hyperframes/studio 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10336 -7195
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  142. package/src/player/lib/timelineDOM.ts +51 -0
  143. package/src/player/store/audioSoloSlice.test.ts +113 -0
  144. package/src/player/store/audioSoloSlice.ts +43 -0
  145. package/src/player/store/editingModeSlice.ts +45 -0
  146. package/src/player/store/groupLevels.ts +33 -0
  147. package/src/player/store/keyframeSlice.ts +26 -0
  148. package/src/player/store/playerStore.ts +21 -22
  149. package/src/player/store/timelineElement.ts +10 -0
  150. package/src/telemetry/events.ts +12 -0
  151. package/dist/assets/index-Cy6fGN1u.js +0 -428
  152. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -177,7 +177,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
177
177
  type="button"
178
178
  disabled={!prevKf}
179
179
  onClick={() => prevKf && onSeek(prevKf.percentage)}
180
- className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
180
+ title="Previous keyframe"
181
+ aria-label={`Previous ${property} keyframe`}
182
+ className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
181
183
  >
182
184
  <ArrowLeft disabled={!prevKf} />
183
185
  </button>
@@ -197,7 +199,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
197
199
  type="button"
198
200
  disabled={!nextKf}
199
201
  onClick={() => nextKf && onSeek(nextKf.percentage)}
200
- className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
202
+ title="Next keyframe"
203
+ aria-label={`Next ${property} keyframe`}
204
+ className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
201
205
  >
202
206
  <ArrowRight disabled={!nextKf} />
203
207
  </button>
@@ -469,14 +469,23 @@ export const LayersPanel = memo(function LayersPanel() {
469
469
  : selected
470
470
  ? "bg-panel-accent/14 text-panel-accent"
471
471
  : "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
472
- } ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
472
+ } ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
473
473
  style={{ paddingLeft: 8 + layer.depth * 16 }}
474
+ title={
475
+ draggable
476
+ ? layer.element.hasAttribute("data-hf-group")
477
+ ? "Double-click to enter group"
478
+ : undefined
479
+ : "This layer can't be reordered"
480
+ }
474
481
  >
475
482
  {hasChildren ? (
476
483
  <button
477
484
  type="button"
478
485
  onClick={(e) => toggleCollapse(layer.key, e)}
479
- className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300"
486
+ aria-expanded={!isCollapsed}
487
+ aria-label={isCollapsed ? "Expand children" : "Collapse children"}
488
+ className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300 before:absolute before:-inset-1.5 before:content-['']"
480
489
  >
481
490
  <svg
482
491
  width="8"
@@ -55,6 +55,7 @@ export function MotionPathNode(props: {
55
55
  onPointerDown={props.onPointerDown}
56
56
  onPointerMove={props.onPointerMove}
57
57
  onPointerUp={props.onPointerUp}
58
+ onPointerCancel={props.onPointerUp}
58
59
  onContextMenu={props.onContextMenu}
59
60
  />
60
61
  )}
@@ -536,12 +536,11 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
536
536
  if (!xRow) throw new Error("expected an X row");
537
537
  const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
538
538
  if (!gutter) throw new Error("expected a keyframe gutter on the X row");
539
- // The diamond button always carries a `title`; the two plain arrow
540
- // buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev
541
- // arrow is disabled (no earlier keyframe) and the next arrow seeks to
542
- // the 50% keyframe — exactly the case the coherence bug affected.
539
+ // At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
540
+ // disabled (no earlier keyframe) and the next arrow seeks to the 50%
541
+ // keyframe exactly the case the coherence bug affected.
543
542
  const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
544
- (b) => !b.title && !b.disabled,
543
+ (b) => b.title === "Next keyframe" && !b.disabled,
545
544
  );
546
545
  if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
547
546
  act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
@@ -582,7 +581,9 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
582
581
  if (!xRow) throw new Error("expected an X row");
583
582
  const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
584
583
  if (!gutter) throw new Error("expected a keyframe gutter on the X row");
585
- const diamond = gutter.querySelector<HTMLButtonElement>("button[title]");
584
+ // The diamond is the only gutter button that reports a pressed state;
585
+ // the prev/next arrows carry titles too, so `button[title]` is ambiguous.
586
+ const diamond = gutter.querySelector<HTMLButtonElement>("button[aria-pressed]");
586
587
  if (!diamond) throw new Error("expected a keyframe diamond button");
587
588
  // KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
588
589
  // inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
@@ -234,11 +234,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
234
234
 
235
235
  const handleCopyElementInfo = () => {
236
236
  const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
237
- void navigator.clipboard.writeText(text);
238
- showToast(`Copied element info for ${element.label} paste into any AI agent`, "info");
239
- setClipboardCopied(true);
240
- clearTimeout(clipboardTimerRef.current);
241
- clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
237
+ // Claim the copy only once the write actually lands — a denied clipboard
238
+ // permission otherwise reports a copy that never happened.
239
+ navigator.clipboard
240
+ .writeText(text)
241
+ .then(() => {
242
+ showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
243
+ setClipboardCopied(true);
244
+ clearTimeout(clipboardTimerRef.current);
245
+ clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
246
+ })
247
+ .catch(() => {
248
+ showToast("Couldn't copy to the clipboard — check browser permissions", "error");
249
+ });
242
250
  };
243
251
 
244
252
  if (STUDIO_FLAT_INSPECTOR_ENABLED) {
@@ -79,6 +79,7 @@ export function PropertyPanelFlat({
79
79
  onRemoveTextField,
80
80
  onAskAgent,
81
81
  onToggleElementHidden,
82
+ onAutoGroupCarveSources,
82
83
  onImportAssets,
83
84
  onAddMediaOverlay,
84
85
  onImportFonts,
@@ -440,6 +441,7 @@ export function PropertyPanelFlat({
440
441
  element={element}
441
442
  onSetAttributeQuiet={onSetAttributeQuiet ?? onSetAttributeLive}
442
443
  onSetAttributeLive={onSetAttributeLive}
444
+ onAutoGroupCarveSources={onAutoGroupCarveSources}
443
445
  />
444
446
  ),
445
447
  });
@@ -102,7 +102,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
102
102
  {motionPathCreateAvailable && (
103
103
  <button
104
104
  type="button"
105
- className={`rounded-md p-1.5 transition-colors ${
105
+ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
106
106
  motionPathArmed
107
107
  ? "bg-studio-accent/20 text-studio-accent"
108
108
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
@@ -118,7 +118,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
118
118
  )}
119
119
  <button
120
120
  type="button"
121
- className={`rounded-md p-1.5 transition-colors ${
121
+ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
122
122
  prefs.snapEnabled
123
123
  ? "bg-studio-accent/20 text-studio-accent"
124
124
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
@@ -134,7 +134,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
134
134
  <button
135
135
  ref={gridButtonRef}
136
136
  type="button"
137
- className={`rounded-md p-1.5 transition-colors ${
137
+ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
138
138
  prefs.gridVisible
139
139
  ? "bg-studio-accent/20 text-studio-accent"
140
140
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
@@ -144,11 +144,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
144
144
  e.preventDefault();
145
145
  setGridPopoverOpen((v) => !v);
146
146
  }}
147
- title={prefs.gridVisible ? "Grid visible (G)" : "Grid hidden (G)"}
147
+ title={
148
+ prefs.gridVisible
149
+ ? "Grid visible (G) — right-click for spacing options"
150
+ : "Grid hidden (G) — right-click for spacing options"
151
+ }
148
152
  aria-label="Toggle grid"
149
153
  >
150
154
  <GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
151
155
  </button>
156
+ <button
157
+ type="button"
158
+ className="absolute -right-0.5 -bottom-0.5 rounded p-0.5 text-white/50 hover:text-white/90 bg-black/50"
159
+ onClick={() => setGridPopoverOpen((v) => !v)}
160
+ title="Grid options"
161
+ aria-label="Grid options"
162
+ aria-expanded={gridPopoverOpen}
163
+ >
164
+ <svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true">
165
+ <path d="M1 2.5l3 3 3-3z" />
166
+ </svg>
167
+ </button>
152
168
 
153
169
  {gridPopoverOpen && (
154
170
  <div
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createRoot } from "react-dom/client";
5
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
6
+ import { TimelineFxPopover } from "./TimelineFxPopover.js";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
11
+ const RECT = { left: 0, top: 0, right: 0, bottom: 0 } as DOMRect;
12
+
13
+ function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
14
+ return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
15
+ }
16
+
17
+ function mount(overrides: Partial<Parameters<typeof TimelineFxPopover>[0]> = {}) {
18
+ const onClose = vi.fn();
19
+ const onChainChange = vi.fn();
20
+ const onChainPreview = vi.fn();
21
+ const onOpenRack = vi.fn();
22
+ const host = document.createElement("div");
23
+ document.body.append(host);
24
+ act(() => {
25
+ createRoot(host).render(
26
+ <TimelineFxPopover
27
+ anchorRect={RECT}
28
+ chain={EMPTY_CHAIN}
29
+ onClose={onClose}
30
+ onChainChange={onChainChange}
31
+ onChainPreview={onChainPreview}
32
+ onOpenRack={onOpenRack}
33
+ {...overrides}
34
+ />,
35
+ );
36
+ });
37
+ return { host, onClose, onChainChange, onChainPreview, onOpenRack };
38
+ }
39
+
40
+ afterEach(() => {
41
+ document.body.innerHTML = "";
42
+ });
43
+
44
+ describe("TimelineFxPopover", () => {
45
+ it("applies a preset with exactly one onChainChange write, and closes", () => {
46
+ const { host, onChainChange, onClose } = mount();
47
+ const button = byTextButton(host, "Chipmunk");
48
+ expect(button).toBeDefined();
49
+ act(() => button?.click());
50
+ expect(onChainChange).toHaveBeenCalledTimes(1);
51
+ const written = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
52
+ expect(written.nodes.length).toBeGreaterThan(0);
53
+ expect(onClose).toHaveBeenCalled();
54
+ });
55
+
56
+ it("auditions on hover (focus is the keyboard's hover) and reverts on leave", () => {
57
+ const { host, onChainPreview } = mount();
58
+ const button = byTextButton(host, "Chipmunk");
59
+ expect(button).toBeDefined();
60
+ act(() => (button as HTMLButtonElement).focus());
61
+ expect(onChainPreview).toHaveBeenCalled();
62
+ const previewed = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
63
+ expect(previewed.nodes.length).toBeGreaterThan(0);
64
+ onChainPreview.mockClear();
65
+ act(() => {
66
+ button?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
67
+ });
68
+ expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN);
69
+ });
70
+
71
+ it("Escape closes without letting the keystroke propagate past the popover", () => {
72
+ const { host, onClose } = mount();
73
+ const outer = vi.fn();
74
+ document.body.addEventListener("keydown", outer);
75
+ const dialog = host.querySelector('[role="dialog"]');
76
+ expect(dialog).toBeTruthy();
77
+ act(() => {
78
+ dialog?.dispatchEvent(
79
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
80
+ );
81
+ });
82
+ expect(onClose).toHaveBeenCalled();
83
+ expect(outer).not.toHaveBeenCalled();
84
+ document.body.removeEventListener("keydown", outer);
85
+ });
86
+
87
+ it("dismisses on an outside pointerdown", () => {
88
+ const { onClose } = mount();
89
+ act(() => {
90
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
91
+ });
92
+ expect(onClose).toHaveBeenCalled();
93
+ });
94
+
95
+ it("does not dismiss on a pointerdown inside the popover", () => {
96
+ const { host, onClose } = mount();
97
+ const dialog = host.querySelector('[role="dialog"]');
98
+ expect(dialog).toBeTruthy();
99
+ act(() => {
100
+ dialog?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
101
+ });
102
+ expect(onClose).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("the footer opens the rack and closes the popover", () => {
106
+ const { host, onOpenRack, onClose } = mount();
107
+ const openRack = byTextButton(host, "Open rack");
108
+ expect(openRack).toBeDefined();
109
+ act(() => openRack?.click());
110
+ expect(onOpenRack).toHaveBeenCalledTimes(1);
111
+ expect(onClose).toHaveBeenCalled();
112
+ });
113
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The FX popover the timeline's per-track/per-group FX button opens (C1).
3
+ *
4
+ * A THIN positioner around existing pieces, not a second rack: the body is
5
+ * `FxPresetMenu` exactly as the property panel's FX section renders it, and
6
+ * applying or auditioning a preset goes through the caller's own write path
7
+ * (a group's own attribute for a group target, the selected element's for a
8
+ * clip) — nothing here serializes a chain of its own.
9
+ */
10
+
11
+ import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from "react";
12
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
13
+ import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
14
+ import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
15
+ import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
16
+ import { useFxAudition } from "./useFxAudition.js";
17
+
18
+ const POPOVER_WIDTH = 260;
19
+ const VIEWPORT_MARGIN = 8;
20
+
21
+ function clampedStyle(anchorRect: DOMRect): CSSProperties {
22
+ const left = Math.min(
23
+ Math.max(anchorRect.left, VIEWPORT_MARGIN),
24
+ Math.max(VIEWPORT_MARGIN, window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN),
25
+ );
26
+ const spaceBelow = window.innerHeight - anchorRect.bottom;
27
+ const openUpward = spaceBelow < 260 && anchorRect.top > spaceBelow;
28
+ return {
29
+ position: "fixed",
30
+ left,
31
+ width: POPOVER_WIDTH,
32
+ ...(openUpward
33
+ ? { bottom: window.innerHeight - anchorRect.top + 4 }
34
+ : { top: anchorRect.bottom + 4 }),
35
+ };
36
+ }
37
+
38
+ export interface TimelineFxPopoverProps {
39
+ anchorRect: DOMRect;
40
+ chain: HfAudioFxChain;
41
+ trackKind?: HfAudioNameKind;
42
+ onClose: () => void;
43
+ /** Persist the applied preset onto the target (group attribute, or the
44
+ * selected clip's attribute — the caller resolves which). */
45
+ onChainChange: (next: HfAudioFxChain) => void;
46
+ /** Preview a hypothetical chain on the running graph without persisting. */
47
+ onChainPreview?: (next: HfAudioFxChain) => void;
48
+ onAuditionTransport?: (on: boolean) => void;
49
+ /** Select the target the way clicking it in the timeline does, and ensure
50
+ * the property panel's Audio FX group is expanded. */
51
+ onOpenRack: () => void;
52
+ }
53
+
54
+ export function TimelineFxPopover({
55
+ anchorRect,
56
+ chain,
57
+ trackKind,
58
+ onClose,
59
+ onChainChange,
60
+ onChainPreview,
61
+ onAuditionTransport,
62
+ onOpenRack,
63
+ }: TimelineFxPopoverProps) {
64
+ const rootRef = useRef<HTMLDivElement | null>(null);
65
+ const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
66
+
67
+ // Outside click dismisses like any other popover; the button itself is
68
+ // excluded by pointerdown timing (the button's own click hasn't happened yet).
69
+ useEffect(() => {
70
+ const onPointerDown = (event: PointerEvent) => {
71
+ if (rootRef.current && !rootRef.current.contains(event.target as Node)) onClose();
72
+ };
73
+ document.addEventListener("pointerdown", onPointerDown, true);
74
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
75
+ }, [onClose]);
76
+
77
+ const applyPreset = (id: string) => {
78
+ const next = applyPresetToChain(chain, id, trackKind);
79
+ if (!next) return;
80
+ clearAudition();
81
+ onChainChange(next);
82
+ onClose();
83
+ };
84
+
85
+ // Escape closes without deselecting whatever is behind this — a keystroke
86
+ // aimed at the popover is not aimed at the clip under it.
87
+ const onKeyDown = (event: KeyboardEvent) => {
88
+ if (event.key !== "Escape") return;
89
+ event.stopPropagation();
90
+ audition(null);
91
+ onClose();
92
+ };
93
+
94
+ return (
95
+ <div
96
+ ref={rootRef}
97
+ role="dialog"
98
+ aria-label="Effects"
99
+ className="z-50 rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
100
+ style={clampedStyle(anchorRect)}
101
+ onKeyDown={onKeyDown}
102
+ onPointerDown={(event) => event.stopPropagation()}
103
+ >
104
+ <FxPresetMenu
105
+ trackKind={trackKind}
106
+ onPick={applyPreset}
107
+ onAudition={
108
+ onChainPreview
109
+ ? (id) =>
110
+ audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
111
+ : undefined
112
+ }
113
+ />
114
+ <div className="mt-2 flex items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
115
+ <button
116
+ type="button"
117
+ className="hover:text-white"
118
+ onClick={() => {
119
+ onClose();
120
+ onOpenRack();
121
+ }}
122
+ >
123
+ + effect
124
+ </button>
125
+ <button
126
+ type="button"
127
+ className="hover:text-white"
128
+ onClick={() => {
129
+ onClose();
130
+ onOpenRack();
131
+ }}
132
+ >
133
+ Open rack ›
134
+ </button>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
@@ -163,6 +163,25 @@ export function Transform3DCube({
163
163
  setDraft(null);
164
164
  };
165
165
 
166
+ const onKeyDown = (e: React.KeyboardEvent<SVGSVGElement>) => {
167
+ const STEP = e.altKey ? 1 : 5;
168
+ let next: CubePose | null = null;
169
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
170
+ const dir = e.key === "ArrowUp" ? -1 : 1;
171
+ next = e.shiftKey
172
+ ? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
173
+ : { ...shown, rotationX: wrapDeg(shown.rotationX + dir * STEP) };
174
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
175
+ const dir = e.key === "ArrowRight" ? 1 : -1;
176
+ next = e.shiftKey
177
+ ? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
178
+ : { ...shown, rotationY: wrapDeg(shown.rotationY + dir * STEP) };
179
+ }
180
+ if (!next) return;
181
+ e.preventDefault();
182
+ onPoseCommit(next);
183
+ };
184
+
166
185
  return (
167
186
  <div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
168
187
  <svg
@@ -174,8 +193,10 @@ export function Transform3DCube({
174
193
  onPointerMove={onPointerMove}
175
194
  onPointerUp={onPointerUp}
176
195
  onPointerCancel={onPointerUp}
196
+ onKeyDown={onKeyDown}
197
+ tabIndex={0}
177
198
  role="slider"
178
- aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
199
+ aria-label="3D rotation. Arrow keys rotate X/Y, Shift+arrows roll Z, Alt for fine steps; drag to rotate, scroll to change depth"
179
200
  aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
180
201
  shown.rotationY,
181
202
  )}°, Z ${Math.round(shown.rotationZ)}°`}
@@ -2,6 +2,7 @@ import { Window } from "happy-dom";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  applyManualOffsetDragCommit,
5
+ resumeGsapTimelines,
5
6
  applyManualOffsetDragDraft,
6
7
  applyManualOffsetDragMatrix,
7
8
  createManualOffsetDragMember,
@@ -447,3 +448,51 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
447
448
  expect(element.style.getPropertyValue("transform")).toMatch(/translate\(/);
448
449
  });
449
450
  });
451
+
452
+ describe("resumeGsapTimelines", () => {
453
+ it("unpauses exactly the timelines the drag start paused, then re-seeks the player", () => {
454
+ const window = new Window();
455
+ const element = window.document.createElement("div");
456
+ element.setAttribute("data-hf-drag-paused-timelines", "figma-demo-unlock,figma-demo-stagger");
457
+ window.document.body.append(element);
458
+
459
+ const pausedState: Record<string, boolean> = {
460
+ "figma-demo-unlock": true,
461
+ "figma-demo-stagger": true,
462
+ main: true,
463
+ };
464
+ const makeTl = (id: string) => ({
465
+ paused: (value?: boolean) => {
466
+ if (value !== undefined) pausedState[id] = value;
467
+ return pausedState[id]!;
468
+ },
469
+ });
470
+ const seeks: number[] = [];
471
+ const win = element.ownerDocument.defaultView as unknown as {
472
+ __timelines?: Record<string, unknown>;
473
+ __player?: { seek: (t: number) => void; getTime: () => number };
474
+ };
475
+ win.__timelines = {
476
+ "figma-demo-unlock": makeTl("figma-demo-unlock"),
477
+ "figma-demo-stagger": makeTl("figma-demo-stagger"),
478
+ main: makeTl("main"),
479
+ };
480
+ win.__player = { seek: (t: number) => seeks.push(t), getTime: () => 3.5 };
481
+
482
+ resumeGsapTimelines(element);
483
+
484
+ expect(pausedState["figma-demo-unlock"]).toBe(false);
485
+ expect(pausedState["figma-demo-stagger"]).toBe(false);
486
+ // main was NOT paused by the drag — leave its state alone
487
+ expect(pausedState["main"]).toBe(true);
488
+ expect(seeks).toEqual([3.5]);
489
+ expect(element.hasAttribute("data-hf-drag-paused-timelines")).toBe(false);
490
+ });
491
+
492
+ it("is a no-op without the paused-timelines attribute", () => {
493
+ const window = new Window();
494
+ const element = window.document.createElement("div");
495
+ window.document.body.append(element);
496
+ expect(() => resumeGsapTimelines(element)).not.toThrow();
497
+ });
498
+ });
@@ -561,11 +561,23 @@ export function resumeGsapTimelines(element: HTMLElement): void {
561
561
  if (!ids) return;
562
562
  const win = element.ownerDocument.defaultView as
563
563
  | (Window & {
564
- __timelines?: Record<string, { pause?: () => void }>;
564
+ __timelines?: Record<string, { paused?: (value?: boolean) => boolean }>;
565
565
  __player?: { seek?: (t: number) => void; getTime?: () => number };
566
566
  })
567
567
  | null;
568
568
  if (!win) return;
569
+ // Unpause exactly the timelines the drag start paused. The player seek below
570
+ // repositions them, but a seek alone leaves play-state-driven sub-composition
571
+ // timelines paused forever — the "selecting a piece kills its animation" bug:
572
+ // main survives (seek-driven every frame) while every scene timeline freezes,
573
+ // and deselecting can't recover because nothing else ever resumes them.
574
+ for (const id of ids.split(",")) {
575
+ try {
576
+ win.__timelines?.[id]?.paused?.(false);
577
+ } catch {
578
+ /* cross-origin guard */
579
+ }
580
+ }
569
581
  const t = win.__player?.getTime?.() ?? 0;
570
582
  win.__player?.seek?.(t);
571
583
  }
@@ -322,11 +322,19 @@ export function PropertyPanel3dTransform({
322
322
  <button
323
323
  type="button"
324
324
  onClick={() => setCollapsed((v) => !v)}
325
- className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400"
325
+ aria-expanded={!collapsed}
326
+ className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400 active:scale-[0.99]"
326
327
  >
327
328
  <span>3D Transform</span>
328
- <svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
329
- {collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
329
+ <svg
330
+ width="9"
331
+ height="9"
332
+ viewBox="0 0 10 10"
333
+ fill="currentColor"
334
+ aria-hidden
335
+ className={`transition-transform duration-150 ${collapsed ? "-rotate-90" : ""}`}
336
+ >
337
+ <path d="M2 3l3 4 3-4z" />
330
338
  </svg>
331
339
  </button>
332
340
  {collapsed ? null : (