@hyperframes/studio 0.6.86 → 0.6.87

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 (87) hide show
  1. package/dist/assets/index-BA19FAPN.js +143 -0
  2. package/dist/assets/index-CGlIm_-E.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +159 -6
  6. package/src/components/StudioHeader.tsx +20 -7
  7. package/src/components/StudioPreviewArea.tsx +6 -1
  8. package/src/components/StudioRightPanel.tsx +13 -0
  9. package/src/components/StudioToast.tsx +47 -7
  10. package/src/components/TimelineToolbar.tsx +12 -122
  11. package/src/components/editor/AnimationCard.tsx +64 -10
  12. package/src/components/editor/ArcPathControls.tsx +131 -0
  13. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  14. package/src/components/editor/DomEditOverlay.tsx +70 -11
  15. package/src/components/editor/DopesheetStrip.tsx +141 -0
  16. package/src/components/editor/EaseCurveSection.tsx +82 -7
  17. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  19. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  20. package/src/components/editor/LayersPanel.tsx +14 -12
  21. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  22. package/src/components/editor/PropertyPanel.tsx +196 -66
  23. package/src/components/editor/SourceEditor.tsx +0 -1
  24. package/src/components/editor/StaggerControls.tsx +61 -0
  25. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  27. package/src/components/editor/domEditing.test.ts +43 -0
  28. package/src/components/editor/domEditing.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +25 -2
  30. package/src/components/editor/domEditingLayers.test.ts +78 -0
  31. package/src/components/editor/domEditingLayers.ts +33 -13
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/manualEditingAvailability.ts +1 -1
  34. package/src/components/editor/manualEdits.ts +3 -0
  35. package/src/components/editor/manualEditsDom.ts +23 -5
  36. package/src/components/editor/manualOffsetDrag.ts +59 -0
  37. package/src/components/editor/panelTokens.ts +10 -0
  38. package/src/components/editor/propertyPanelColor.tsx +2 -2
  39. package/src/components/editor/propertyPanelFill.tsx +1 -1
  40. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  41. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  42. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  43. package/src/components/editor/propertyPanelSections.tsx +4 -6
  44. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  45. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  46. package/src/components/renders/RenderQueue.tsx +121 -100
  47. package/src/components/renders/RenderQueueItem.tsx +13 -13
  48. package/src/contexts/DomEditContext.tsx +12 -0
  49. package/src/contexts/FileManagerContext.tsx +3 -0
  50. package/src/contexts/StudioContext.tsx +0 -4
  51. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  52. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  53. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  54. package/src/hooks/gsapRuntimePreview.ts +19 -0
  55. package/src/hooks/useAppHotkeys.ts +18 -0
  56. package/src/hooks/useAskAgentModal.ts +2 -4
  57. package/src/hooks/useDomEditCommits.ts +11 -17
  58. package/src/hooks/useDomEditSession.ts +47 -4
  59. package/src/hooks/useEnableKeyframes.ts +171 -0
  60. package/src/hooks/useFileManager.ts +7 -0
  61. package/src/hooks/useGestureRecording.ts +340 -0
  62. package/src/hooks/useGsapScriptCommits.ts +171 -35
  63. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  64. package/src/hooks/useGsapTweenCache.ts +169 -11
  65. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  66. package/src/hooks/useStudioContextValue.ts +5 -4
  67. package/src/hooks/useStudioUrlState.ts +1 -2
  68. package/src/hooks/useTimelineEditing.ts +50 -3
  69. package/src/hooks/useToast.ts +6 -1
  70. package/src/player/components/ShortcutsPanel.tsx +40 -0
  71. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  72. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  73. package/src/player/lib/timelineDOM.test.ts +55 -0
  74. package/src/player/lib/timelineDOM.ts +13 -0
  75. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  76. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  77. package/src/player/store/playerStore.ts +43 -0
  78. package/src/utils/audioBeatDetection.ts +58 -0
  79. package/src/utils/globalTimeCompiler.test.ts +169 -0
  80. package/src/utils/globalTimeCompiler.ts +77 -0
  81. package/src/utils/gsapSoftReload.ts +30 -10
  82. package/src/utils/keyframeSnapping.test.ts +74 -0
  83. package/src/utils/keyframeSnapping.ts +63 -0
  84. package/src/utils/rdpSimplify.ts +183 -0
  85. package/src/utils/sourcePatcher.ts +2 -0
  86. package/dist/assets/index-BT9VHgSy.js +0 -140
  87. package/dist/assets/index-DHcptK1_.css +0 -1
@@ -1156,3 +1156,46 @@ describe("patch builders and prompt builder", () => {
1156
1156
  ).not.toThrow();
1157
1157
  });
1158
1158
  });
1159
+
1160
+ describe("hfId — find, key, capabilities (R7 fixes)", () => {
1161
+ it("getDomEditTargetKey keeps two hfId-only elements distinct", () => {
1162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1163
+ const a = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-aaa" } as any);
1164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1165
+ const b = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-bbb" } as any);
1166
+ expect(a).not.toBe(b);
1167
+ });
1168
+
1169
+ it("findElementForSelection finds element by data-hf-id when no id or selector", () => {
1170
+ const doc = createDocument(`
1171
+ <div data-composition-id="root">
1172
+ <div data-hf-id="hf-xyz789" class="clip" style="position:absolute;left:0;top:0;width:100px;height:100px;"></div>
1173
+ </div>
1174
+ `);
1175
+ const el = doc.querySelector('[data-hf-id="hf-xyz789"]') as HTMLElement;
1176
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1177
+ const found = findElementForSelection(doc, { hfId: "hf-xyz789" } as any);
1178
+ expect(found).toBe(el);
1179
+ });
1180
+
1181
+ it("resolveDomEditCapabilities enables editing for hfId-only element (no CSS selector)", () => {
1182
+ const result = resolveDomEditCapabilities({
1183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1184
+ hfId: "hf-abc" as any,
1185
+ selector: undefined,
1186
+ inlineStyles: { left: "10px", top: "20px", width: "100px", height: "50px" },
1187
+ computedStyles: {
1188
+ position: "absolute",
1189
+ left: "10px",
1190
+ top: "20px",
1191
+ width: "100px",
1192
+ height: "50px",
1193
+ },
1194
+ isCompositionHost: false,
1195
+ isInsideLockedComposition: false,
1196
+ isMasterView: false,
1197
+ });
1198
+ expect(result.canSelect).toBe(true);
1199
+ expect(result.canMove).toBe(true);
1200
+ });
1201
+ });
@@ -26,6 +26,7 @@ export {
26
26
  // Layers, text fields, capabilities, selection, patch ops
27
27
  export {
28
28
  buildDefaultDomEditTextField,
29
+ buildDomEditPatchTarget,
29
30
  buildDomEditStylePatchOperation,
30
31
  buildDomEditTextPatchOperation,
31
32
  collectDomEditLayerItems,
@@ -34,6 +35,7 @@ export {
34
35
  getDomEditNonEditableReason,
35
36
  getDomEditTargetKey,
36
37
  isTextEditableSelection,
38
+ readHfId,
37
39
  refreshDomEditSelection,
38
40
  resolveDomEditCapabilities,
39
41
  resolveDomEditSelection,
@@ -37,9 +37,26 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
37
37
 
38
38
  const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
39
39
 
40
+ function hasVisualPresence(el: HTMLElement): boolean {
41
+ const win = el.ownerDocument.defaultView;
42
+ if (!win) return false;
43
+ const cs = win.getComputedStyle(el);
44
+ if (cs.backgroundImage !== "none") return true;
45
+ if (
46
+ cs.backgroundColor &&
47
+ cs.backgroundColor !== "transparent" &&
48
+ cs.backgroundColor !== "rgba(0, 0, 0, 0)"
49
+ )
50
+ return true;
51
+ if (cs.borderWidth && parseFloat(cs.borderWidth) > 0 && cs.borderStyle !== "none") return true;
52
+ if (cs.boxShadow && cs.boxShadow !== "none") return true;
53
+ return false;
54
+ }
55
+
40
56
  function isEmptyVisualContainer(el: HTMLElement): boolean {
41
57
  const tag = el.tagName.toLowerCase();
42
58
  if (VISUAL_LEAF_TAGS.has(tag)) return false;
59
+ if (hasVisualPresence(el)) return false;
43
60
 
44
61
  const { children } = el;
45
62
  if (children.length === 0) {
@@ -95,7 +112,7 @@ function isInspectableLayerElement(el: HTMLElement): boolean {
95
112
  export function getDomLayerPatchTarget(
96
113
  el: HTMLElement,
97
114
  activeCompositionPath: string | null,
98
- ): Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile"> | null {
115
+ ): Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"> | null {
99
116
  if (!isInspectableLayerElement(el)) return null;
100
117
  if (el.hasAttribute("data-composition-id")) return null;
101
118
 
@@ -105,6 +122,7 @@ export function getDomLayerPatchTarget(
105
122
  const { sourceFile } = getSourceFileForElement(el, activeCompositionPath);
106
123
  return {
107
124
  id: el.id || undefined,
125
+ hfId: el.getAttribute("data-hf-id") || undefined,
108
126
  selector,
109
127
  selectorIndex: getSelectorIndex(
110
128
  el.ownerDocument,
@@ -229,9 +247,14 @@ export function isLargeRasterDomEditSelection(
229
247
 
230
248
  export function findElementForSelection(
231
249
  doc: Document,
232
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
250
+ selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
233
251
  activeCompositionPath: string | null = null,
234
252
  ): HTMLElement | null {
253
+ if (selection.hfId) {
254
+ const byHfId = doc.querySelector(`[data-hf-id="${selection.hfId}"]`);
255
+ if (isHtmlElement(byHfId)) return byHfId;
256
+ }
257
+
235
258
  if (selection.id) {
236
259
  const byId = doc.getElementById(selection.id);
237
260
  if (
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers";
4
+
5
+ const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
6
+
7
+ describe("buildDomEditPatchTarget", () => {
8
+ it("includes hfId when selection has hfId", () => {
9
+ const target = buildDomEditPatchTarget({
10
+ id: undefined,
11
+ hfId: "hf-abc",
12
+ selector: ".foo",
13
+ selectorIndex: 0,
14
+ });
15
+ expect(target.hfId).toBe("hf-abc");
16
+ });
17
+
18
+ it("includes id and selector when hfId absent", () => {
19
+ const target = buildDomEditPatchTarget({
20
+ id: "hero",
21
+ hfId: undefined,
22
+ selector: "#hero",
23
+ selectorIndex: undefined,
24
+ });
25
+ expect(target.id).toBe("hero");
26
+ expect(target.hfId).toBeUndefined();
27
+ });
28
+ });
29
+
30
+ describe("readHfId", () => {
31
+ it("returns the attribute value when present", () => {
32
+ const el = document.createElement("div");
33
+ el.setAttribute("data-hf-id", "hf-abc");
34
+ expect(readHfId(el)).toBe("hf-abc");
35
+ });
36
+
37
+ it("returns undefined when attribute is absent", () => {
38
+ const el = document.createElement("div");
39
+ expect(readHfId(el)).toBeUndefined();
40
+ });
41
+
42
+ it("returns undefined when attribute is empty string", () => {
43
+ const el = document.createElement("div");
44
+ el.setAttribute("data-hf-id", "");
45
+ expect(readHfId(el)).toBeUndefined();
46
+ });
47
+
48
+ it("returns undefined when attribute is whitespace-only", () => {
49
+ const el = document.createElement("div");
50
+ el.setAttribute("data-hf-id", " ");
51
+ expect(readHfId(el)).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe("resolveDomEditSelection — hfId from data-hf-id", () => {
56
+ it("populates hfId from the element data-hf-id attribute", async () => {
57
+ const el = document.createElement("div");
58
+ el.id = "hero";
59
+ el.setAttribute("data-hf-id", "hf-x7k2");
60
+ document.body.appendChild(el);
61
+
62
+ const selection = await resolveDomEditSelection(el, opts);
63
+ document.body.removeChild(el);
64
+
65
+ expect(selection?.hfId).toBe("hf-x7k2");
66
+ });
67
+
68
+ it("leaves hfId undefined when element has no data-hf-id", async () => {
69
+ const el = document.createElement("div");
70
+ el.id = "no-hfid-el";
71
+ document.body.appendChild(el);
72
+
73
+ const selection = await resolveDomEditSelection(el, opts);
74
+ document.body.removeChild(el);
75
+
76
+ expect(selection?.hfId).toBeUndefined();
77
+ });
78
+ });
@@ -173,6 +173,7 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
173
173
  // fallow-ignore-next-line complexity
174
174
  export function resolveDomEditCapabilities(args: {
175
175
  selector?: string;
176
+ hfId?: string;
176
177
  tagName?: string;
177
178
  className?: string;
178
179
  inlineStyles: Record<string, string>;
@@ -182,7 +183,7 @@ export function resolveDomEditCapabilities(args: {
182
183
  isMasterView: boolean;
183
184
  existsInSource?: boolean;
184
185
  }): DomEditCapabilities {
185
- if (!args.selector || args.isInsideLockedComposition) {
186
+ if ((!args.selector && !args.hfId) || args.isInsideLockedComposition) {
186
187
  return {
187
188
  canSelect: !args.isInsideLockedComposition,
188
189
  canEditStyles: false,
@@ -289,7 +290,7 @@ export function buildElementLabel(el: HTMLElement): string {
289
290
  async function probeSourceElement(
290
291
  projectId: string,
291
292
  sourceFile: string,
292
- target: { id?: string; selector?: string; selectorIndex?: number },
293
+ target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
293
294
  ): Promise<boolean> {
294
295
  try {
295
296
  const response = await fetch(
@@ -321,7 +322,8 @@ export async function resolveDomEditSelection(
321
322
  let current: HTMLElement | null = getSelectionCandidate(startEl, options);
322
323
  while (current && current !== doc.body && current !== doc.documentElement) {
323
324
  const selector = buildStableSelector(current);
324
- if (!selector) {
325
+ const hfId = readHfId(current);
326
+ if (!selector && !hfId) {
325
327
  current = current.parentElement;
326
328
  continue;
327
329
  }
@@ -330,13 +332,9 @@ export async function resolveDomEditSelection(
330
332
  current,
331
333
  options.activeCompositionPath,
332
334
  );
333
- const selectorIndex = getSelectorIndex(
334
- doc,
335
- current,
336
- selector,
337
- sourceFile,
338
- options.activeCompositionPath,
339
- );
335
+ const selectorIndex = selector
336
+ ? getSelectorIndex(doc, current, selector, sourceFile, options.activeCompositionPath)
337
+ : undefined;
340
338
  const compositionSrc =
341
339
  current.getAttribute("data-composition-src") ??
342
340
  current.getAttribute("data-composition-file") ??
@@ -346,15 +344,18 @@ export async function resolveDomEditSelection(
346
344
  const textFields = collectDomEditTextFields(current);
347
345
  const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
348
346
  let existsInSource: boolean | undefined;
349
- if (!options.skipSourceProbe && options.projectId && (current.id || selector)) {
350
- const probeTarget: { id?: string; selector?: string; selectorIndex?: number } = {};
347
+ if (!options.skipSourceProbe && options.projectId && (current.id || selector || hfId)) {
348
+ const probeTarget: { id?: string; hfId?: string; selector?: string; selectorIndex?: number } =
349
+ {};
351
350
  if (current.id) probeTarget.id = current.id;
351
+ if (hfId) probeTarget.hfId = hfId;
352
352
  if (selector) probeTarget.selector = selector;
353
353
  if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
354
354
  existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
355
355
  }
356
356
  const capabilities = resolveDomEditCapabilities({
357
357
  selector,
358
+ hfId,
358
359
  tagName: current.tagName.toLowerCase(),
359
360
  className: current.className,
360
361
  inlineStyles,
@@ -369,6 +370,7 @@ export async function resolveDomEditSelection(
369
370
  return {
370
371
  element: current,
371
372
  id: current.id || undefined,
373
+ hfId,
372
374
  selector,
373
375
  selectorIndex,
374
376
  sourceFile,
@@ -451,6 +453,7 @@ export function collectDomEditLayerItems(
451
453
  if (!root) return [];
452
454
 
453
455
  const items: DomEditLayerItem[] = [];
456
+ // fallow-ignore-next-line complexity
454
457
  const visit = (el: HTMLElement, depth: number) => {
455
458
  if (items.length >= maxItems) return;
456
459
 
@@ -464,6 +467,7 @@ export function collectDomEditLayerItems(
464
467
  depth,
465
468
  childCount: getDirectLayerChildren(el, options).length,
466
469
  id: target.id ?? undefined,
470
+ hfId: target.hfId ?? undefined,
467
471
  selector: target.selector ?? undefined,
468
472
  selectorIndex: target.selectorIndex,
469
473
  sourceFile: target.sourceFile,
@@ -535,10 +539,11 @@ export function getDomEditNonEditableReason(
535
539
  }
536
540
 
537
541
  export function getDomEditTargetKey(
538
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
542
+ selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
539
543
  ): string {
540
544
  return [
541
545
  selection.sourceFile || "index.html",
546
+ selection.hfId ?? "",
542
547
  selection.id ?? "",
543
548
  selection.selector ?? "",
544
549
  selection.selectorIndex ?? "",
@@ -554,3 +559,18 @@ export function isTextEditableSelection(selection: DomEditSelection): boolean {
554
559
  }
555
560
 
556
561
  // buildElementAgentPrompt is in domEditingAgentPrompt.ts
562
+
563
+ export function readHfId(element: Element): string | undefined {
564
+ return element.getAttribute("data-hf-id")?.trim() || undefined;
565
+ }
566
+
567
+ export function buildDomEditPatchTarget(
568
+ selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex">,
569
+ ): { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number } {
570
+ return {
571
+ id: selection.id,
572
+ hfId: selection.hfId,
573
+ selector: selection.selector,
574
+ selectorIndex: selection.selectorIndex,
575
+ };
576
+ }
@@ -98,6 +98,7 @@ export interface DomEditLayerItem {
98
98
  depth: number;
99
99
  childCount: number;
100
100
  id?: string;
101
+ hfId?: string;
101
102
  selector?: string;
102
103
  selectorIndex?: number;
103
104
  sourceFile: string;
@@ -74,7 +74,7 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
74
74
  export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
75
75
  env,
76
76
  ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
77
- false,
77
+ true,
78
78
  );
79
79
 
80
80
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
@@ -240,6 +240,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
240
240
  "renderSeek",
241
241
  );
242
242
  const wrappedTimelineSeek = wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "seek");
243
+ wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "totalTime");
243
244
  const wrappedPlayerPlay = wrapPlayReapplyFunction(studioWin, studioWin.__player, "play");
244
245
  const wrappedTimelinePlay = wrapPlayReapplyFunction(studioWin, studioWin.__timeline, "play");
245
246
  const wrappedPlayerPause = wrapApplyAfterFunction(studioWin, studioWin.__player, "pause");
@@ -250,6 +251,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
250
251
  for (const timeline of Object.values(studioWin.__timelines ?? {})) {
251
252
  wrappedNamedTimelineSeek =
252
253
  wrapSeekReapplyFunction(studioWin, timeline, "seek") || wrappedNamedTimelineSeek;
254
+ wrapSeekReapplyFunction(studioWin, timeline, "totalTime");
253
255
  wrappedNamedTimelinePlay =
254
256
  wrapPlayReapplyFunction(studioWin, timeline, "play") || wrappedNamedTimelinePlay;
255
257
  wrappedNamedTimelinePause =
@@ -268,6 +270,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
268
270
  if (typeof value === "object" && value !== null) {
269
271
  const tl = value as Record<string, unknown>;
270
272
  wrapSeekReapplyFunction(studioWin, tl, "seek");
273
+ wrapSeekReapplyFunction(studioWin, tl, "totalTime");
271
274
  wrapPlayReapplyFunction(studioWin, tl, "play");
272
275
  wrapApplyAfterFunction(studioWin, tl, "pause");
273
276
  studioWin.__hfStudioManualEditsApply?.();
@@ -273,11 +273,25 @@ export function applyStudioPathOffsetDraft(
273
273
  ): void {
274
274
  promoteInlineForTransform(element);
275
275
  writeStudioPathOffsetVars(element, offset, { updateBase: false });
276
- element.style.setProperty(
277
- "translate",
278
- composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
279
- );
280
- stripGsapTranslateFromTransform(element);
276
+
277
+ const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
278
+ if (isGsapAnimated) {
279
+ // For GSAP-animated elements: use gsap.set for positioning (the timeline
280
+ // is paused during drag). Set translate:none explicitly to prevent
281
+ // double-counting with the transform.
282
+ element.style.setProperty("translate", "none");
283
+ const win = element.ownerDocument.defaultView as
284
+ | (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
285
+ | null;
286
+ win?.gsap?.set(element, { x: offset.x, y: offset.y });
287
+ } else {
288
+ // Non-GSAP elements: use CSS translate as before.
289
+ element.style.setProperty(
290
+ "translate",
291
+ composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
292
+ );
293
+ stripGsapTranslateFromTransform(element);
294
+ }
281
295
  }
282
296
 
283
297
  /* ── Box size apply ───────────────────────────────────────────────── */
@@ -505,6 +519,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
505
519
 
506
520
  function reapplyPathOffsets(doc: Document): void {
507
521
  for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
522
+ // Skip elements where GSAP actively animates position — GSAP bakes the
523
+ // CSS translate into its transform and sets translate: none every tick.
524
+ // Stripping/restoring would oscillate against GSAP's rendering.
525
+ if (gsapAnimatesProperty(el, "x", "y")) continue;
508
526
  const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
509
527
  const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
510
528
  if (x || y) {
@@ -232,6 +232,41 @@ export function createManualOffsetDragMember(input: {
232
232
  rect: ManualOffsetDragRect;
233
233
  }): ManualOffsetDragMemberResult {
234
234
  const initialOffset = readStudioPathOffset(input.element);
235
+ input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
236
+ input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
237
+
238
+ // Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path
239
+ // needs the original (uncorrupted) GSAP position to compute the new keyframe value.
240
+ const win = input.element.ownerDocument.defaultView as
241
+ | (Window & {
242
+ gsap?: { getProperty?: (el: Element, prop: string) => number };
243
+ __timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
244
+ })
245
+ | null;
246
+ const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
247
+ const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
248
+ input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
249
+ input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
250
+
251
+ // Pause GSAP timelines during drag to prevent the tween from overwriting
252
+ // the draft's gsap.set on every tick. Track which we paused to resume later.
253
+ if (win?.__timelines) {
254
+ const paused: string[] = [];
255
+ for (const [id, tl] of Object.entries(win.__timelines)) {
256
+ try {
257
+ if (tl?.pause && !tl.paused?.()) {
258
+ tl.pause();
259
+ paused.push(id);
260
+ }
261
+ } catch {
262
+ /* cross-origin guard */
263
+ }
264
+ }
265
+ if (paused.length > 0) {
266
+ input.element.setAttribute("data-hf-drag-paused-timelines", paused.join(","));
267
+ }
268
+ }
269
+
235
270
  const initialPathOffset = captureStudioPathOffset(input.element);
236
271
  const gestureToken = beginStudioManualEditGesture(input.element);
237
272
  const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
@@ -313,11 +348,35 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
313
348
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
314
349
  for (const member of members) {
315
350
  restoreManualOffsetDragMember(member);
351
+ resumeGsapTimelines(member.element);
316
352
  }
317
353
  }
318
354
 
319
355
  export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
320
356
  for (const member of members) {
321
357
  endStudioManualEditGesture(member.element, member.gestureToken);
358
+ member.element.removeAttribute("data-hf-drag-initial-offset-x");
359
+ member.element.removeAttribute("data-hf-drag-initial-offset-y");
360
+ member.element.removeAttribute("data-hf-drag-gsap-base-x");
361
+ member.element.removeAttribute("data-hf-drag-gsap-base-y");
362
+ resumeGsapTimelines(member.element);
322
363
  }
323
364
  }
365
+
366
+ function resumeGsapTimelines(element: HTMLElement): void {
367
+ const ids = element.getAttribute("data-hf-drag-paused-timelines");
368
+ element.removeAttribute("data-hf-drag-paused-timelines");
369
+ if (!ids) return;
370
+ const win = element.ownerDocument.defaultView as
371
+ | (Window & {
372
+ __timelines?: Record<string, { pause?: () => void }>;
373
+ __player?: { seek?: (t: number) => void; getTime?: () => number };
374
+ })
375
+ | null;
376
+ if (!win) return;
377
+ // Re-seek to the current time to restore the paused timeline's render state.
378
+ // play() would start playback; pause() already stops. Seek re-renders at the
379
+ // current position without starting playback.
380
+ const t = win.__player?.getTime?.() ?? 0;
381
+ win.__player?.seek?.(t);
382
+ }
@@ -0,0 +1,10 @@
1
+ // ── Design Panel Tokens (for inline style={{}} usage) ──────────────────
2
+ // Tailwind classes use `panel-*` from tailwind.config.js theme.extend.colors.
3
+ // This file provides the same values for inline styles where Tailwind can't reach.
4
+
5
+ export const P = {
6
+ accent: "#3CE6AC",
7
+ borderInput: "#27272A",
8
+ textMuted: "#52525B",
9
+ white: "#FAFAFA",
10
+ } as const;
@@ -71,7 +71,7 @@ function ColorSlider({
71
71
  aria-valuemax={max}
72
72
  aria-valuenow={value}
73
73
  aria-disabled={disabled}
74
- className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-[#f5a400] focus:ring-2 focus:ring-[#f5a400]/40 ${
74
+ className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-panel-accent focus:ring-2 focus:ring-panel-accent/40 ${
75
75
  disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
76
76
  }`}
77
77
  style={{ background }}
@@ -294,7 +294,7 @@ export function ColorField({
294
294
  <div className="truncate text-[11px] font-medium text-neutral-100">
295
295
  {currentColor}
296
296
  </div>
297
- <div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
297
+ <div className="mt-0.5 text-[9px] text-neutral-600">
298
298
  S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
299
299
  </div>
300
300
  </div>
@@ -278,7 +278,7 @@ export function GradientField({
278
278
  checked={parsed.repeating}
279
279
  disabled={disabled}
280
280
  onChange={(e) => patch({ repeating: e.target.checked })}
281
- className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
281
+ className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-panel-accent focus:ring-panel-accent"
282
282
  />
283
283
  Repeat
284
284
  </label>
@@ -41,6 +41,19 @@ export interface PropertyPanelProps {
41
41
  onAddGsapFromProperty?: (animId: string, prop: string) => void;
42
42
  onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
43
43
  onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
44
+ onSetArcPath?: (
45
+ animId: string,
46
+ config: {
47
+ enabled: boolean;
48
+ autoRotate?: boolean | number;
49
+ segments?: import("@hyperframes/core/gsap-parser").ArcPathSegment[];
50
+ },
51
+ ) => void;
52
+ onUpdateArcSegment?: (
53
+ animId: string,
54
+ segmentIndex: number,
55
+ update: Partial<import("@hyperframes/core/gsap-parser").ArcPathSegment>,
56
+ ) => void;
44
57
  onAddKeyframe?: (
45
58
  animationId: string,
46
59
  percentage: number,
@@ -55,6 +68,9 @@ export interface PropertyPanelProps {
55
68
  value: number | string,
56
69
  ) => Promise<void>;
57
70
  onSeekToTime?: (time: number) => void;
71
+ recordingState?: "idle" | "recording" | "preview";
72
+ recordingDuration?: number;
73
+ onToggleRecording?: () => void;
58
74
  }
59
75
 
60
76
  /* ------------------------------------------------------------------ */
@@ -184,8 +200,8 @@ function fontSourceRank(source: FontSource): number {
184
200
  /* ------------------------------------------------------------------ */
185
201
 
186
202
  export const FIELD =
187
- "min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
188
- export const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
203
+ "min-w-0 rounded-md bg-panel-input px-3 py-[7px] text-panel-text-1 transition-colors focus-within:ring-1 focus-within:ring-panel-accent/30";
204
+ export const LABEL = "text-[11px] font-medium text-panel-text-3";
189
205
  export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
190
206
  export const EMPTY_STYLES: Record<string, string> = {};
191
207
 
@@ -76,7 +76,7 @@ export function MediaSection({
76
76
  {srcAttr && (
77
77
  <div className="min-w-0">
78
78
  <div className="flex items-center justify-between gap-2">
79
- <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">Source</div>
79
+ <div className="text-[11px] font-medium text-neutral-500">Source</div>
80
80
  <button
81
81
  type="button"
82
82
  onClick={() => {