@hyperframes/studio 0.7.78 → 0.7.80

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 (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -26,7 +26,6 @@ describe("withTrackedGsapAnimationCallbacks", () => {
26
26
  const onLivePreviewEnd = vi.fn();
27
27
  callbacks.onLivePreview = onLivePreview;
28
28
  callbacks.onLivePreviewEnd = onLivePreviewEnd;
29
-
30
29
  const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
31
30
 
32
31
  expect(tracked.onUpdateFromProperty).toBeUndefined();
@@ -37,6 +36,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
37
36
  expect(tracked.onUpdateKeyframeEase).toBeUndefined();
38
37
  expect(tracked.onSetAllKeyframeEases).toBeUndefined();
39
38
  expect(tracked.onUnroll).toBeUndefined();
39
+ expect(tracked.onUpdateSegmentEase).toBeUndefined();
40
40
  expect(tracked.onLivePreview).toBe(onLivePreview);
41
41
  expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
42
42
  });
@@ -57,6 +57,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
57
57
  onUpdateArcSegment: mutation("arc-segment"),
58
58
  onUpdateKeyframeEase: mutation("keyframe-ease"),
59
59
  onSetAllKeyframeEases: mutation("all-eases"),
60
+ onUpdateSegmentEase: mutation("segment-ease"),
60
61
  onUnroll: mutation("unroll"),
61
62
  };
62
63
  const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
@@ -79,6 +80,10 @@ describe("withTrackedGsapAnimationCallbacks", () => {
79
80
  requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
80
81
  requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
81
82
  requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
83
+ requireCallback(tracked.onUpdateSegmentEase)(
84
+ [{ animationId: "a1", tweenPercentage: 50 }],
85
+ "none",
86
+ );
82
87
  requireCallback(tracked.onUnroll)("a1");
83
88
 
84
89
  expect(events).toEqual([
@@ -115,6 +120,8 @@ describe("withTrackedGsapAnimationCallbacks", () => {
115
120
  "mutate:keyframe-ease",
116
121
  "track:select:All keyframe eases",
117
122
  "mutate:all-eases",
123
+ "track:select:Segment ease",
124
+ "mutate:segment-ease",
118
125
  "track:button:Unroll animation",
119
126
  "mutate:unroll",
120
127
  ]);
@@ -1,5 +1,6 @@
1
1
  import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
2
2
  import { usePlayerStore } from "../../player";
3
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
3
4
 
4
5
  /**
5
6
  * Edit callbacks shared by GsapAnimationSection and each AnimationCard it
@@ -30,6 +31,7 @@ export interface GsapAnimationEditCallbacks {
30
31
  update: Partial<ArcPathSegment>,
31
32
  ) => void;
32
33
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
34
+ onUpdateSegmentEase?: (targets: AnimationKeyframeTarget[], ease: string) => void;
33
35
  /** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
34
36
  onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
35
37
  /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
@@ -122,6 +124,12 @@ export function withTrackedGsapAnimationCallbacks(
122
124
  : undefined,
123
125
  onLivePreview: callbacks.onLivePreview,
124
126
  onLivePreviewEnd: callbacks.onLivePreviewEnd,
127
+ onUpdateSegmentEase: callbacks.onUpdateSegmentEase
128
+ ? (targets, ease) => {
129
+ track("select", "Segment ease");
130
+ callbacks.onUpdateSegmentEase?.(targets, ease);
131
+ }
132
+ : undefined,
125
133
  onSetArcPath: callbacks.onSetArcPath
126
134
  ? (animationId, config) => {
127
135
  track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
@@ -0,0 +1,37 @@
1
+ // Real gsap, not a parseEase stub: this pins the seek behaviour of the `hold`
2
+ // ease against the engine that actually runs it, and gsap is a studio
3
+ // dependency. core's own customEase.test.ts covers the resolver in isolation.
4
+ import { installStudioCustomEase } from "@hyperframes/core/runtime/custom-ease";
5
+ import { gsap } from "gsap";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ describe("Studio hold ease", () => {
9
+ it("holds the start value under seek until the destination time", () => {
10
+ const runtimeGsap = { parseEase: gsap.parseEase.bind(gsap) };
11
+ expect(installStudioCustomEase(runtimeGsap)).toBe(true);
12
+ const hold = runtimeGsap.parseEase("hold");
13
+ expect(hold).toBeTypeOf("function");
14
+ if (typeof hold !== "function") return;
15
+
16
+ const target = { value: 0 };
17
+ const timeline = gsap.timeline({ paused: true }).to(
18
+ target,
19
+ {
20
+ value: 100,
21
+ duration: 2,
22
+ ease: hold,
23
+ },
24
+ 0,
25
+ );
26
+
27
+ timeline.seek(0.5);
28
+ expect(target.value).toBe(0);
29
+ timeline.seek(1);
30
+ expect(target.value).toBe(0);
31
+ timeline.seek(1.99);
32
+ expect(target.value).toBe(0);
33
+ timeline.seek(2);
34
+ expect(target.value).toBe(100);
35
+ timeline.kill();
36
+ });
37
+ });
@@ -180,3 +180,45 @@ describe("resolveKeyframeRetime — guards", () => {
180
180
  expect(r.pctRemap).toEqual([]);
181
181
  });
182
182
  });
183
+
184
+ describe("resolveKeyframeRetime — move percentages are rounded like the resize path", () => {
185
+ // The move branch used to return the raw quotient, so `74.81203007518799%`
186
+ // landed in the user's source and churned the diff on every drag.
187
+ const decimals = (n: number): number => String(n).split(".")[1]?.length ?? 0;
188
+
189
+ it("rounds a repeating quotient to 3dp", () => {
190
+ const r = resolveKeyframeRetime({
191
+ tweenStart: 2,
192
+ tweenDuration: 3,
193
+ keyframes: KEYFRAMES,
194
+ draggedTweenPct: 0,
195
+ dropAbsTime: 3, // (3-2)/3 = 33.333333333333336%
196
+ });
197
+ expect(r.kind).toBe("move");
198
+ expect(r.toTweenPct).toBe(33.333);
199
+ });
200
+
201
+ it("never emits more than 3 decimal places", () => {
202
+ for (const tweenDuration of [3, 7, 9, 11, 133]) {
203
+ const r = resolveKeyframeRetime({
204
+ tweenStart: 2,
205
+ tweenDuration,
206
+ keyframes: KEYFRAMES,
207
+ draggedTweenPct: 0,
208
+ dropAbsTime: 3,
209
+ });
210
+ expect(r.kind).toBe("move");
211
+ expect(decimals(r.toTweenPct ?? 0)).toBeLessThanOrEqual(3);
212
+ }
213
+ });
214
+
215
+ it("leaves an already-short percentage untouched", () => {
216
+ const r = resolveKeyframeRetime({
217
+ ...WINDOW,
218
+ keyframes: KEYFRAMES,
219
+ draggedTweenPct: 0,
220
+ dropAbsTime: 3, // (3-2)/4 = exactly 25%
221
+ });
222
+ expect(r.toTweenPct).toBe(25);
223
+ });
224
+ });
@@ -121,7 +121,10 @@ export function resolveKeyframeRetime(opts: {
121
121
 
122
122
  // Within the tween window → plain move (re-key the tween-%).
123
123
  if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
124
- const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
124
+ // Round here, not at the return: the no-op test below and the value written
125
+ // to source must be the same number. The resize branch already rounds, so
126
+ // this keeps both write paths at the authored 3dp precision.
127
+ const toTweenPct = round3(clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100));
125
128
  if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
126
129
  return { kind: "move", toTweenPct };
127
130
  }
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import type { DomEditSelection } from "./domEditingTypes";
4
+ import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
5
+ import { selectorFor } from "./motionPathSelection";
6
+
7
+ afterEach(() => {
8
+ document.body.innerHTML = "";
9
+ });
10
+
11
+ function selectionFor(el: HTMLElement): DomEditSelection {
12
+ const selector = buildStableSelector(el);
13
+ return {
14
+ element: el,
15
+ id: el.id || undefined,
16
+ hfId: el.getAttribute("data-hf-id") || undefined,
17
+ selector,
18
+ selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
19
+ sourceFile: "index.html",
20
+ dataAttributes: { start: "0", duration: "2" },
21
+ } as unknown as DomEditSelection;
22
+ }
23
+
24
+ function mountGroupSiblings(): HTMLElement[] {
25
+ document.body.innerHTML = `
26
+ <div id="scene" class="clip" data-start="0" data-duration="2">
27
+ <div class="group"></div>
28
+ <div class="group"></div>
29
+ <div class="group"></div>
30
+ </div>
31
+ `;
32
+ return Array.from(document.querySelectorAll<HTMLElement>(".group"));
33
+ }
34
+
35
+ describe("selectorFor", () => {
36
+ it("addresses one element for a class-only sibling", () => {
37
+ const groups = mountGroupSiblings();
38
+ const selector = selectorFor(selectionFor(groups[2]!));
39
+
40
+ // The bare ".group" both measured home off the FIRST sibling and wrote the
41
+ // new motion path onto all three.
42
+ expect(selector).not.toBe(".group");
43
+ expect(document.querySelectorAll(selector!)).toHaveLength(1);
44
+ expect(document.querySelector(selector!)).toBe(groups[2]);
45
+ });
46
+
47
+ it("keeps a unique id target", () => {
48
+ document.body.innerHTML = `<div id="hero"></div>`;
49
+ const el = document.querySelector<HTMLElement>("#hero")!;
50
+
51
+ expect(selectorFor(selectionFor(el))).toBe("#hero");
52
+ });
53
+
54
+ it("returns null with no selection", () => {
55
+ expect(selectorFor(null)).toBeNull();
56
+ });
57
+
58
+ it("returns null when no rung addresses one element", () => {
59
+ const groups = mountGroupSiblings();
60
+ const selection = selectionFor(groups[1]!);
61
+ groups[1]!.remove();
62
+
63
+ expect(selectorFor(selection)).toBeNull();
64
+ });
65
+ });
@@ -5,11 +5,21 @@
5
5
  */
6
6
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
7
7
  import type { DomEditSelection } from "./domEditing";
8
+ import { writeTargetSelector } from "../../hooks/gsapShared";
8
9
 
10
+ /**
11
+ * The selector the overlay both MEASURES the element by and authors a new
12
+ * motion path against.
13
+ *
14
+ * Both halves need exactly one element. The selection's own selector is a bare
15
+ * class for an id-less element, so a `.group` sibling read its home position off
16
+ * the FIRST sibling (skewing the destination the click computes) and then wrote
17
+ * `add-motion-path` onto all five. `writeTargetSelector` is the same one-element
18
+ * narrowing every other new-tween writer goes through; null means no such form
19
+ * exists, and the overlay hides "set destination" rather than write a wrong one.
20
+ */
9
21
  export function selectorFor(sel: DomEditSelection | null): string | null {
10
- if (!sel) return null;
11
- if (sel.id) return `#${CSS.escape(sel.id)}`;
12
- return sel.selector ?? null;
22
+ return sel ? writeTargetSelector(sel) : null;
13
23
  }
14
24
 
15
25
  /** The animation whose path is editable on-canvas: literal, statically resolved,
@@ -1,43 +1,85 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
6
7
  import { ColorField } from "./propertyPanelColor";
7
8
 
9
+ const trackStudioEvent = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock("../../utils/studioTelemetry", () => ({
12
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
13
+ }));
14
+
8
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
16
 
17
+ const roots: Root[] = [];
18
+
19
+ beforeEach(() => {
20
+ trackStudioEvent.mockReset();
21
+ __resetDesignInputThrottle();
22
+ });
23
+
10
24
  afterEach(() => {
25
+ for (const root of roots) act(() => root.unmount());
26
+ roots.length = 0;
11
27
  document.body.innerHTML = "";
12
28
  });
13
29
 
14
- function renderColorField(onCommit: (value: string) => void): void {
30
+ function renderColorField({
31
+ value = "#333333",
32
+ onPreview,
33
+ onCommit = vi.fn(),
34
+ }: {
35
+ value?: string;
36
+ onPreview?: (value: string) => void;
37
+ onCommit?: (value: string) => void;
38
+ } = {}): HTMLElement {
15
39
  const host = document.createElement("div");
16
40
  document.body.append(host);
17
41
  const root = createRoot(host);
42
+ roots.push(root);
18
43
  act(() => {
19
- root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
44
+ root.render(
45
+ <ColorField flat label="Color" value={value} onPreview={onPreview} onCommit={onCommit} />,
46
+ );
20
47
  });
48
+ return host;
49
+ }
50
+
51
+ function changeInput(input: HTMLInputElement, value: string): void {
52
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
53
+ if (!setter) throw new Error("expected native input value setter");
54
+ setter.call(input, value);
55
+ input.dispatchEvent(new Event("input", { bubbles: true }));
56
+ }
57
+
58
+ function openHexInput(host: HTMLElement): HTMLInputElement {
59
+ const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
60
+ if (!trigger) throw new Error("Color trigger was not rendered");
61
+ act(() => trigger.click());
62
+ const input = document.querySelector<HTMLInputElement>('input[spellcheck="false"]');
63
+ if (!input) throw new Error("Hex input was not rendered");
64
+ return input;
65
+ }
66
+
67
+ function clickOutside(): void {
68
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
21
69
  }
22
70
 
23
71
  describe("ColorField flat trigger", () => {
24
72
  it("renders label and value inline with a small swatch, no boxed border", () => {
25
- const host = document.createElement("div");
26
- document.body.append(host);
27
- const root = createRoot(host);
28
- act(() => {
29
- root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={vi.fn()} />);
30
- });
73
+ const host = renderColorField({ value: "rgb(255, 176, 32)" });
31
74
  const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
32
75
  expect(trigger).not.toBeNull();
33
76
  expect(trigger?.className).not.toContain("border-neutral-800");
34
77
  expect(host.textContent).toContain("Color");
35
- act(() => root.unmount());
36
78
  });
37
79
 
38
80
  it("persists one keyboard slider gesture on keyup", () => {
39
81
  const onCommit = vi.fn();
40
- renderColorField(onCommit);
82
+ renderColorField({ value: "rgb(255, 176, 32)", onCommit });
41
83
  const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
42
84
  if (!trigger) throw new Error("Color trigger was not rendered");
43
85
  act(() => {
@@ -57,3 +99,130 @@ describe("ColorField flat trigger", () => {
57
99
  expect(onCommit).toHaveBeenCalledOnce();
58
100
  });
59
101
  });
102
+
103
+ describe("ColorField hex editing", () => {
104
+ it("allows #333333 to be backspaced to #3 without snapping", () => {
105
+ const input = openHexInput(renderColorField());
106
+
107
+ for (const value of ["#33333", "#3333", "#333", "#33", "#3"]) {
108
+ act(() => changeInput(input, value));
109
+ expect(input.value).toBe(value);
110
+ }
111
+ });
112
+
113
+ it("does not silently change #22CC66 to #2222CC while backspacing", () => {
114
+ const input = openHexInput(renderColorField({ value: "#22CC66" }));
115
+
116
+ for (const value of ["#22CC6", "#22CC", "#22C"]) {
117
+ act(() => changeInput(input, value));
118
+ expect(input.value).toBe(value);
119
+ expect(input.value).not.toBe("#2222CC");
120
+ }
121
+ });
122
+
123
+ it("commits a full replacement after selecting the existing value", () => {
124
+ const onCommit = vi.fn();
125
+ const input = openHexInput(renderColorField({ onCommit }));
126
+ input.focus();
127
+ input.select();
128
+
129
+ act(() => changeInput(input, "#12AB34"));
130
+ act(() => input.blur());
131
+
132
+ expect(onCommit).toHaveBeenCalledOnce();
133
+ expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
134
+ });
135
+
136
+ it("commits a complete pending hex on outside-click", () => {
137
+ const onCommit = vi.fn();
138
+ const input = openHexInput(renderColorField({ onCommit }));
139
+
140
+ act(() => changeInput(input, "#12AB34"));
141
+ act(clickOutside);
142
+
143
+ expect(onCommit).toHaveBeenCalledOnce();
144
+ expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
145
+ });
146
+
147
+ it("commits a 3-digit hex shorthand on outside-click", () => {
148
+ // parseCssColor accepts shorthand, so the gesture resolver has to as well;
149
+ // #F00 is ordinary designer input and used to be dropped in silence.
150
+ const onCommit = vi.fn();
151
+ const input = openHexInput(renderColorField({ onCommit }));
152
+
153
+ act(() => changeInput(input, "#F00"));
154
+ act(clickOutside);
155
+
156
+ expect(onCommit).toHaveBeenCalledOnce();
157
+ expect(onCommit).toHaveBeenCalledWith("rgb(255, 0, 0)");
158
+ });
159
+
160
+ it("does not commit an incomplete pending hex on outside-click", () => {
161
+ const onCommit = vi.fn();
162
+ const host = renderColorField({ value: "#224466", onCommit });
163
+ const input = openHexInput(host);
164
+
165
+ act(() => changeInput(input, "#12AB3"));
166
+ act(clickOutside);
167
+
168
+ expect(onCommit).not.toHaveBeenCalled();
169
+ // The settle also has to put the field back, or the panel re-opens showing
170
+ // a value the composition never took.
171
+ expect(openHexInput(host).value).toBe("#224466");
172
+ });
173
+
174
+ it("cancels a pending hex edit on Escape and restores the previous value", () => {
175
+ const onPreview = vi.fn();
176
+ const onCommit = vi.fn();
177
+ const host = renderColorField({ value: "#224466", onPreview, onCommit });
178
+ const input = openHexInput(host);
179
+
180
+ act(() => changeInput(input, "#12AB34"));
181
+ act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
182
+
183
+ expect(onCommit).not.toHaveBeenCalled();
184
+ expect(onPreview).toHaveBeenLastCalledWith("rgb(34, 68, 102)");
185
+ expect(openHexInput(host).value).toBe("#224466");
186
+ });
187
+
188
+ it("still commits a complete hex on Tab-blur", () => {
189
+ const onCommit = vi.fn();
190
+ const input = openHexInput(renderColorField({ onCommit }));
191
+ input.focus();
192
+
193
+ act(() => changeInput(input, "#12AB34"));
194
+ act(() => input.blur());
195
+
196
+ expect(onCommit).toHaveBeenCalledOnce();
197
+ expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
198
+ });
199
+
200
+ it("live-previews only a hex length that parses, 3 or 6 digits", () => {
201
+ const onPreview = vi.fn();
202
+ const input = openHexInput(renderColorField({ value: "#112233", onPreview }));
203
+
204
+ // 4 and 5 digits are mid-typing, so they must stay silent.
205
+ act(() => changeInput(input, "#3333"));
206
+ act(() => changeInput(input, "#33333"));
207
+ expect(onPreview).not.toHaveBeenCalled();
208
+
209
+ act(() => changeInput(input, "#333333"));
210
+ expect(onPreview).toHaveBeenCalledOnce();
211
+ expect(onPreview).toHaveBeenCalledWith("rgb(51, 51, 51)");
212
+
213
+ act(() => changeInput(input, "#333"));
214
+ expect(onPreview).toHaveBeenCalledTimes(2);
215
+ expect(onPreview).toHaveBeenLastCalledWith("rgb(51, 51, 51)");
216
+ });
217
+
218
+ it("tracks exactly once per completed edit, not once per keystroke", () => {
219
+ const input = openHexInput(renderColorField());
220
+
221
+ for (const value of ["#", "#1", "#12", "#12A", "#12AB", "#12AB3", "#12AB34"]) {
222
+ act(() => changeInput(input, value));
223
+ }
224
+ act(clickOutside);
225
+
226
+ expect(trackStudioEvent).toHaveBeenCalledOnce();
227
+ });
228
+ });
@@ -184,11 +184,27 @@ export function ColorField({
184
184
  const brightnessPercent = Math.round(hsv.value * 100);
185
185
  const alphaPercent = Math.round(draftColor.alpha * 100);
186
186
 
187
- const updateColorDraft = useCallback((nextValue: string) => {
187
+ const updateColorDraft = useCallback((nextValue: string, source: "hex" | "picker") => {
188
188
  const nextColor = parseCssColor(nextValue);
189
189
  if (!nextColor) return;
190
190
  setDraftColor(nextColor);
191
- setHexDraft(toHexColor(nextColor).toUpperCase());
191
+ if (source === "picker") setHexDraft(toHexColor(nextColor).toUpperCase());
192
+ }, []);
193
+ const resolveColorGestureValue = useCallback((nextValue: string) => {
194
+ const source = nextValue.startsWith("#") ? "hex" : "picker";
195
+ // Only a COMPLETE hex resolves, so a half-typed one neither previews nor
196
+ // commits. Both lengths parseCssColor accepts count as complete: gating on
197
+ // 6 alone silently dropped #F00 and friends, which the old onBlur committed.
198
+ if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
199
+ const nextColor = parseCssColor(nextValue);
200
+ if (!nextColor) return null;
201
+ return {
202
+ source,
203
+ value: formatCssColor({
204
+ ...nextColor,
205
+ alpha: source === "hex" ? draftColorRef.current.alpha : nextColor.alpha,
206
+ }),
207
+ } as const;
192
208
  }, []);
193
209
  const persistColorValue = useCallback(
194
210
  (nextValue: string) => {
@@ -203,12 +219,17 @@ export function ColorField({
203
219
  settle: settleColorGesture,
204
220
  cancel: cancelColorGesture,
205
221
  } = useInspectorGestureTransaction({
206
- sourceValue: value,
222
+ sourceValue: formatCssColor(colorFromCss(value)),
207
223
  onPreview: (nextValue) => {
208
- updateColorDraft(nextValue);
209
- onPreview?.(nextValue);
224
+ const resolved = resolveColorGestureValue(nextValue);
225
+ if (!resolved) return;
226
+ updateColorDraft(resolved.value, resolved.source);
227
+ onPreview?.(resolved.value);
228
+ },
229
+ onCommit: (nextValue) => {
230
+ const resolved = resolveColorGestureValue(nextValue);
231
+ if (resolved) persistColorValue(resolved.value);
210
232
  },
211
- onCommit: persistColorValue,
212
233
  });
213
234
 
214
235
  useEffect(() => {
@@ -288,13 +309,11 @@ export function ColorField({
288
309
  commitHsv({ saturation, value: nextValue });
289
310
  };
290
311
 
291
- const handleHexCommit = (nextHex: string) => {
312
+ const handleHexChange = (nextHex: string) => {
292
313
  setHexDraft(nextHex);
293
314
  const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
294
- const parsed = parseCssColor(normalized);
295
- if (!parsed) return;
296
- const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
297
- updateColorDraft(nextValue);
315
+ beginColorGesture();
316
+ previewColorGesture(normalized);
298
317
  };
299
318
 
300
319
  const picker = open
@@ -413,21 +432,8 @@ export function ColorField({
413
432
  <span className={LABEL}>Hex</span>
414
433
  <input
415
434
  value={hexDraft}
416
- onChange={(event) => handleHexCommit(event.target.value)}
417
- onBlur={() => {
418
- const normalized = hexDraft.trim().startsWith("#")
419
- ? hexDraft.trim()
420
- : `#${hexDraft.trim()}`;
421
- const parsed = parseCssColor(normalized);
422
- if (parsed) {
423
- const nextValue = formatCssColor({
424
- ...parsed,
425
- alpha: draftColorRef.current.alpha,
426
- });
427
- persistColorValue(nextValue);
428
- }
429
- setHexDraft(toHexColor(draftColorRef.current).toUpperCase());
430
- }}
435
+ onChange={(event) => handleHexChange(event.target.value)}
436
+ onBlur={settleColorGesture}
431
437
  className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
432
438
  spellCheck={false}
433
439
  />
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection";
7
7
  import type { DomEditSelection } from "./domEditing";
8
+ import { usePlayerStore } from "../../player";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
11
12
  afterEach(() => {
12
13
  document.body.innerHTML = "";
14
+ // The store is module-global, so a test that parks a focused ease segment
15
+ // would otherwise leak it into every test that runs after it.
16
+ usePlayerStore.getState().reset();
13
17
  });
14
18
 
15
19
  function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
@@ -270,3 +274,68 @@ describe("FlatMotionSection", () => {
270
274
  act(() => root.unmount());
271
275
  });
272
276
  });
277
+
278
+ it("forwards focused bulk segment easing through the flat animation card", () => {
279
+ const onUpdateKeyframeEase = vi.fn();
280
+ const onUpdateSegmentEase = vi.fn();
281
+ usePlayerStore.setState({
282
+ focusedEaseSegment: {
283
+ elementId: "index.html#hero",
284
+ animationId: "a1",
285
+ tweenPercentage: 50,
286
+ collidingAnimationTargets: [
287
+ { animationId: "a1", tweenPercentage: 50 },
288
+ { animationId: "a2", tweenPercentage: 75 },
289
+ ],
290
+ },
291
+ });
292
+ const { host, root } = renderInto(
293
+ <FlatMotionSection
294
+ element={baseElement()}
295
+ animations={[
296
+ {
297
+ id: "a1",
298
+ method: "to",
299
+ position: 0,
300
+ duration: 1,
301
+ properties: { x: 100 },
302
+ keyframes: {
303
+ format: "percentage",
304
+ keyframes: [
305
+ { percentage: 0, properties: { x: 0 } },
306
+ { percentage: 50, properties: { x: 100 } },
307
+ ],
308
+ },
309
+ } as never,
310
+ ]}
311
+ showTiming={false}
312
+ showEffects
313
+ onSetAttribute={vi.fn()}
314
+ onAddAnimation={vi.fn()}
315
+ onUpdateProperty={vi.fn()}
316
+ onUpdateMeta={vi.fn()}
317
+ onDeleteAnimation={vi.fn()}
318
+ onAddProperty={vi.fn()}
319
+ onRemoveProperty={vi.fn()}
320
+ onUpdateKeyframeEase={onUpdateKeyframeEase}
321
+ onUpdateSegmentEase={onUpdateSegmentEase}
322
+ />,
323
+ );
324
+
325
+ const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
326
+ expect(dropdown).not.toBeNull();
327
+ act(() => dropdown?.click());
328
+ const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
329
+ expect(preset).not.toBeNull();
330
+ act(() => preset?.click());
331
+
332
+ expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
333
+ [
334
+ { animationId: "a1", tweenPercentage: 50 },
335
+ { animationId: "a2", tweenPercentage: 75 },
336
+ ],
337
+ "power2.out",
338
+ );
339
+ expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
340
+ act(() => root.unmount());
341
+ });
@@ -1,3 +1,4 @@
1
+ import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
1
2
  import { useState } from "react";
2
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
4
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
@@ -143,7 +144,7 @@ export function FlatMotionSection({
143
144
  // the store's selectedElementId, which flips synchronously during async
144
145
  // selection resolution), so a shared class-selector animation id can't open
145
146
  // the wrong element's editor.
146
- const renderedElementId = `${element.sourceFile}#${element.id}`;
147
+ const renderedElementId = scopedElementKey(element);
147
148
  const focusedHere =
148
149
  focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
149
150
  ? focusedEaseSegment