@hyperframes/studio 0.8.134 → 0.8.135

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 (54) hide show
  1. package/dist/assets/{hyperframes-player-Bro1gj0Z.js → hyperframes-player-DFv9heE6.js} +1 -1
  2. package/dist/assets/{index-DnVGIbEC.js → index-Buy06mZy.js} +1 -1
  3. package/dist/assets/{index-BwNDTmAD.js → index-CTp4loy9.js} +1 -1
  4. package/dist/assets/{index-wnXyQjW-.js → index-N_4Ij4X6.js} +185 -184
  5. package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
  6. package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
  7. package/dist/chunk-NM7BXNZ7.js.map +1 -0
  8. package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +227 -88
  12. package/dist/index.js.map +1 -1
  13. package/dist/player.js +2 -2
  14. package/dist/styles.css +1 -1
  15. package/dist/ui.d.ts +3 -2
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/AnimationCard.test.tsx +60 -0
  19. package/src/components/editor/AnimationCard.tsx +16 -6
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -1
  21. package/src/components/editor/LayersPanel.tsx +39 -47
  22. package/src/components/editor/domEditInlineText.ts +1 -1
  23. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +34 -0
  25. package/src/components/editor/layerKind.test.ts +38 -0
  26. package/src/components/editor/layerKind.ts +19 -0
  27. package/src/components/editor/useDomEditNudge.test.tsx +48 -27
  28. package/src/components/ui/cn.ts +2 -1
  29. package/src/contexts/DomEditContext.tsx +3 -1
  30. package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
  31. package/src/hooks/gsapRuntimePatch.ts +4 -0
  32. package/src/hooks/gsapShared.test.ts +11 -0
  33. package/src/hooks/gsapShared.ts +1 -1
  34. package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
  35. package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
  37. package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
  38. package/src/hooks/useDomGeometryCommits.ts +6 -1
  39. package/src/hooks/useGestureCommit.test.tsx +89 -0
  40. package/src/hooks/useGestureCommit.ts +26 -9
  41. package/src/icons/SystemIcons.tsx +6 -0
  42. package/src/layersPanelExport.test.tsx +45 -4
  43. package/src/utils/gsapKeyframeEases.ts +2 -0
  44. package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
  45. package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
  46. package/src/utils/gsapLiveRetime.ts +39 -31
  47. package/src/utils/gsapSoftReload.test.ts +98 -14
  48. package/src/utils/gsapSoftReload.ts +14 -2
  49. package/src/utils/gsapUndoRestore.test.ts +21 -2
  50. package/src/utils/softReloadTargets.ts +56 -0
  51. package/src/utils/studioPendingEdits.test.ts +123 -0
  52. package/src/utils/studioPendingEdits.ts +27 -1
  53. package/dist/chunk-5VSXRGIX.js.map +0 -1
  54. /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
package/dist/ui.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ButtonHTMLAttributes, ReactNode, ElementType, ComponentPropsWithoutRef, ReactElement } from 'react';
3
3
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
4
  import { Tabs as Tabs$1 } from '@base-ui/react/tabs';
5
- import * as node_modules_cn_dist_types from 'node_modules/cn/dist/types';
5
+ import { CnFunction } from 'cn';
6
6
  import { ContextMenu as ContextMenu$1 } from '@base-ui/react/context-menu';
7
7
  import { Menu as Menu$1 } from '@base-ui/react/menu';
8
8
  import { Popover as Popover$1 } from '@base-ui/react/popover';
@@ -101,7 +101,8 @@ declare function Tooltip({ label, children, delay, side }: TooltipProps): react_
101
101
  * Studio's tailwind-merge config: teaches it the two scales `theme.css`
102
102
  * invents (`text-step-*`, `ctl`/`ctl-sm`/`ctl-lg`) so overrides replace, not stack.
103
103
  */
104
- declare const cn: node_modules_cn_dist_types.CnFunction;
104
+
105
+ declare const cn: CnFunction;
105
106
 
106
107
  /**
107
108
  * Base UI lets `className` be a function of the part's state. Studio's parts
package/dist/ui.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  fieldBase,
31
31
  fieldText,
32
32
  popupSurface
33
- } from "./chunk-5VSXRGIX.js";
33
+ } from "./chunk-NM7BXNZ7.js";
34
34
  import "./chunk-PZ5AY32C.js";
35
35
  export {
36
36
  Button,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.134",
3
+ "version": "0.8.135",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/sdk": "0.8.134",
64
- "@hyperframes/core": "0.8.134",
65
- "@hyperframes/studio-server": "0.8.134",
66
- "@hyperframes/parsers": "0.8.134",
67
- "@hyperframes/player": "0.8.134",
68
- "@hyperframes/shader-transitions": "0.8.134"
63
+ "@hyperframes/core": "0.8.135",
64
+ "@hyperframes/player": "0.8.135",
65
+ "@hyperframes/shader-transitions": "0.8.135",
66
+ "@hyperframes/parsers": "0.8.135",
67
+ "@hyperframes/studio-server": "0.8.135",
68
+ "@hyperframes/sdk": "0.8.135"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.134"
84
+ "@hyperframes/producer": "0.8.135"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -149,6 +149,66 @@ function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void
149
149
  }
150
150
 
151
151
  describe("AnimationCard", () => {
152
+ it("heads a keyframed card with the segment ease and names the run ease on its own line", () => {
153
+ const view = renderFocusCard(null, vi.fn(), true);
154
+ expect(view.host.querySelector("[data-card-ease]")?.textContent).toBe("power1.inOut");
155
+ expect(view.host.querySelector("[data-card-run-ease]")?.textContent).toBe(
156
+ "Run ease: power1.out",
157
+ );
158
+ act(() => view.root.unmount());
159
+ });
160
+
161
+ it("heads a keyframed card Mixed when its segments differ, with no run line for a linear run", () => {
162
+ const animation: GsapAnimation = {
163
+ ...ANIMATION,
164
+ ease: undefined,
165
+ keyframes: {
166
+ format: "percentage",
167
+ keyframes: [
168
+ { percentage: 0, properties: { x: 0 } },
169
+ { percentage: 50, properties: { x: 100 }, ease: "expo.in" },
170
+ { percentage: 100, properties: { x: 200 } },
171
+ ],
172
+ },
173
+ };
174
+ const view = renderFocusCard(null, vi.fn(), true, animation);
175
+ expect(view.host.querySelector("[data-card-ease]")?.textContent).toBe("Mixed");
176
+ expect(view.host.querySelector("[data-card-run-ease]")).toBeNull();
177
+ act(() => view.root.unmount());
178
+ });
179
+
180
+ it("labels segments with rounded percentages, never float noise or exponents", () => {
181
+ const animation: GsapAnimation = {
182
+ ...ANIMATION,
183
+ keyframes: {
184
+ format: "percentage",
185
+ keyframes: [
186
+ { percentage: 0, properties: { x: 0 } },
187
+ { percentage: 1.2860082304526747e-7, properties: { x: 1 } },
188
+ { percentage: 0.13484773662551455, properties: { x: 2 } },
189
+ { percentage: 100, properties: { x: 200 } },
190
+ ],
191
+ },
192
+ };
193
+ const view = renderFocusCard(null, vi.fn(), true, animation);
194
+ const labels = [
195
+ ...view.host.querySelectorAll("[data-ease-segment-pct] button span:first-child"),
196
+ ];
197
+ expect(labels.map((label) => label.textContent)).toEqual([
198
+ "0% → <0.001%",
199
+ "<0.001% → 0.135%",
200
+ "0.135% → 100%",
201
+ ]);
202
+ act(() => view.root.unmount());
203
+ });
204
+
205
+ it("labels an unset segment with the ease GSAP plays, not the tween's run ease", () => {
206
+ const view = renderFocusCard(null, vi.fn(), true);
207
+ const labels = [...view.host.querySelectorAll("[data-ease-segment-pct] button span.ml-auto")];
208
+ expect(labels.map((label) => label.textContent)).toEqual(["power1.inOut", "power1.inOut"]);
209
+ act(() => view.root.unmount());
210
+ });
211
+
152
212
  it("scrolls a focused segment into view but not a manually toggled segment", () => {
153
213
  const originalScrollIntoView = Object.getOwnPropertyDescriptor(
154
214
  HTMLElement.prototype,
@@ -6,7 +6,8 @@ import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
6
6
  import { MetricField, SelectField } from "./propertyPanelPrimitives";
7
7
  import { controlPointsForGsapEase } from "./studioMotion";
8
8
  import { EASE_LABELS, METHOD_LABELS, METHOD_TOOLTIPS, PROP_LABELS } from "./gsapAnimationConstants";
9
- import { buildTweenSummary } from "./gsapAnimationHelpers";
9
+ import { buildTweenSummary, uniformSegmentEase } from "./gsapAnimationHelpers";
10
+ import { keyframedTweenEases } from "../../utils/gsapKeyframeEases";
10
11
  import { EaseCurveSection } from "./EaseCurveSection";
11
12
  import { ArcPathControls } from "./ArcPathControls";
12
13
  import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
@@ -31,6 +32,9 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
31
32
  onFocusSegmentConsumed?: () => void;
32
33
  }
33
34
 
35
+ const easeDisplayName = (ease: string) =>
36
+ ease.startsWith("custom(") ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
37
+
34
38
  // fallow-ignore-next-line complexity
35
39
  export const AnimationCard = memo(function AnimationCard({
36
40
  animation,
@@ -152,9 +156,9 @@ export const AnimationCard = memo(function AnimationCard({
152
156
  const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
153
157
  const easeName =
154
158
  (animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
155
- const easeLabel = easeName.startsWith("custom(")
156
- ? "Custom curve"
157
- : (EASE_LABELS[easeName] ?? easeName);
159
+ const headerEase = animation.keyframes ? uniformSegmentEase(animation) : easeName;
160
+ const easeLabel = headerEase === null ? "Mixed" : easeDisplayName(headerEase);
161
+ const runEase = animation.keyframes ? keyframedTweenEases(animation).run : undefined;
158
162
  const endTime =
159
163
  typeof animation.position === "number"
160
164
  ? animation.position + (animation.duration ?? 0)
@@ -217,7 +221,8 @@ export const AnimationCard = memo(function AnimationCard({
217
221
  </span>
218
222
  <span
219
223
  className={`ml-auto text-[10px] ${flat ? "text-panel-text-3" : "text-neutral-500"}`}
220
- title={easeName}
224
+ title={headerEase ?? "Mixed"}
225
+ data-card-ease
221
226
  >
222
227
  {easeLabel}
223
228
  </span>
@@ -254,6 +259,11 @@ export const AnimationCard = memo(function AnimationCard({
254
259
  Keyframed — click a segment below to edit its curve
255
260
  </p>
256
261
  )}
262
+ {runEase && runEase !== "none" && (
263
+ <p className="mt-1 text-[9px] text-neutral-500" data-card-run-ease>
264
+ Run ease: {easeDisplayName(runEase)}
265
+ </p>
266
+ )}
257
267
  </div>
258
268
  <button
259
269
  type="button"
@@ -296,7 +306,7 @@ export const AnimationCard = memo(function AnimationCard({
296
306
  {animation.keyframes && onUpdateKeyframeEase ? (
297
307
  <KeyframeEaseList
298
308
  keyframes={animation.keyframes.keyframes}
299
- globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
309
+ globalEase={keyframedTweenEases(animation).segment({})}
300
310
  expandedPct={expandedKfPct}
301
311
  collidingAnimationTargets={focusedCollidingAnimationTargets}
302
312
  onToggle={(pct) => {
@@ -1,6 +1,7 @@
1
1
  import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
2
2
  import { EASE_LABELS } from "./gsapAnimationConstants";
3
3
  import { EaseCurveSection } from "./EaseCurveSection";
4
+ import { roundTo3 } from "../../utils/rounding";
4
5
  import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
5
6
 
6
7
  // The full GSAP easing vocabulary offered by the "Set all…" bulk control —
@@ -41,6 +42,8 @@ const APPLY_ALL_EASES = [
41
42
  "bounce.inOut",
42
43
  ] as const;
43
44
 
45
+ const percentLabel = (pct: number) => (pct > 0 && pct < 0.001 ? "<0.001" : String(roundTo3(pct)));
46
+
44
47
  export function KeyframeEaseList({
45
48
  keyframes,
46
49
  globalEase,
@@ -91,7 +94,7 @@ export function KeyframeEaseList({
91
94
  if (i === 0) return null;
92
95
  const segEase = kf.ease ?? globalEase;
93
96
  const isExpanded = expandedPct === kf.percentage;
94
- const label = `${keyframes[i - 1].percentage}% → ${kf.percentage}%`;
97
+ const label = `${percentLabel(keyframes[i - 1].percentage)}% → ${percentLabel(kf.percentage)}%`;
95
98
  const easeLabel = segEase.startsWith("custom(")
96
99
  ? "Custom"
97
100
  : (EASE_LABELS[segEase] ?? segEase);
@@ -20,7 +20,17 @@ import {
20
20
  findMatchingTimelineElementId,
21
21
  resolveTimelineSelectionSeekTime,
22
22
  } from "../../utils/studioHelpers";
23
- import { Layers } from "../../icons/SystemIcons";
23
+ import {
24
+ Film,
25
+ Folder,
26
+ ImageIcon,
27
+ Layers,
28
+ Music,
29
+ Square,
30
+ Type,
31
+ Vector,
32
+ } from "../../icons/SystemIcons";
33
+ import { isCompositionHost, layerKindOf } from "./layerKind";
24
34
  import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
25
35
  import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort";
26
36
  import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
@@ -34,38 +44,18 @@ import { useLayerRevealOverride } from "./useLayerRevealOverride";
34
44
  // Rows this panel renders before it stops. A display budget, not a document limit.
35
45
  const LAYERS_PANEL_MAX_ROWS = 80;
36
46
 
37
- const TAG_ICONS: Record<string, string> = {
38
- video: "Vi",
39
- audio: "Au",
40
- img: "Im",
41
- svg: "Sv",
42
- canvas: "Cn",
43
- div: "Di",
44
- section: "Se",
45
- span: "Sp",
46
- p: "P",
47
- h1: "H1",
48
- h2: "H2",
49
- h3: "H3",
50
- h4: "H4",
51
- h5: "H5",
52
- h6: "H6",
53
- a: "A",
54
- button: "Bt",
55
- ul: "Ul",
56
- ol: "Ol",
57
- li: "Li",
58
- style: "St",
59
- template: "Te",
60
- };
61
-
62
- function getTagBadge(tagName: string): string {
63
- return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase();
64
- }
47
+ const [CARET, ICON, GAP] = [16, 20, 6];
48
+ const CHILD_INDENT = CARET + GAP + ICON + GAP;
65
49
 
66
- function isCompositionHost(el: HTMLElement): boolean {
67
- return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
68
- }
50
+ const KIND_ICON = {
51
+ image: ImageIcon,
52
+ video: Film,
53
+ audio: Music,
54
+ vector: Vector,
55
+ group: Folder,
56
+ text: Type,
57
+ shape: Square,
58
+ } as const;
69
59
 
70
60
  /**
71
61
  * A trailing-rAF + cooldown throttle: `invoke` runs `run` at most once per
@@ -452,7 +442,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
452
442
  <button
453
443
  type="button"
454
444
  onClick={() => setActiveGroupElement(null)}
455
- className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
445
+ className="mx-1 flex w-[calc(100%-0.5rem)] items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
456
446
  >
457
447
  <span aria-hidden="true">←</span>
458
448
  <span className="truncate">
@@ -467,6 +457,14 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
467
457
  const isCollapsed = collapsed[layer.key] ?? false;
468
458
  const hasChildren = layer.childCount > 0;
469
459
  const isCompHost = isCompositionHost(layer.element);
460
+ const kind = layerKindOf(layer.element, hasChildren);
461
+ const KindIcon = KIND_ICON[kind];
462
+ const kindName = kind[0].toUpperCase() + kind.slice(1);
463
+ const hint = draggable
464
+ ? layer.element.hasAttribute("data-hf-group")
465
+ ? "Double-click to enter group"
466
+ : undefined
467
+ : "This layer can't be reordered";
470
468
 
471
469
  return (
472
470
  <div
@@ -484,21 +482,15 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
484
482
  handleSelectLayer(layer);
485
483
  }
486
484
  }}
487
- className={`group flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors ${
485
+ className={`group mx-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors ${
488
486
  isDragged
489
487
  ? "opacity-40"
490
488
  : selected
491
489
  ? "bg-panel-accent/14 text-accent-ink"
492
490
  : "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
493
491
  } ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
494
- style={{ paddingLeft: 8 + layer.depth * 16 }}
495
- title={
496
- draggable
497
- ? layer.element.hasAttribute("data-hf-group")
498
- ? "Double-click to enter group"
499
- : undefined
500
- : "This layer can't be reordered"
501
- }
492
+ style={{ paddingLeft: 8 + layer.depth * CHILD_INDENT }}
493
+ title={hint}
502
494
  >
503
495
  {hasChildren ? (
504
496
  <button
@@ -518,11 +510,11 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
518
510
  <path d="M2 1l4 3-4 3z" />
519
511
  </svg>
520
512
  </button>
521
- ) : (
522
- <span className="w-4 shrink-0" />
523
- )}
513
+ ) : null}
524
514
  <span
525
- className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
515
+ data-layer-kind={kind}
516
+ title={hint ? `${kindName} · ${hint}` : kindName}
517
+ className={`flex h-5 w-5 shrink-0 items-center justify-center rounded ${
526
518
  selected
527
519
  ? "bg-panel-accent/18 text-accent-ink"
528
520
  : isCompHost
@@ -530,7 +522,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
530
522
  : "bg-panel-hover text-panel-text-4"
531
523
  }`}
532
524
  >
533
- {getTagBadge(layer.tagName)}
525
+ <KindIcon size={12} />
534
526
  </span>
535
527
  <span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
536
528
  {hasChildren && (
@@ -53,7 +53,7 @@ export function canEditElementTextInline(element: HTMLElement | null): boolean {
53
53
  return (element.textContent ?? "").trim().length > 0;
54
54
  }
55
55
 
56
- function hasOnlyFormattingChildren(element: HTMLElement): boolean {
56
+ export function hasOnlyFormattingChildren(element: HTMLElement): boolean {
57
57
  for (const child of Array.from(element.children)) {
58
58
  if (!isRichTextFormattingTag(child.tagName)) return false;
59
59
  if (!isHtmlFromAnyWindow(child)) return false;
@@ -52,6 +52,35 @@ describe("buildTweenSummary", () => {
52
52
  expect(s).toContain("opacity");
53
53
  });
54
54
 
55
+ it("describes a keyframed tween by its keyframes and the segment ease it plays", () => {
56
+ const keyframes = {
57
+ easeEach: "power3.out",
58
+ keyframes: [
59
+ { percentage: 0, properties: { x: 0 } },
60
+ { percentage: 50, properties: { x: 300, opacity: 0.5 } },
61
+ { percentage: 100, properties: { x: 600 } },
62
+ ],
63
+ };
64
+ expect(buildTweenSummary(anim({ ease: undefined, keyframes } as Partial<GsapAnimation>))).toBe(
65
+ "Starting at 0s, over 1s, animate #box's move x, opacity through 3 keyframes, each segment eased power3.out.",
66
+ );
67
+ });
68
+
69
+ it("names a keyframed tween's run ease and mixed segment eases", () => {
70
+ const keyframes = {
71
+ keyframes: [
72
+ { percentage: 0, properties: { x: 0 } },
73
+ { percentage: 50, properties: { x: 300 }, ease: "expo.in" },
74
+ { percentage: 100, properties: { x: 600 } },
75
+ ],
76
+ };
77
+ expect(
78
+ buildTweenSummary(anim({ ease: "sine.inOut", keyframes } as Partial<GsapAnimation>)),
79
+ ).toBe(
80
+ "Starting at 0s, over 1s, animate #box's move x through 3 keyframes, with per-keyframe easing, across a sine.inOut run.",
81
+ );
82
+ });
83
+
55
84
  it("describes a set tween", () => {
56
85
  const s = buildTweenSummary(anim({ method: "set", properties: { opacity: 0 } }));
57
86
  expect(s).toMatch(/^At 0s, instantly set/);
@@ -1,5 +1,6 @@
1
1
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
2
2
  import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
3
+ import { keyframedTweenEases } from "../../utils/gsapKeyframeEases";
3
4
 
4
5
  function formatPropValue(prop: string, v: number | string): string {
5
6
  const unit = PROP_UNITS[prop] ?? "";
@@ -7,6 +8,38 @@ function formatPropValue(prop: string, v: number | string): string {
7
8
  return `${v}${unit}`;
8
9
  }
9
10
 
11
+ const easeLabel = (ease: string) =>
12
+ ease.startsWith("custom(") ? "custom" : (EASE_LABELS[ease] ?? ease);
13
+
14
+ export function uniformSegmentEase(animation: GsapAnimation): string | null {
15
+ const eases = keyframedTweenEases(animation);
16
+ const steps = animation.keyframes?.keyframes ?? [];
17
+ const segmentEases = new Set(steps.slice(1).map((kf) => eases.segment(kf)));
18
+ if (segmentEases.size === 0) return eases.segment({});
19
+ return segmentEases.size === 1 ? [...segmentEases][0]! : null;
20
+ }
21
+
22
+ function keyframedTweenSummary(
23
+ animation: GsapAnimation,
24
+ pos: number | string,
25
+ dur: number,
26
+ ): string {
27
+ const steps = animation.keyframes?.keyframes ?? [];
28
+ const props = [...new Set(steps.flatMap((kf) => Object.keys(kf.properties)))];
29
+ const propText = props.map((p) => (PROP_LABELS[p] ?? p).toLowerCase()).join(", ");
30
+ const segmentEase = uniformSegmentEase(animation);
31
+ const segmentText =
32
+ steps.length < 2
33
+ ? ""
34
+ : segmentEase
35
+ ? `, each segment eased ${easeLabel(segmentEase)}`
36
+ : ", with per-keyframe easing";
37
+ const run = keyframedTweenEases(animation).run;
38
+ const runText = run && run !== "none" ? `, across a ${easeLabel(run)} run` : "";
39
+ const count = `${steps.length} keyframe${steps.length === 1 ? "" : "s"}`;
40
+ return `Starting at ${pos}s, over ${dur}s, animate ${animation.targetSelector}'s ${propText || "no properties yet"} through ${count}${segmentText}${runText}.`;
41
+ }
42
+
10
43
  // fallow-ignore-next-line complexity
11
44
  export function buildTweenSummary(animation: GsapAnimation): string {
12
45
  const easeName = animation.ease ?? "none";
@@ -16,6 +49,7 @@ export function buildTweenSummary(animation: GsapAnimation): string {
16
49
  const dur = animation.duration ?? 0;
17
50
  const rawPos = animation.position;
18
51
  const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos;
52
+ if (animation.keyframes) return keyframedTweenSummary(animation, pos, dur);
19
53
  const propDescs = props.map(([p, v]) => {
20
54
  const label = (PROP_LABELS[p] ?? p).toLowerCase();
21
55
  return `${label} to ${formatPropValue(p, v)}`;
@@ -0,0 +1,38 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { layerKindOf } from "./layerKind";
4
+
5
+ const el = (html: string) => {
6
+ const host = document.createElement("div");
7
+ host.innerHTML = html;
8
+ return host.firstElementChild!;
9
+ };
10
+
11
+ describe("layerKindOf", () => {
12
+ it.each([
13
+ ['<img src="a.png">', "image"],
14
+ ['<picture><img src="a.png"></picture>', "image"],
15
+ ['<video src="a.mp4"></video>', "video"],
16
+ ['<audio src="a.mp3"></audio>', "audio"],
17
+ ['<svg viewBox="0 0 10 10"><path d="M0 0h10"/></svg>', "vector"],
18
+ ['<div data-hf-group="Intro"><div></div></div>', "group"],
19
+ ['<div data-composition-src="intro.html"></div>', "group"],
20
+ ['<div data-composition-file="intro.html"></div>', "group"],
21
+ ["<h1>Title</h1>", "text"],
22
+ ["<div>Plain words</div>", "text"],
23
+ ["<p>Rich <b>bold</b> and <em>soft</em> words</p>", "text"],
24
+ ['<div style="background:red"></div>', "shape"],
25
+ ["<div><img></div>", "shape"],
26
+ ["<div>Caption<img></div>", "shape"],
27
+ ["<p>H<sub>2</sub>O</p>", "shape"],
28
+ ["<div> </div>", "shape"],
29
+ ["<canvas></canvas>", "shape"],
30
+ ])("%s is %s", (html, kind) => {
31
+ expect(layerKindOf(el(html), false)).toBe(kind);
32
+ });
33
+ });
34
+
35
+ it("a row with layer children is a group unless it is text", () => {
36
+ expect(layerKindOf(el("<div><div></div></div>"), true)).toBe("group");
37
+ expect(layerKindOf(el('<p>Hi <span class="x">there</span></p>'), true)).toBe("text");
38
+ });
@@ -0,0 +1,19 @@
1
+ import { hasOnlyFormattingChildren } from "./domEditInlineText";
2
+
3
+ export type LayerKind = "image" | "video" | "audio" | "vector" | "group" | "text" | "shape";
4
+
5
+ export function isCompositionHost(el: Element): boolean {
6
+ return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
7
+ }
8
+
9
+ /** What a Layers row tells a person an element is: its media tag, a group, text, or else a shape. */
10
+ export function layerKindOf(el: Element, hasChildren: boolean): LayerKind {
11
+ const tag = el.tagName.toLowerCase();
12
+ if (tag === "img" || tag === "picture") return "image";
13
+ if (tag === "video") return "video";
14
+ if (tag === "audio") return "audio";
15
+ if (tag === "svg") return "vector";
16
+ if (el.hasAttribute("data-hf-group") || isCompositionHost(el)) return "group";
17
+ if (hasOnlyFormattingChildren(el as HTMLElement) && el.textContent?.trim()) return "text";
18
+ return hasChildren ? "group" : "shape";
19
+ }
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { trackStudioEvent } from "../../utils/studioTelemetry";
2
3
  // @vitest-environment happy-dom
3
4
  import React, { act } from "react";
@@ -7,7 +8,11 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
7
8
  import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
8
9
  import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
9
10
  import { __resetForTests } from "../../utils/canvasNudgeGate";
10
- import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
11
+ import {
12
+ flushStudioPendingEdits,
13
+ hasStudioPendingEdits,
14
+ trackedStudioEdit,
15
+ } from "../../utils/studioPendingEdits";
11
16
  import type { DomEditSelection } from "./domEditing";
12
17
  import type { OverlayRect } from "./domEditOverlayGeometry";
13
18
 
@@ -392,23 +397,32 @@ describe("useDomEditNudge pauses playback before it snapshots the timelines", ()
392
397
  });
393
398
  });
394
399
 
400
+ /** A burst-ready Harness on a fresh element, under fake timers. */
401
+ function mountBurstHarness(
402
+ id: string,
403
+ onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"],
404
+ ) {
405
+ __resetForTests();
406
+ vi.useFakeTimers();
407
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
408
+ const element = document.body.appendChild(document.createElement("div"));
409
+ element.id = id;
410
+ act(() => {
411
+ root.render(
412
+ React.createElement(Harness, {
413
+ selection: makeSelection("Dot", element),
414
+ onPathOffsetCommit,
415
+ }),
416
+ );
417
+ });
418
+ return root;
419
+ }
420
+
395
421
  describe("useDomEditNudge — undo right after a burst", () => {
396
422
  it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
397
- __resetForTests();
398
- vi.useFakeTimers();
399
- const root = createRoot(document.body.appendChild(document.createElement("div")));
400
- const element = document.body.appendChild(document.createElement("div"));
401
- element.id = "dot-undo";
402
423
  let saved!: () => void;
403
424
  const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
404
- act(() => {
405
- root.render(
406
- React.createElement(Harness, {
407
- selection: makeSelection("Dot", element),
408
- onPathOffsetCommit: commit,
409
- }),
410
- );
411
- });
425
+ const root = mountBurstHarness("dot-undo", commit);
412
426
  act(() => dispatchArrowRight());
413
427
 
414
428
  let drained = false;
@@ -424,21 +438,9 @@ describe("useDomEditNudge — undo right after a burst", () => {
424
438
  });
425
439
 
426
440
  it("counts the burst as a pending edit from its first key until its save lands", async () => {
427
- __resetForTests();
428
- vi.useFakeTimers();
429
- const root = createRoot(document.body.appendChild(document.createElement("div")));
430
- const element = document.body.appendChild(document.createElement("div"));
431
- element.id = "dot-pending";
432
441
  let saved!: () => void;
433
442
  const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
434
- act(() => {
435
- root.render(
436
- React.createElement(Harness, {
437
- selection: makeSelection("Dot", element),
438
- onPathOffsetCommit: commit,
439
- }),
440
- );
441
- });
443
+ const root = mountBurstHarness("dot-pending", commit);
442
444
  expect(hasStudioPendingEdits()).toBe(false);
443
445
  act(() => dispatchArrowRight());
444
446
  expect(hasStudioPendingEdits()).toBe(true);
@@ -452,6 +454,25 @@ describe("useDomEditNudge — undo right after a burst", () => {
452
454
  });
453
455
  });
454
456
 
457
+ describe("useDomEditNudge — a Design-panel edit during a burst", () => {
458
+ it("commits the burst before the panel edit, so one undo takes back the panel edit", async () => {
459
+ const order: string[] = [];
460
+ const root = mountBurstHarness("dot-panel", async () => void order.push("nudge"));
461
+ try {
462
+ act(() => dispatchArrowRight());
463
+ const panelEdit = trackedStudioEdit(async () => void order.push("width"), {
464
+ afterOlderSaves: true,
465
+ })();
466
+ vi.useRealTimers();
467
+ await panelEdit;
468
+ expect(order).toEqual(["nudge", "width"]);
469
+ } finally {
470
+ vi.useRealTimers();
471
+ act(() => root.unmount());
472
+ }
473
+ });
474
+ });
475
+
455
476
  describe("useDomEditNudge — a commit that throws", () => {
456
477
  it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
457
478
  __resetForTests();
@@ -3,6 +3,7 @@
3
3
  * invents (`text-step-*`, `ctl`/`ctl-sm`/`ctl-lg`) so overrides replace, not stack.
4
4
  */
5
5
 
6
+ import type { CnFunction } from "cn";
6
7
  import { createCn } from "cn/config";
7
8
 
8
9
  /** `text-step-11`, `text-step-9`, … — the type scale from `theme.css`. */
@@ -11,7 +12,7 @@ const isTypeStep = (value: string) => /^step-\d+$/.test(value);
11
12
  /** `ctl`, `ctl-sm`, `ctl-lg` — the control heights from `theme.css`. */
12
13
  const isControlSize = (value: string) => /^ctl(-sm|-lg)?$/.test(value);
13
14
 
14
- export const cn = createCn({
15
+ export const cn: CnFunction = createCn({
15
16
  extend: {
16
17
  classGroups: {
17
18
  "font-size": [{ text: [isTypeStep] }],
@@ -107,7 +107,9 @@ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
107
107
  const tracked: Record<string, unknown> = { ...actions };
108
108
  for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
109
109
  if (!SAVES_AN_EDIT[key]) continue;
110
- tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown);
110
+ tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown, {
111
+ afterOlderSaves: true,
112
+ });
111
113
  }
112
114
  return tracked as unknown as DomEditActionsValue;
113
115
  }