@hyperframes/studio 0.8.136 → 0.8.138

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 (32) hide show
  1. package/dist/assets/{hyperframes-player-ukvUFqdg.js → hyperframes-player-B30apksz.js} +1 -1
  2. package/dist/assets/{index-C-pD9_xY.js → index-BdjA0CLD.js} +147 -147
  3. package/dist/assets/{index-syMK0_9C.css → index-CVd4O6eH.css} +1 -1
  4. package/dist/assets/{index-CRRgIxsS.js → index-DIJuIBwI.js} +1 -1
  5. package/dist/assets/{index-BY_myArg.js → index-DXVvWfQG.js} +1 -1
  6. package/dist/{chunk-QRLKWENQ.js → chunk-4S3AYCOG.js} +2 -2
  7. package/dist/{chunk-NM7BXNZ7.js → chunk-LOVBLSMR.js} +10 -4
  8. package/dist/chunk-LOVBLSMR.js.map +1 -0
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2852 -2852
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +2 -2
  13. package/dist/styles.css +1 -1
  14. package/dist/ui.d.ts +2 -1
  15. package/dist/ui.js +1 -1
  16. package/package.json +9 -8
  17. package/src/App.tsx +2 -7
  18. package/src/components/editor/LayersPanel.test.ts +25 -0
  19. package/src/components/editor/LayersPanel.tsx +1 -1
  20. package/src/components/ui/Tooltip.tsx +10 -3
  21. package/src/hooks/useConsoleErrorCapture.test.tsx +87 -0
  22. package/src/hooks/useConsoleErrorCapture.ts +18 -22
  23. package/src/player/components/ClipPeakMarks.test.tsx +55 -11
  24. package/src/player/components/ClipPeakMarks.tsx +16 -10
  25. package/src/player/components/ClipPeakTooltip.tsx +21 -0
  26. package/src/player/components/Timeline.test.ts +1 -4
  27. package/src/player/components/TimelineClip.tsx +3 -1
  28. package/src/styles/components.css +5 -1
  29. package/src/styles/studio.css +9 -0
  30. package/src/utils/gsapUndoRestore.browser.test.ts +21 -12
  31. package/dist/chunk-NM7BXNZ7.js.map +0 -1
  32. /package/dist/{chunk-QRLKWENQ.js.map → chunk-4S3AYCOG.js.map} +0 -0
package/dist/ui.d.ts CHANGED
@@ -93,9 +93,10 @@ interface TooltipProps {
93
93
  }>;
94
94
  /** Hover delay in ms. */
95
95
  delay?: number;
96
+ disabled?: boolean;
96
97
  side?: "top" | "bottom" | "left" | "right";
97
98
  }
98
- declare function Tooltip({ label, children, delay, side }: TooltipProps): react_jsx_runtime.JSX.Element;
99
+ declare function Tooltip({ label, children, delay, side, disabled, }: TooltipProps): react_jsx_runtime.JSX.Element;
99
100
 
100
101
  /**
101
102
  * Studio's tailwind-merge config: teaches it the two scales `theme.css`
package/dist/ui.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  fieldBase,
31
31
  fieldText,
32
32
  popupSurface
33
- } from "./chunk-NM7BXNZ7.js";
33
+ } from "./chunk-LOVBLSMR.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.136",
3
+ "version": "0.8.138",
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.136",
64
- "@hyperframes/sdk": "0.8.136",
65
- "@hyperframes/shader-transitions": "0.8.136",
66
- "@hyperframes/player": "0.8.136",
67
- "@hyperframes/studio-server": "0.8.136",
68
- "@hyperframes/parsers": "0.8.136"
63
+ "@hyperframes/parsers": "0.8.138",
64
+ "@hyperframes/core": "0.8.138",
65
+ "@hyperframes/player": "0.8.138",
66
+ "@hyperframes/sdk": "0.8.138",
67
+ "@hyperframes/shader-transitions": "0.8.138",
68
+ "@hyperframes/studio-server": "0.8.138"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -73,6 +73,7 @@
73
73
  "@types/react-dom": "19",
74
74
  "@vitejs/plugin-react": "^4.0.0",
75
75
  "chokidar": "^4.0.3",
76
+ "esbuild": "^0.25.12",
76
77
  "fake-indexeddb": "^6.2.5",
77
78
  "puppeteer-core": "^25.2.1",
78
79
  "tailwindcss": "^4.3.3",
@@ -81,7 +82,7 @@
81
82
  "vite": "^6.4.2",
82
83
  "vitest": "^4.1.11",
83
84
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.136"
85
+ "@hyperframes/producer": "0.8.138"
85
86
  },
86
87
  "peerDependencies": {
87
88
  "react": "19",
package/src/App.tsx CHANGED
@@ -357,11 +357,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
357
357
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
358
358
  });
359
359
  const frameCapture = useStableHandlers(frameCaptureResult, projectId);
360
- const {
361
- consoleErrors,
362
- setConsoleErrors,
363
- resetErrors: resetConsoleErrors,
364
- } = useConsoleErrorCapture(previewIframe);
360
+ const { consoleErrors, setConsoleErrors } = useConsoleErrorCapture(previewIframe);
365
361
  const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
366
362
  const handleToggleRecordingRef = useRef<(method?: "button" | "keyboard") => void>(() => {});
367
363
  const domEditSessionRef = useRef(domEditSession);
@@ -385,10 +381,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
385
381
  previewIframeRef.current = iframe;
386
382
  usePreviewIframeStore.getState().setIframe(iframe);
387
383
  appHotkeys.syncPreviewHotkeys(iframe);
388
- resetConsoleErrors();
389
384
  refreshPreviewDocumentVersion();
390
385
  },
391
- [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
386
+ [appHotkeys, refreshPreviewDocumentVersion],
392
387
  );
393
388
  const rightPanel = useRightPanelIntent();
394
389
  const { inspectorPanelActive, shouldShowMotionPath, shouldShowSelectedDomBounds } =
@@ -306,4 +306,29 @@ describe("LayersPanel preview promotion", () => {
306
306
  expect(collectedRootIds()).toEqual(["a"]);
307
307
  fetchSpy.mockRestore();
308
308
  });
309
+
310
+ it("a row press starts no text selection, so dragging a row leaves no letters highlighted", () => {
311
+ const doc = new Window().document;
312
+ doc.body.innerHTML = `<div data-composition-id="a"><div id="back"></div><div id="front"></div></div>`;
313
+ const iframe = document.createElement("iframe");
314
+ Object.defineProperty(iframe, "contentDocument", { value: doc });
315
+ const item = (id: string) =>
316
+ ({
317
+ key: id,
318
+ element: doc.getElementById(id),
319
+ label: id,
320
+ depth: 0,
321
+ childCount: 0,
322
+ id,
323
+ }) as unknown as DomEditLayerItem;
324
+ mocks.collect.mockReset().mockReturnValue([item("back"), item("front")]);
325
+ mocks.previewIframeRef.current = iframe;
326
+ usePreviewIframeStore.setState({ iframe });
327
+ act(() => root.render(createElement(LayersPanel)));
328
+ act(() => void iframe.dispatchEvent(new Event("load")));
329
+
330
+ const rows = [...host.querySelectorAll("[data-layer-index]")];
331
+ expect(rows).toHaveLength(2);
332
+ expect(rows.every((row) => row.closest(".select-none"))).toBe(true);
333
+ });
309
334
  });
@@ -433,7 +433,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
433
433
  </div>
434
434
  <div
435
435
  ref={scrollContainerRef}
436
- className="relative min-h-0 flex-1 overflow-y-auto py-1"
436
+ className="relative min-h-0 flex-1 select-none overflow-y-auto py-1"
437
437
  onPointerMove={handleContainerPointerMove}
438
438
  onPointerUp={handleContainerPointerUp}
439
439
  onPointerCancel={handleContainerPointerUp}
@@ -14,6 +14,7 @@ interface TooltipProps {
14
14
  children: ReactElement<{ "aria-describedby"?: string }>;
15
15
  /** Hover delay in ms. */
16
16
  delay?: number;
17
+ disabled?: boolean;
17
18
  side?: "top" | "bottom" | "left" | "right";
18
19
  }
19
20
 
@@ -21,16 +22,22 @@ interface TooltipProps {
21
22
  const SIDE_OFFSET = 6;
22
23
  const VIEWPORT_MARGIN = 8;
23
24
 
24
- export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
25
+ export function Tooltip({
26
+ label,
27
+ children,
28
+ delay = 400,
29
+ side = "top",
30
+ disabled = false,
31
+ }: TooltipProps) {
25
32
  const [open, setOpen] = useState(false);
26
33
  const boxRef = useRef<HTMLSpanElement>(null);
27
34
  const tooltipId = useId();
28
35
 
29
36
  return (
30
- <BaseTooltip.Root open={open} onOpenChange={setOpen}>
37
+ <BaseTooltip.Root open={open} onOpenChange={setOpen} disabled={disabled}>
31
38
  <BaseTooltip.Trigger delay={delay} render={<span ref={boxRef} className="contents" />}>
32
39
  {cloneElement(children, {
33
- "aria-describedby": open ? tooltipId : children.props["aria-describedby"],
40
+ "aria-describedby": open && !disabled ? tooltipId : children.props["aria-describedby"],
34
41
  })}
35
42
  </BaseTooltip.Trigger>
36
43
  <BaseTooltip.Portal>
@@ -0,0 +1,87 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, expect, it } from "vitest";
6
+ import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
7
+ import { useConsoleErrorCapture } from "./useConsoleErrorCapture";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+
11
+ let capture: ReturnType<typeof useConsoleErrorCapture>;
12
+ function Harness({ iframe }: { iframe: HTMLIFrameElement }) {
13
+ capture = useConsoleErrorCapture(iframe);
14
+ return null;
15
+ }
16
+
17
+ function previewFrame() {
18
+ const previewWindow = Object.assign(new EventTarget(), { console: { error: () => undefined } });
19
+ const iframe = document.createElement("iframe");
20
+ Object.defineProperty(iframe, "contentWindow", { get: () => previewWindow });
21
+ return { iframe, previewWindow };
22
+ }
23
+
24
+ const shown = () => capture.consoleErrors?.map((finding) => finding.message);
25
+
26
+ let root: Root | null = null;
27
+ afterEach(async () => {
28
+ await act(async () => root?.unmount());
29
+ root = null;
30
+ });
31
+
32
+ it("shows the errors a preview raised before its load, once each", async () => {
33
+ const raisedBeforeLoad = "Uncaught Error: GSAP could not load from a or b";
34
+ const { iframe, previewWindow } = previewFrame();
35
+
36
+ root = createRoot(document.createElement("div"));
37
+ await act(async () => root?.render(React.createElement(Harness, { iframe })));
38
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
39
+ await act(async () => iframe.dispatchEvent(new Event("load")));
40
+
41
+ expect(shown()).toEqual([raisedBeforeLoad]);
42
+ });
43
+
44
+ it("shows a loaded preview's errors once when the capture attaches to it twice", async () => {
45
+ const raised = "Uncaught Error: GSAP could not load from a or b";
46
+ const { iframe, previewWindow } = previewFrame();
47
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raised] });
48
+
49
+ root = createRoot(document.createElement("div"));
50
+ const harness = React.createElement(Harness, { iframe });
51
+ await act(async () => root?.render(React.createElement(React.StrictMode, null, harness)));
52
+
53
+ expect(shown()).toEqual([raised]);
54
+ });
55
+
56
+ it("keeps listening after reading a preview's very long error list", async () => {
57
+ const { iframe, previewWindow } = previewFrame();
58
+ const raised = Array.from({ length: 200_000 }, (_, i) => `Uncaught Error: ${i}`);
59
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: raised });
60
+
61
+ root = createRoot(document.createElement("div"));
62
+ await act(async () => root?.render(React.createElement(Harness, { iframe })));
63
+ const live = new ErrorEvent("error", { message: "Uncaught Error: raised after attach" });
64
+ await act(async () => previewWindow.dispatchEvent(live));
65
+
66
+ expect(shown()?.length).toBe(raised.length + 1);
67
+ expect(shown()?.at(-1)).toBe("Uncaught Error: raised after attach");
68
+ });
69
+
70
+ it("clears a document's errors when its load replaces it, and when the preview goes away", async () => {
71
+ const { iframe, previewWindow } = previewFrame();
72
+ const NullableHarness = ({ frame }: { frame: HTMLIFrameElement | null }) => {
73
+ capture = useConsoleErrorCapture(frame);
74
+ return null;
75
+ };
76
+ root = createRoot(document.createElement("div"));
77
+ await act(async () => root?.render(React.createElement(NullableHarness, { frame: iframe })));
78
+ await act(async () =>
79
+ previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
80
+ );
81
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
82
+ await act(async () => iframe.dispatchEvent(new Event("load")));
83
+ expect(shown()).toEqual(["Uncaught Error: new"]);
84
+
85
+ await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
86
+ expect(capture.consoleErrors).toBeNull();
87
+ });
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
2
3
  import type { LintFinding } from "../components/LintModal";
3
4
 
4
5
  /**
@@ -16,6 +17,7 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
16
17
 
17
18
  // eslint-disable-next-line no-restricted-syntax
18
19
  useEffect(() => {
20
+ resetErrors();
19
21
  if (!previewIframe) return;
20
22
 
21
23
  let patchedWin: (Window & typeof globalThis) | null = null;
@@ -40,49 +42,43 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
40
42
 
41
43
  const attachErrorCapture = () => {
42
44
  detachErrorCapture();
45
+ resetErrors();
43
46
  try {
44
47
  const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
45
48
  if (!win) return;
46
49
  if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
47
50
  (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
48
51
  patchedWin = win;
49
- origConsoleError = win.console.error.bind(win.console);
50
- win.console.error = function (...args: unknown[]) {
51
- origConsoleError!(...args);
52
- const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
53
- if (text.includes("favicon")) return;
52
+ const record = (texts: readonly string[]) => {
53
+ if (texts.length === 0) return;
54
54
  consoleErrorsRef.current = [
55
55
  ...consoleErrorsRef.current,
56
- { severity: "error", message: text },
56
+ ...texts.map((message) => ({ severity: "error" as const, message })),
57
57
  ];
58
58
  setConsoleErrors([...consoleErrorsRef.current]);
59
59
  };
60
- errorHandler = (e: ErrorEvent) => {
61
- const text = e.message || String(e);
62
- consoleErrorsRef.current = [
63
- ...consoleErrorsRef.current,
64
- { severity: "error", message: text },
65
- ];
66
- setConsoleErrors([...consoleErrorsRef.current]);
60
+ origConsoleError = win.console.error.bind(win.console);
61
+ win.console.error = function (...args: unknown[]) {
62
+ origConsoleError!(...args);
63
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
64
+ if (!text.includes("favicon")) record([text]);
67
65
  };
66
+ errorHandler = (e: ErrorEvent) => record([e.message || String(e)]);
68
67
  win.addEventListener("error", errorHandler);
68
+ const raised: unknown = Reflect.get(win, STUDIO_PREVIEW_ERRORS);
69
+ if (Array.isArray(raised)) record(raised.map(String));
69
70
  } catch {
70
71
  /* same-origin only */
71
72
  }
72
73
  };
73
74
 
74
75
  attachErrorCapture();
75
- const handleLoad = () => {
76
- consoleErrorsRef.current = [];
77
- setConsoleErrors(null);
78
- attachErrorCapture();
79
- };
80
- previewIframe.addEventListener("load", handleLoad);
76
+ previewIframe.addEventListener("load", attachErrorCapture);
81
77
  return () => {
82
- previewIframe.removeEventListener("load", handleLoad);
78
+ previewIframe.removeEventListener("load", attachErrorCapture);
83
79
  detachErrorCapture();
84
80
  };
85
- }, [previewIframe]);
81
+ }, [previewIframe, resetErrors]);
86
82
 
87
- return { consoleErrors, setConsoleErrors, resetErrors };
83
+ return { consoleErrors, setConsoleErrors };
88
84
  }
@@ -3,6 +3,9 @@ import { act } from "react";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { createHappyDomRootHarness } from "./testRootHarness";
5
5
  import { ClipPeakMarks } from "./ClipPeakMarks";
6
+ import { TimelineClip } from "./TimelineClip";
7
+
8
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
6
9
 
7
10
  const harness = createHappyDomRootHarness();
8
11
 
@@ -13,29 +16,70 @@ async function render(url: string, bins: number[], gain: number) {
13
16
  );
14
17
  const host = document.createElement("div");
15
18
  document.body.appendChild(host);
16
- await act(async () => {
17
- harness.mount(host).render(
18
- <ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={gain}>
19
+ const root = harness.mount(host);
20
+ const view = (volume: number) => (
21
+ <TimelineClip
22
+ el={{ id: "audio", tag: "audio", start: 0, duration: 2, track: 0 }}
23
+ pps={16}
24
+ clipY={0}
25
+ isSelected={false}
26
+ isHovered={false}
27
+ hasCustomContent
28
+ capabilities={{ canMove: true, canTrimStart: true, canTrimEnd: true }}
29
+ isComposition={false}
30
+ tabIndex={0}
31
+ onHoverStart={() => {}}
32
+ onHoverEnd={() => {}}
33
+ onClick={() => {}}
34
+ onDoubleClick={() => {}}
35
+ >
36
+ <ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={volume}>
19
37
  <span>wave</span>
20
- </ClipPeakMarks>,
21
- );
22
- });
38
+ </ClipPeakMarks>
39
+ </TimelineClip>
40
+ );
41
+ await act(async () => root.render(view(gain)));
23
42
  await act(async () => {});
24
43
  vi.unstubAllGlobals();
25
- return host;
44
+ return { host, setGain: async (value: number) => act(async () => root.render(view(value))) };
26
45
  }
27
46
 
28
47
  describe("ClipPeakMarks", () => {
29
- it("paints red marks and the peak on a clip that redlines", async () => {
30
- const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
48
+ it("uses a plain warning and describes the focused clip with the numeric peak", async () => {
49
+ const { host } = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
31
50
  expect(host.textContent).toContain("wave");
32
51
  expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain(
33
- "▲ peaks −0.2 dBFS",
52
+ "▲ Too loud",
34
53
  );
54
+ expect(host.querySelector("[data-peak-text]")?.textContent).not.toContain("dBFS");
55
+ const clip = host.querySelector("button")!;
56
+ await act(async () => clip.focus());
57
+ await vi.waitFor(() =>
58
+ expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(
59
+ "Peaks −0.2 dBFS at this volume; export lowers the whole mix",
60
+ ),
61
+ );
62
+ expect(clip.getAttribute("aria-describedby")).toBe(
63
+ document.querySelector('[role="tooltip"]')?.id,
64
+ );
65
+ expect(host.querySelectorAll("button")).toHaveLength(1);
66
+ });
67
+
68
+ it("removes the focused warning when volume becomes quiet without replacing the clip", async () => {
69
+ const { host, setGain } = await render("/api/projects/p/peaks/changing.mp4", [1, 1], 1);
70
+ const clip = host.querySelector("button")!;
71
+ await act(async () => clip.focus());
72
+ await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
73
+ await setGain(0.25);
74
+ await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
75
+ expect(clip.getAttribute("aria-describedby")).toBeNull();
76
+ expect(document.activeElement).toBe(clip);
77
+ expect(host.querySelector("button")).toBe(clip);
78
+ expect(host.querySelector("[data-peak-badge]")).toBeNull();
35
79
  });
36
80
 
37
81
  it("paints nothing on a quiet clip", async () => {
38
- const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
82
+ const { host } = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
39
83
  expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull();
40
84
  });
41
85
  });
@@ -1,6 +1,7 @@
1
- import { useEffect, useState, type ReactNode } from "react";
1
+ import { useContext, useEffect, useState, type ReactNode } from "react";
2
2
  import { clipPeakRuns, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
3
3
  import { loadPeakMap } from "./clipPeakMap";
4
+ import { ClipPeakDescriptionContext } from "./ClipPeakTooltip";
4
5
 
5
6
  const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
6
7
 
@@ -30,15 +31,20 @@ export function ClipPeakMarks({
30
31
  const { runs, peakDbfs } = map
31
32
  ? clipPeakRuns(map, sourceWindow, gain)
32
33
  : { runs: [], peakDbfs: null };
34
+ const detail =
35
+ runs.length > 0 && peakDbfs !== null
36
+ ? `Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`
37
+ : null;
38
+ const setDetail = useContext(ClipPeakDescriptionContext);
39
+ useEffect(() => {
40
+ setDetail?.(detail);
41
+ return () => setDetail?.(null);
42
+ }, [detail, setDetail]);
33
43
  return (
34
- <div className="relative h-full w-full">
44
+ <div className="relative isolate h-full w-full">
35
45
  {children}
36
- {runs.length > 0 && peakDbfs !== null && (
37
- <div
38
- className="pointer-events-none absolute inset-0"
39
- data-testid="clip-peak-marks"
40
- title={`Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`}
41
- >
46
+ {detail !== null && (
47
+ <div className="pointer-events-none absolute inset-0" data-testid="clip-peak-marks">
42
48
  {runs.map((run) => (
43
49
  <div
44
50
  key={run.from}
@@ -50,10 +56,10 @@ export function ClipPeakMarks({
50
56
  />
51
57
  ))}
52
58
  <span
53
- className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400"
59
+ className="pointer-events-auto absolute right-4 bottom-1 font-mono text-[9px] leading-none text-red-400"
54
60
  data-peak-badge
55
61
  >
56
- ▲<span data-peak-text> peaks {dbText(peakDbfs)}</span>
62
+ ▲<span data-peak-text> Too loud</span>
57
63
  </span>
58
64
  </div>
59
65
  )}
@@ -0,0 +1,21 @@
1
+ import { cloneElement, createContext, useState, type ReactElement } from "react";
2
+ import { Tooltip } from "../../components/ui/Tooltip";
3
+
4
+ export const ClipPeakDescriptionContext = createContext<((detail: string | null) => void) | null>(
5
+ null,
6
+ );
7
+
8
+ export function ClipPeakTooltip({
9
+ children,
10
+ }: {
11
+ children: ReactElement<{ title?: string; "aria-describedby"?: string }>;
12
+ }) {
13
+ const [detail, setDetail] = useState<string | null>(null);
14
+ return (
15
+ <ClipPeakDescriptionContext.Provider value={setDetail}>
16
+ <Tooltip label={detail ?? ""} disabled={detail === null}>
17
+ {cloneElement(children, { title: detail === null ? children.props.title : undefined })}
18
+ </Tooltip>
19
+ </ClipPeakDescriptionContext.Provider>
20
+ );
21
+ }
@@ -87,11 +87,8 @@ describe("timeline viewport geometry", () => {
87
87
  });
88
88
  });
89
89
 
90
- /** The track's content row: a clip's parent, past its `display: contents` wrapper. */
91
90
  function trackContentOf(clip: HTMLElement | null | undefined): HTMLElement | null {
92
- let row = clip?.parentElement ?? null;
93
- while (row?.style.display === "contents") row = row.parentElement;
94
- return row;
91
+ return clip?.closest<HTMLElement>('[role="gridcell"]') ?? null;
95
92
  }
96
93
 
97
94
  function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
@@ -16,6 +16,7 @@ import { ClipBadges } from "./ClipBadges";
16
16
  import { linkLabelColor } from "./linkLabelColor";
17
17
  import { OutOfSyncBadge } from "./OutOfSyncBadge";
18
18
  import { clipSpeedSuffix } from "./clipToolAttrs";
19
+ import { ClipPeakTooltip } from "./ClipPeakTooltip";
19
20
 
20
21
  interface TimelineClipProps {
21
22
  el: TimelineElement;
@@ -128,7 +129,7 @@ export const TimelineClip = memo(function TimelineClip({
128
129
  ...passengerStyle,
129
130
  };
130
131
 
131
- return (
132
+ const clip = (
132
133
  <button
133
134
  type="button"
134
135
  data-clip={isGestureActor ? undefined : "true"}
@@ -256,4 +257,5 @@ export const TimelineClip = memo(function TimelineClip({
256
257
  )}
257
258
  </button>
258
259
  );
260
+ return isAudioClip || el.hasAudio ? <ClipPeakTooltip>{clip}</ClipPeakTooltip> : clip;
259
261
  });
@@ -314,14 +314,18 @@
314
314
  display: none;
315
315
  }
316
316
 
317
+ [data-peak-badge] {
318
+ z-index: 20;
319
+ }
320
+
317
321
  .audible-video-wave [data-peak-badge] {
318
322
  top: 50%;
323
+ bottom: auto;
319
324
  right: var(--timeline-clip-radius);
320
325
  translate: 0 -50%;
321
326
  padding: 1px 3px;
322
327
  border-radius: 3px;
323
328
  background: var(--timeline-clip-chip-audio-bg);
324
- z-index: 20;
325
329
  }
326
330
 
327
331
  .timeline-clip.is-audio.is-hovered {
@@ -45,4 +45,13 @@ body {
45
45
  */
46
46
  height: 100vh;
47
47
  height: 100dvh;
48
+ /*
49
+ * Nothing may overflow #root. Dockview parks hidden panel overlays below
50
+ * the dock, which made the document ~2x the viewport tall; body's
51
+ * overflow:hidden only blocks user scrolling, so scrollIntoView, focus()
52
+ * and find-in-page still scrolled the viewport and cut off the header
53
+ * (repro: select a clip, open the Grade group). `clip`, not `hidden`,
54
+ * which is itself programmatically scrollable.
55
+ */
56
+ overflow: clip;
48
57
  }
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import puppeteer, { type Browser } from "puppeteer-core";
8
- import { build } from "vite";
8
+ import { build, type Plugin } from "esbuild";
9
9
  import { afterAll, beforeAll, expect, it, vi } from "vitest";
10
10
  import { findSystemChrome } from "../../vite.browser";
11
11
  import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
@@ -18,20 +18,29 @@ let browser: Browser;
18
18
  let undoBundle: string;
19
19
  let softReloadBundle: string;
20
20
 
21
+ // recast imports these, and its code that uses them is dropped from the bundle. A new
22
+ // built-in fails the build instead of turning into an empty module the way Vite makes it.
23
+ const emptyNodeBuiltins: Plugin = {
24
+ name: "empty-node-builtins",
25
+ setup(build) {
26
+ build.onResolve({ filter: /^(node:)?(fs|os)$/ }, ({ path }) => ({ path, namespace: "empty" }));
27
+ build.onLoad({ filter: /.*/, namespace: "empty" }, () => ({ contents: "module.exports = {}" }));
28
+ },
29
+ };
30
+
21
31
  async function bundle(file: string, name: string): Promise<string> {
22
32
  const out = await build({
23
- configFile: false,
24
- logLevel: "silent",
25
- resolve: {
26
- alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
27
- },
28
- build: {
29
- write: false,
30
- minify: false,
31
- lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
32
- },
33
+ entryPoints: [fileURLToPath(new URL(file, import.meta.url))],
34
+ bundle: true,
35
+ write: false,
36
+ platform: "browser",
37
+ format: "iife",
38
+ globalName: name,
39
+ alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
40
+ plugins: [emptyNodeBuiltins],
41
+ define: { "import.meta.env": "{}" },
33
42
  });
34
- return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
43
+ return out.outputFiles[0]!.text;
35
44
  }
36
45
 
37
46
  beforeAll(async () => {