@hyperframes/studio 0.8.146 → 0.8.148

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.
package/dist/ui.d.ts CHANGED
@@ -182,12 +182,14 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
182
182
  side?: "top" | "bottom" | "left" | "right";
183
183
  align?: "start" | "center" | "end";
184
184
  sideOffset?: number;
185
+ collisionAvoidance?: ComponentPropsWithoutRef<typeof Popover$1.Positioner>["collisionAvoidance"];
185
186
  /** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
186
187
  arrow?: boolean;
187
188
  /** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
188
189
  container?: PortalContainer;
189
190
  /** Names the popup for assistive tech. */
190
191
  "aria-label"?: string;
192
+ "aria-describedby"?: string;
191
193
  /**
192
194
  * What to focus on open. Defaults to Base UI's behaviour (the first tabbable
193
195
  * element), which is what a rename field wants.
@@ -196,7 +198,7 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
196
198
  className?: string;
197
199
  "data-preview-state"?: PopupPreviewState;
198
200
  }
199
- declare function Popover({ trigger, children, side, align, arrow, sideOffset, container, className, initialFocus, "aria-label": ariaLabel, "data-preview-state": previewState, ...root }: PopoverProps): react_jsx_runtime.JSX.Element;
201
+ declare function Popover({ trigger, children, side, align, arrow, sideOffset, collisionAvoidance, container, className, initialFocus, "aria-label": ariaLabel, "aria-describedby": describedBy, "data-preview-state": previewState, ...root }: PopoverProps): react_jsx_runtime.JSX.Element;
200
202
 
201
203
  /**
202
204
  * Shared field look (also used by NumberField and Select). Each interactive state is written
package/dist/ui.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  fieldBase,
31
31
  fieldText,
32
32
  popupSurface
33
- } from "./chunk-PVFBY7KR.js";
33
+ } from "./chunk-FF5DM7BQ.js";
34
34
  import "./chunk-MLKGABMK.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.146",
3
+ "version": "0.8.148",
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/core": "0.8.146",
64
- "@hyperframes/parsers": "0.8.146",
65
- "@hyperframes/player": "0.8.146",
66
- "@hyperframes/sdk": "0.8.146",
67
- "@hyperframes/studio-server": "0.8.146",
68
- "@hyperframes/shader-transitions": "0.8.146"
63
+ "@hyperframes/parsers": "0.8.148",
64
+ "@hyperframes/core": "0.8.148",
65
+ "@hyperframes/sdk": "0.8.148",
66
+ "@hyperframes/player": "0.8.148",
67
+ "@hyperframes/shader-transitions": "0.8.148",
68
+ "@hyperframes/studio-server": "0.8.148"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.146"
85
+ "@hyperframes/producer": "0.8.148"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
@@ -35,12 +35,16 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.
35
35
  side?: "top" | "bottom" | "left" | "right";
36
36
  align?: "start" | "center" | "end";
37
37
  sideOffset?: number;
38
+ collisionAvoidance?: ComponentPropsWithoutRef<
39
+ typeof BasePopover.Positioner
40
+ >["collisionAvoidance"];
38
41
  /** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
39
42
  arrow?: boolean;
40
43
  /** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
41
44
  container?: PortalContainer;
42
45
  /** Names the popup for assistive tech. */
43
46
  "aria-label"?: string;
47
+ "aria-describedby"?: string;
44
48
  /**
45
49
  * What to focus on open. Defaults to Base UI's behaviour (the first tabbable
46
50
  * element), which is what a rename field wants.
@@ -57,17 +61,19 @@ export function Popover({
57
61
  align = "center",
58
62
  arrow = false,
59
63
  sideOffset = arrow ? SIDE_OFFSET + ARROW_PX : SIDE_OFFSET,
64
+ collisionAvoidance,
60
65
  container,
61
66
  className,
62
67
  initialFocus,
63
68
  "aria-label": ariaLabel,
69
+ "aria-describedby": describedBy,
64
70
  "data-preview-state": previewState,
65
71
  ...root
66
72
  }: PopoverProps) {
67
73
  const inline = useInlinePortal(container);
68
74
  return (
69
75
  <BasePopover.Root {...root}>
70
- <BasePopover.Trigger render={trigger} />
76
+ <BasePopover.Trigger render={trigger} aria-describedby={describedBy} />
71
77
  {inline.host}
72
78
  <BasePopover.Portal {...inline.portal}>
73
79
  <BasePopover.Positioner
@@ -75,6 +81,7 @@ export function Popover({
75
81
  align={align}
76
82
  sideOffset={sideOffset}
77
83
  collisionPadding={VIEWPORT_MARGIN}
84
+ collisionAvoidance={collisionAvoidance}
78
85
  arrowPadding={ARROW_CORNER_CLEARANCE}
79
86
  className={POPUP_LAYER}
80
87
  >
@@ -218,6 +218,26 @@ describe("patchIframeDomTiming", () => {
218
218
 
219
219
  expect([...cards!].map((card) => card.dataset.start)).toEqual(["1", "9"]);
220
220
  });
221
+
222
+ it("makes a runtime-stamped element authored on any timing edit, a lane move included", () => {
223
+ const iframe = makeIframeWith(`
224
+ <div data-composition-id="root">
225
+ <div id="title" data-start="0" data-duration="10" data-hf-autostamped="1"></div>
226
+ <div id="badge" data-start="0" data-duration="10" data-hf-autostamped="1"></div>
227
+ </div>
228
+ `);
229
+ const doc = iframe.contentDocument!;
230
+ const at = (id: string) => el({ id, tag: "div", domId: id, sourceFile: "index.html" });
231
+
232
+ patchIframeDomTiming(iframe, at("title"), [
233
+ ["data-start", "4"],
234
+ ["data-duration", "2"],
235
+ ]);
236
+ patchIframeDomTiming(iframe, at("badge"), [["data-track-index", "3"]]);
237
+
238
+ expect(doc.getElementById("title")?.hasAttribute("data-hf-autostamped")).toBe(false);
239
+ expect(doc.getElementById("badge")?.hasAttribute("data-hf-autostamped")).toBe(false);
240
+ });
221
241
  });
222
242
 
223
243
  describe("extendRootDurationIfNeeded", () => {
@@ -1,4 +1,5 @@
1
1
  import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
2
+ import { applyAuthoredTiming } from "@hyperframes/core/studio-preview-mark";
2
3
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
3
4
  import { toAuthoredStart } from "../player/store/timelineElement";
4
5
  import {
@@ -180,7 +181,7 @@ export function patchIframeDomTiming(
180
181
  try {
181
182
  const el = findTimelineElementInIframe(iframe, element, activeCompositionPath);
182
183
  if (!el) return;
183
- for (const [name, value] of attrs) el.setAttribute(name, value);
184
+ applyAuthoredTiming(el, attrs);
184
185
  } catch {
185
186
  // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
186
187
  }
@@ -7,6 +7,7 @@ import { PlayerControls } from "./PlayerControls";
7
7
  import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
8
8
  import { createHappyDomRootHarness } from "./testRootHarness";
9
9
  import { usePlayerStore } from "../store/playerStore";
10
+ import { POPUP_LAYER } from "../../components/ui/Menu";
10
11
 
11
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
13
 
@@ -52,11 +53,16 @@ function openPanel(trigger: HTMLButtonElement): void {
52
53
  expect(trigger.getAttribute("aria-expanded")).toBe("true");
53
54
  }
54
55
 
56
+ /** The open popover a trigger controls; it portals out of the trigger's host. */
57
+ function panelOf(trigger: HTMLButtonElement): HTMLElement {
58
+ return document.getElementById(trigger.getAttribute("aria-controls")!)!;
59
+ }
60
+
55
61
  describe("ShortcutsPanel", () => {
56
62
  it.each(["page", "button", "panel"] as const)(
57
63
  "closes with Escape from the %s-focused position",
58
64
  (focusPosition) => {
59
- const { host, trigger } = renderPanel();
65
+ const { trigger } = renderPanel();
60
66
  openPanel(trigger);
61
67
 
62
68
  let eventTarget: Document | HTMLElement;
@@ -70,7 +76,9 @@ describe("ShortcutsPanel", () => {
70
76
  eventTarget = trigger;
71
77
  expect(document.activeElement).toBe(trigger);
72
78
  } else {
73
- const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
79
+ const panelInput = document.querySelector<HTMLInputElement>(
80
+ '[aria-label="Jump to frame"]',
81
+ )!;
74
82
  panelInput.focus();
75
83
  eventTarget = panelInput;
76
84
  expect(document.activeElement).toBe(panelInput);
@@ -81,40 +89,47 @@ describe("ShortcutsPanel", () => {
81
89
  });
82
90
 
83
91
  expect(trigger.getAttribute("aria-expanded")).toBe("false");
84
- expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
92
+ expect(document.querySelector('[aria-label="Jump to frame"]')).toBeNull();
85
93
  },
86
94
  );
87
95
 
88
- it("closes on capture-phase pointerdown even when its default is prevented", () => {
96
+ it("closes on an outside press whose pointerdown a canvas gesture prevented", () => {
89
97
  const preventDefault = (event: Event) => event.preventDefault();
90
98
  document.addEventListener("pointerdown", preventDefault, true);
91
- const { host, trigger } = renderPanel();
92
- openPanel(trigger);
99
+ // The canvas exists before the panel opens, as in the editor.
93
100
  const outside = document.createElement("div");
94
101
  document.body.append(outside);
102
+ const { trigger } = renderPanel();
103
+ openPanel(trigger);
95
104
  const pointerDown = new PointerEvent("pointerdown", {
96
105
  bubbles: true,
97
106
  cancelable: true,
98
107
  button: 0,
99
108
  });
100
109
 
101
- act(() => outside.dispatchEvent(pointerDown));
110
+ // A prevented pointerdown suppresses mousedown and mouseup; the browser still sends the click.
111
+ act(() => {
112
+ outside.dispatchEvent(pointerDown);
113
+ outside.dispatchEvent(
114
+ new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
115
+ );
116
+ });
102
117
 
103
118
  document.removeEventListener("pointerdown", preventDefault, true);
104
119
  expect(pointerDown.defaultPrevented).toBe(true);
105
120
  expect(trigger.getAttribute("aria-expanded")).toBe("false");
106
- expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
121
+ expect(document.querySelector('[aria-label="Jump to frame"]')).toBeNull();
107
122
  });
108
123
 
109
124
  it("does not close on a click inside the panel", () => {
110
- const { host, trigger } = renderPanel();
125
+ const { trigger } = renderPanel();
111
126
  openPanel(trigger);
112
- const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
127
+ const panelInput = document.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
113
128
 
114
129
  act(() => pressAndClick(panelInput));
115
130
 
116
131
  expect(trigger.getAttribute("aria-expanded")).toBe("true");
117
- expect(host.querySelector('[aria-label="Jump to frame"]')).not.toBeNull();
132
+ expect(document.querySelector('[aria-label="Jump to frame"]')).not.toBeNull();
118
133
  });
119
134
 
120
135
  it("toggles once per trigger click", () => {
@@ -140,10 +155,10 @@ describe("ShortcutsPanel", () => {
140
155
  });
141
156
 
142
157
  it("connects the trigger to the dialog with aria-controls", () => {
143
- const { host, trigger } = renderPanel();
158
+ const { trigger } = renderPanel();
144
159
  openPanel(trigger);
145
160
  const panelId = trigger.getAttribute("aria-controls");
146
- const panel = panelId ? host.querySelector<HTMLElement>(`#${CSS.escape(panelId)}`) : null;
161
+ const panel = panelId ? document.querySelector<HTMLElement>(`#${CSS.escape(panelId)}`) : null;
147
162
 
148
163
  expect(panelId).not.toBeNull();
149
164
  expect(panel?.getAttribute("role")).toBe("dialog");
@@ -153,6 +168,21 @@ describe("ShortcutsPanel", () => {
153
168
  expect(panel?.id).toBe(panelId);
154
169
  });
155
170
 
171
+ it("opens on the popup layer, above its button and outside the boxes that hold it", async () => {
172
+ const { host, trigger } = renderPanel();
173
+ // A button at the window's top edge: the panel must not flip down over the timeline.
174
+ trigger.getBoundingClientRect = () => new DOMRect(900, 0, 28, 28);
175
+ openPanel(trigger);
176
+ await act(async () => {});
177
+ const panel = panelOf(trigger);
178
+
179
+ expect(host.contains(panel)).toBe(false);
180
+ expect(panel.closest(`.${POPUP_LAYER}`)).not.toBeNull();
181
+ expect(panel.dataset.side).toBe("top");
182
+ expect(panel.dataset.align).toBe("end");
183
+ expect(panel.classList).toContain("max-h-[min(280px,var(--available-height))]");
184
+ });
185
+
156
186
  it("lists what an embedder passes to PlayerControls instead of Studio's defaults", () => {
157
187
  const listed = (sections?: readonly ShortcutSection[]) => {
158
188
  const host = document.createElement("div");
@@ -162,8 +192,11 @@ describe("ShortcutsPanel", () => {
162
192
  <PlayerControls onTogglePlay={vi.fn()} onSeek={vi.fn()} shortcutSections={sections} />,
163
193
  );
164
194
  });
165
- openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
166
- return host.textContent ?? "";
195
+ const trigger = host.querySelector<HTMLButtonElement>(
196
+ 'button[aria-label="Shortcuts and tools"]',
197
+ )!;
198
+ openPanel(trigger);
199
+ return panelOf(trigger).textContent ?? "";
167
200
  };
168
201
  const withoutSplit = DEFAULT_SHORTCUT_SECTIONS.map((section) => ({
169
202
  ...section,
@@ -196,12 +229,32 @@ describe("ShortcutsPanel", () => {
196
229
  });
197
230
  openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
198
231
 
199
- expect(host.textContent).toContain("Select");
200
- expect(host.textContent).toContain("Snap");
232
+ expect(document.body.textContent).toContain("Select");
233
+ expect(document.body.textContent).toContain("Snap");
201
234
  expect(errors.mock.calls.flat().join(" ")).not.toMatch(/same key/);
202
235
  errors.mockRestore();
203
236
  });
204
237
 
238
+ it("ties the shortcuts button to its tooltip for screen readers", async () => {
239
+ const host = document.createElement("div");
240
+ document.body.append(host);
241
+ act(() => {
242
+ mount(host).render(<ShortcutsButton sections={[{ title: "Host", hints: [] }]} />);
243
+ });
244
+ const button = host.querySelector<HTMLButtonElement>(
245
+ 'button[aria-label="Shortcuts and tools"]',
246
+ )!;
247
+
248
+ act(() => button.focus());
249
+ await act(async () => {
250
+ await new Promise((resolve) => setTimeout(resolve, 0));
251
+ });
252
+
253
+ const tip = document.querySelector<HTMLElement>('[role="tooltip"]');
254
+ expect(tip?.textContent).toBe("Shortcuts and tools");
255
+ expect(button.getAttribute("aria-describedby")).toBe(tip?.id);
256
+ });
257
+
205
258
  it("renders standalone for a host toolbar, reading the player store", () => {
206
259
  const { duration, inPoint, requestSeek: storeSeek } = usePlayerStore.getState();
207
260
  const requestSeek = vi.fn();
@@ -213,10 +266,10 @@ describe("ShortcutsPanel", () => {
213
266
  mount(host).render(<ShortcutsButton sections={[{ title: "Host", hints: [] }]} />);
214
267
  });
215
268
  openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
216
- expect(host.textContent).toContain("Host");
217
- expect(host.textContent).toContain("00:02");
269
+ expect(document.body.textContent).toContain("Host");
270
+ expect(document.body.textContent).toContain("00:02");
218
271
 
219
- const input = host.querySelector<HTMLInputElement>('input[aria-label="Jump to frame"]')!;
272
+ const input = document.querySelector<HTMLInputElement>('input[aria-label="Jump to frame"]')!;
220
273
  act(() => {
221
274
  Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
222
275
  input,
@@ -1,8 +1,7 @@
1
- import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
1
+ import { useState, useCallback, useRef, memo } from "react";
2
2
  import { formatTime, frameToSeconds } from "../lib/time";
3
- import { Tooltip } from "../../components/ui";
3
+ import { Popover, Tooltip } from "../../components/ui";
4
4
  import { flatActive, flatIdle } from "../../components/timelineToolbarStyles";
5
- import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
5
  import { usePlayerStore } from "../store/playerStore";
7
6
  import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
8
7
 
@@ -17,6 +16,9 @@ interface ShortcutsPanelProps {
17
16
  sections?: readonly ShortcutSection[];
18
17
  }
19
18
 
19
+ /** Stays above its button, shrinking to the room there, rather than flipping down over the timeline. */
20
+ const STAY_ABOVE = { side: "none", align: "shift", fallbackAxisSide: "none" } as const;
21
+
20
22
  export const ShortcutsPanel = memo(function ShortcutsPanel({
21
23
  disabled,
22
24
  duration,
@@ -29,23 +31,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
29
31
  }: ShortcutsPanelProps) {
30
32
  const [showShortcuts, setShowShortcuts] = useState(false);
31
33
  const [jumpFrame, setJumpFrame] = useState("");
32
- const shortcutsPanelId = useId();
33
- const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
34
- const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
34
+ // Focus lands on the scrolling body, not the frame input, so keyboard users can scroll and read it.
35
35
  const panelBodyRef = useRef<HTMLDivElement>(null);
36
- const triggerRef = useRef<HTMLButtonElement>(null);
37
-
38
- // Move focus into the panel on open so keyboard users can scroll and read
39
- // it; hand focus back to the trigger on close.
40
- // eslint-disable-next-line no-restricted-syntax
41
- useEffect(() => {
42
- if (!showShortcuts) return;
43
- const trigger = triggerRef.current;
44
- panelBodyRef.current?.focus();
45
- return () => {
46
- trigger?.focus();
47
- };
48
- }, [showShortcuts]);
49
36
 
50
37
  const commitJumpFrame = useCallback(() => {
51
38
  if (disabled) return;
@@ -72,48 +59,40 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
72
59
  );
73
60
 
74
61
  return (
75
- <div ref={shortcutsPanelRef} className="relative shrink-0">
76
- <Tooltip label="Shortcuts and tools">
77
- <button
78
- ref={triggerRef}
79
- type="button"
80
- onClick={() => setShowShortcuts((v) => !v)}
81
- className={showShortcuts ? flatActive : flatIdle}
82
- aria-label="Shortcuts and tools"
83
- aria-expanded={showShortcuts}
84
- aria-controls={shortcutsPanelId}
85
- >
86
- <svg
87
- width="11"
88
- height="11"
89
- viewBox="0 0 24 24"
90
- fill="none"
91
- stroke="currentColor"
92
- strokeWidth="1.75"
93
- strokeLinecap="round"
94
- strokeLinejoin="round"
95
- aria-hidden="true"
62
+ <Tooltip label="Shortcuts and tools">
63
+ <Popover
64
+ open={showShortcuts}
65
+ onOpenChange={setShowShortcuts}
66
+ side="top"
67
+ align="end"
68
+ collisionAvoidance={STAY_ABOVE}
69
+ aria-label="Keyboard shortcuts and tools"
70
+ initialFocus={panelBodyRef}
71
+ className="flex max-h-[min(280px,var(--available-height))] min-w-[220px] flex-col border-border bg-raised p-0"
72
+ trigger={
73
+ <button
74
+ type="button"
75
+ className={`${showShortcuts ? flatActive : flatIdle} shrink-0`}
76
+ aria-label="Shortcuts and tools"
96
77
  >
97
- <rect x="2" y="4" width="20" height="16" rx="2" />
98
- <path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
99
- </svg>
100
- </button>
101
- </Tooltip>
102
- {showShortcuts && (
103
- <div
104
- id={shortcutsPanelId}
105
- ref={panelBodyRef}
106
- tabIndex={-1}
107
- role="dialog"
108
- aria-label="Keyboard shortcuts and tools"
109
- // Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
110
- // not trapped and the rest of the editor stays operable, so claiming
111
- // modality would make assistive tech treat the whole app as inert.
112
- className="absolute bottom-full right-0 mb-2 z-50 rounded-lg border border-border bg-raised shadow-popover min-w-[220px] overflow-y-auto outline-hidden"
113
- style={{
114
- maxHeight: "min(280px, calc(100vh - 80px))",
115
- }}
116
- >
78
+ <svg
79
+ width="11"
80
+ height="11"
81
+ viewBox="0 0 24 24"
82
+ fill="none"
83
+ stroke="currentColor"
84
+ strokeWidth="1.75"
85
+ strokeLinecap="round"
86
+ strokeLinejoin="round"
87
+ aria-hidden="true"
88
+ >
89
+ <rect x="2" y="4" width="20" height="16" rx="2" />
90
+ <path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
91
+ </svg>
92
+ </button>
93
+ }
94
+ >
95
+ <div ref={panelBodyRef} tabIndex={-1} className="min-h-0 overflow-y-auto outline-hidden">
117
96
  <div className="px-3 pt-3 pb-2.5">
118
97
  <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
119
98
  Jump to frame
@@ -247,8 +226,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
247
226
  ))}
248
227
  </div>
249
228
  </div>
250
- )}
251
- </div>
229
+ </Popover>
230
+ </Tooltip>
252
231
  );
253
232
  });
254
233
 
@@ -2,6 +2,7 @@
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  PREVIEW_RASTER_ATTR,
5
+ RUNTIME_STAMPED_ATTR,
5
6
  STUDIO_PREVIEW_LAZY_ATTR,
6
7
  } from "@hyperframes/core/studio-preview-mark";
7
8
  import { liveMarkupWithoutPreviewMarks } from "./authoredSource";
@@ -22,4 +23,13 @@ describe("liveMarkupWithoutPreviewMarks", () => {
22
23
  live.innerHTML = `<img src="a.png" loading="lazy" ${STUDIO_PREVIEW_LAZY_ATTR}>`;
23
24
  expect(liveMarkupWithoutPreviewMarks(live)).toBe('<div><img src="a.png"></div>');
24
25
  });
26
+
27
+ it("drops the runtime's stamp so copied markup cannot carry it into a project file", () => {
28
+ const live = document.createElement("div");
29
+ live.innerHTML = `<div id="title" data-start="0" data-duration="10" ${RUNTIME_STAMPED_ATTR}="1"></div>`;
30
+ live.setAttribute(RUNTIME_STAMPED_ATTR, "1");
31
+ expect(liveMarkupWithoutPreviewMarks(live)).toBe(
32
+ '<div><div id="title" data-start="0" data-duration="10"></div></div>',
33
+ );
34
+ });
25
35
  });