@hyperframes/studio 0.8.190 → 0.8.192

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 (34) hide show
  1. package/dist/assets/{hyperframes-player-0HXJPmYC.js → hyperframes-player-ChN3cOr0.js} +3 -3
  2. package/dist/assets/{index-DsSfhjUS.js → index-BYbFcRR0.js} +1 -1
  3. package/dist/assets/{index-BYw97F07.js → index-CHu83hHM.js} +126 -125
  4. package/dist/assets/{index-B3vk3hfU.css → index-CYbvOVp-.css} +1 -1
  5. package/dist/{chunk-563JPIL4.js → chunk-6SXUUBPD.js} +9 -3
  6. package/dist/{chunk-FF5DM7BQ.js → chunk-7HZGKFFU.js} +18 -4
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.html +2 -2
  9. package/dist/index.js +509 -282
  10. package/dist/player.js +2 -2
  11. package/dist/styles.css +1 -1
  12. package/dist/ui.d.ts +3 -1
  13. package/dist/ui.js +1 -1
  14. package/package.json +9 -8
  15. package/src/captions/parser.dom.test.ts +126 -0
  16. package/src/captions/parser.test.ts +71 -1
  17. package/src/captions/parser.ts +121 -65
  18. package/src/components/StudioRightPanels.tsx +9 -0
  19. package/src/components/dock/DockTab.tsx +2 -0
  20. package/src/components/dock/dockAccessibility.test.ts +3 -3
  21. package/src/components/dock/dockLayout.ts +1 -1
  22. package/src/components/dock/panelRegistry.ts +6 -0
  23. package/src/components/panels/MotionBlurPanel.test.tsx +177 -0
  24. package/src/components/panels/MotionBlurPanel.tsx +163 -0
  25. package/src/components/panels/motionBlurPanelModel.test.ts +143 -0
  26. package/src/components/panels/motionBlurPanelModel.ts +98 -0
  27. package/src/components/ui/Tooltip.test.tsx +32 -0
  28. package/src/components/ui/Tooltip.tsx +23 -3
  29. package/src/hooks/usePanelLayout.test.ts +1 -1
  30. package/src/player/components/TimelineClip.filmstrip.test.tsx +1 -1
  31. package/src/player/components/timelineMotionStyles.test.ts +1 -1
  32. package/src/styles/theme.css +1 -1
  33. package/src/utils/studioHelpers.ts +2 -1
  34. package/src/utils/studioUrlState.ts +8 -1
@@ -0,0 +1,177 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { openComposition } from "@hyperframes/sdk";
7
+ import { MotionBlurPanel } from "./MotionBlurPanel";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const page = (attrs = "") =>
12
+ `<!DOCTYPE html><html><body><div data-composition-id="main" data-hf-root data-duration="5" data-width="1920" data-height="1080"${attrs}></div></body></html>`;
13
+
14
+ const shell = {
15
+ activeCompPath: null as string | null,
16
+ previewIframeRef: { current: null as HTMLIFrameElement | null },
17
+ showToast: vi.fn(),
18
+ };
19
+
20
+ vi.mock("../../contexts/StudioContext", () => ({
21
+ useStudioShellContext: () => shell,
22
+ useStudioPlaybackContext: () => ({ refreshKey: 0 }),
23
+ }));
24
+
25
+ const files: Record<string, string> = {};
26
+ const writeProjectFile = vi.fn(async (path: string, content: string) => {
27
+ files[path] = content;
28
+ });
29
+ vi.mock("../../contexts/FileManagerContext", () => ({
30
+ useFileManagerContext: () => ({
31
+ readProjectFile: async (path: string) => files[path] ?? "",
32
+ writeProjectFile,
33
+ compositions: ["index.html", "scene.html"],
34
+ }),
35
+ }));
36
+
37
+ let root: Root | null = null;
38
+ const recordEdit = vi.fn(async () => {});
39
+
40
+ function cleanup() {
41
+ act(() => root?.unmount());
42
+ root = null;
43
+ document.body.innerHTML = "";
44
+ }
45
+
46
+ afterEach(() => {
47
+ cleanup();
48
+ shell.activeCompPath = null;
49
+ shell.previewIframeRef.current = null;
50
+ vi.clearAllMocks();
51
+ });
52
+
53
+ async function mount(attrs = "") {
54
+ files["index.html"] = page(attrs);
55
+ const host = document.createElement("div");
56
+ document.body.append(host);
57
+ root = createRoot(host);
58
+ const session = await openComposition(files["index.html"], { history: false });
59
+ await act(async () => {
60
+ root?.render(
61
+ <MotionBlurPanel
62
+ sdkSession={session}
63
+ publishSdkSession={vi.fn()}
64
+ reloadPreview={vi.fn()}
65
+ recordEdit={recordEdit}
66
+ />,
67
+ );
68
+ });
69
+ return host;
70
+ }
71
+
72
+ const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
73
+ const click = async (el: Element | null) => {
74
+ await act(async () => (el as HTMLElement).click());
75
+ await settle();
76
+ };
77
+
78
+ const radio = (host: HTMLElement, label: string) =>
79
+ [...host.querySelectorAll('[role="radio"]')].find((el) => el.textContent === label) ?? null;
80
+ const checked = (host: HTMLElement) =>
81
+ [...host.querySelectorAll('[role="radio"]')]
82
+ .filter((el) => el.getAttribute("aria-checked") === "true")
83
+ .map((el) => el.textContent);
84
+
85
+ describe("MotionBlurPanel", () => {
86
+ it("offers Off, Subtle and Strong and nothing else", async () => {
87
+ const host = await mount();
88
+ expect([...host.querySelectorAll('[role="radio"]')].map((el) => el.textContent)).toEqual([
89
+ "Off",
90
+ "Subtle",
91
+ "Strong",
92
+ ]);
93
+ expect(
94
+ host.querySelectorAll('[role="slider"], [role="switch"], [role="combobox"], input'),
95
+ ).toHaveLength(0);
96
+ });
97
+
98
+ it("shows the level the root is at", async () => {
99
+ expect(checked(await mount())).toEqual(["Off"]);
100
+ cleanup();
101
+ expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Subtle"]);
102
+ cleanup();
103
+ expect(
104
+ checked(
105
+ await mount(
106
+ ' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
107
+ ),
108
+ ),
109
+ ).toEqual(["Strong"]);
110
+ });
111
+
112
+ it("Subtle writes data-motion-blur and leaves the default shutter off the root", async () => {
113
+ const host = await mount(
114
+ ' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
115
+ );
116
+ await click(radio(host, "Subtle"));
117
+ const html = files["index.html"] ?? "";
118
+ expect(html).toContain('data-motion-blur="on"');
119
+ expect(html).not.toContain("data-motion-blur-shutter");
120
+ expect(html).not.toContain("data-motion-blur-samples");
121
+ expect(recordEdit).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ it("Strong writes the maximum shutter and samples", async () => {
125
+ const host = await mount();
126
+ await click(radio(host, "Strong"));
127
+ const html = files["index.html"] ?? "";
128
+ expect(html).toContain('data-motion-blur="on"');
129
+ expect(html).toContain('data-motion-blur-shutter="720"');
130
+ expect(html).toContain('data-motion-blur-samples="64"');
131
+ });
132
+
133
+ it("Off removes data-motion-blur and leaves every other setting on the root", async () => {
134
+ const host = await mount(
135
+ ' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64" data-motion-blur-tier="filter" data-motion-blur-events="on"',
136
+ );
137
+ await click(radio(host, "Off"));
138
+ const html = files["index.html"] ?? "";
139
+ expect(html).not.toContain('data-motion-blur="on"');
140
+ for (const kept of ['shutter="720"', 'samples="64"', 'tier="filter"', 'events="on"']) {
141
+ expect(html).toContain(`data-motion-blur-${kept}`);
142
+ }
143
+ });
144
+
145
+ it("writes nothing when the chosen level is already set", async () => {
146
+ const host = await mount(' data-motion-blur="on"');
147
+ await click(radio(host, "Subtle"));
148
+ expect(writeProjectFile).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it("follows the live preview over the session, so an undo or an outside edit shows", async () => {
152
+ const doc = document.implementation.createHTMLDocument("preview");
153
+ doc.body.innerHTML =
154
+ '<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720"></div>';
155
+ shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
156
+ expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Strong"]);
157
+ });
158
+
159
+ it("writes the level picked even when the preview and the file disagree", async () => {
160
+ const doc = document.implementation.createHTMLDocument("preview");
161
+ doc.body.innerHTML =
162
+ '<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"></div>';
163
+ shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
164
+ const host = await mount();
165
+ await click(radio(host, "Subtle"));
166
+ expect(files["index.html"]).toContain('data-motion-blur="on"');
167
+ expect(files["index.html"]).not.toContain("data-motion-blur-samples");
168
+ });
169
+
170
+ it("is read-only for a sub-composition, which blur does not read", async () => {
171
+ shell.activeCompPath = "scene.html";
172
+ const host = await mount();
173
+ expect(host.textContent).toContain("read from the main composition");
174
+ expect(radio(host, "Strong")?.hasAttribute("disabled")).toBe(true);
175
+ expect(checked(host)).toEqual([]);
176
+ });
177
+ });
@@ -0,0 +1,163 @@
1
+ import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
2
+ import type { Composition } from "@hyperframes/sdk";
3
+ import type { MotionBlurSettings } from "@hyperframes/core/motion-blur-settings";
4
+ import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
5
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
6
+ import {
7
+ useVariablesPersist,
8
+ type UseVariablesPersistParams,
9
+ } from "../../hooks/useVariablesPersist";
10
+ import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
11
+ import { Button } from "../ui";
12
+ import {
13
+ BLUR_LEVELS,
14
+ changedEdits,
15
+ findCompositionRoot,
16
+ levelOf,
17
+ patchForLevel,
18
+ readPreviewRootAttributes,
19
+ sameAttributes,
20
+ settingsOf,
21
+ withEdits,
22
+ type BlurLevel,
23
+ type RootAttributes,
24
+ } from "./motionBlurPanelModel";
25
+
26
+ export interface MotionBlurPanelProps {
27
+ sdkSession: Composition | null;
28
+ publishSdkSession?: UseVariablesPersistParams["publishSdkSession"];
29
+ reloadPreview: () => void;
30
+ recordEdit: UseVariablesPersistParams["recordEdit"];
31
+ }
32
+
33
+ const POLL_MS = 500;
34
+ /** After a write the preview is reloading: keep what was just written instead of the old document. */
35
+ const SETTLE_MS = 1500;
36
+
37
+ /** Thrown from the edit when the file already holds the level, so nothing is written or recorded for undo. */
38
+ class NothingToChange extends Error {}
39
+
40
+ /** The preview's own root attributes, polled; `hold` pins an optimistic value until the preview catches up. */
41
+ function useLivePreviewAttributes(
42
+ previewIframeRef: { current: HTMLIFrameElement | null },
43
+ refreshKey: number,
44
+ ) {
45
+ const [live, setLive] = useState<RootAttributes | null>(null);
46
+ const holdUntil = useRef(0);
47
+ useEffect(() => {
48
+ const read = () => {
49
+ const found = readPreviewRootAttributes(previewIframeRef.current);
50
+ if (found && Date.now() >= holdUntil.current) {
51
+ setLive((current) => (sameAttributes(current, found) ? current : found));
52
+ }
53
+ };
54
+ read();
55
+ const timer = setInterval(read, POLL_MS);
56
+ return () => clearInterval(timer);
57
+ }, [previewIframeRef, refreshKey]);
58
+ const hold = useCallback((next: (current: RootAttributes | null) => RootAttributes) => {
59
+ holdUntil.current = Date.now() + SETTLE_MS;
60
+ setLive(next);
61
+ }, []);
62
+ return [live, hold] as const;
63
+ }
64
+
65
+ export const MotionBlurPanel = memo(function MotionBlurPanel({
66
+ sdkSession,
67
+ publishSdkSession,
68
+ reloadPreview,
69
+ recordEdit,
70
+ }: MotionBlurPanelProps) {
71
+ const { activeCompPath, previewIframeRef, showToast } = useStudioShellContext();
72
+ const { refreshKey } = useStudioPlaybackContext();
73
+ const { readProjectFile, writeProjectFile, compositions } = useFileManagerContext();
74
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
75
+ const [live, hold] = useLivePreviewAttributes(previewIframeRef, refreshKey);
76
+
77
+ useEffect(() => sdkSession?.on("change", rerender), [sdkSession]);
78
+
79
+ const root = sdkSession ? findCompositionRoot(sdkSession.getRootElements()) : null;
80
+ // The preview's own root is the truth after an undo or an outside edit, which the shared session does not follow.
81
+ const attributes = live ?? root?.attributes ?? null;
82
+ const settings = useMemo(() => (attributes ? settingsOf(attributes) : null), [attributes]);
83
+ const isMain =
84
+ activeCompPath == null || activeCompPath === resolveMasterCompositionPath(compositions);
85
+ const persist = useVariablesPersist({
86
+ sdkSession,
87
+ activeCompPath,
88
+ readProjectFile,
89
+ writeProjectFile,
90
+ recordEdit,
91
+ reloadPreview,
92
+ publishSdkSession,
93
+ });
94
+
95
+ const apply = useCallback(
96
+ async (patch: Partial<MotionBlurSettings>) => {
97
+ if (!attributes) return;
98
+ // The write lands on the file's own root, so the edits are worked out there, not from the preview.
99
+ let applied: Array<{ name: string; value: string | null }> = [];
100
+ try {
101
+ await persist("Edit motion blur", (session) => {
102
+ const target = findCompositionRoot(session.getRootElements());
103
+ if (!target) return;
104
+ const next = { ...settingsOf(target.attributes), ...patch };
105
+ applied = changedEdits(target.attributes, next);
106
+ if (applied.length === 0) throw new NothingToChange();
107
+ for (const e of applied) {
108
+ session.dispatch({
109
+ type: "setAttribute",
110
+ target: target.id,
111
+ name: e.name,
112
+ value: e.value,
113
+ });
114
+ }
115
+ });
116
+ hold((current) => withEdits(current ?? attributes, applied));
117
+ rerender();
118
+ } catch (err) {
119
+ if (err instanceof NothingToChange) return;
120
+ showToast(err instanceof Error ? err.message : String(err), "error");
121
+ }
122
+ },
123
+ [attributes, persist, hold, showToast],
124
+ );
125
+
126
+ if (!settings) {
127
+ return (
128
+ <p className="p-3 text-[10px] leading-relaxed text-neutral-500">
129
+ Open a composition to set up motion blur.
130
+ </p>
131
+ );
132
+ }
133
+ const locked = !isMain;
134
+ // A sub-composition's own root is not the setting being edited, so no level is shown while locked.
135
+ const current = locked ? null : levelOf(settings);
136
+
137
+ return (
138
+ <div className="flex h-full flex-col gap-3 overflow-y-auto p-3 text-[11px] text-neutral-300">
139
+ <span className="text-[12px] font-medium text-neutral-100">Motion blur</span>
140
+ <div role="radiogroup" aria-label="Motion blur" className="flex gap-1.5">
141
+ {BLUR_LEVELS.map(({ level, label }) => (
142
+ <Button
143
+ key={level}
144
+ role="radio"
145
+ aria-checked={current === level}
146
+ size="sm"
147
+ variant={current === level ? "secondary" : "ghost"}
148
+ className="flex-1"
149
+ disabled={locked}
150
+ onClick={() => void apply(patchForLevel(level as BlurLevel))}
151
+ >
152
+ {label}
153
+ </Button>
154
+ ))}
155
+ </div>
156
+ {locked ? (
157
+ <p className="text-[10px] leading-relaxed text-amber-400/90">
158
+ Motion blur is read from the main composition. Open it to change this setting.
159
+ </p>
160
+ ) : null}
161
+ </div>
162
+ );
163
+ });
@@ -0,0 +1,143 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { openComposition } from "@hyperframes/sdk";
5
+ import {
6
+ changedEdits,
7
+ findCompositionRoot,
8
+ readPreviewRootAttributes,
9
+ sameAttributes,
10
+ levelOf,
11
+ patchForLevel,
12
+ settingsOf,
13
+ withEdits,
14
+ } from "./motionBlurPanelModel";
15
+
16
+ const html = (attrs = "") =>
17
+ `<!DOCTYPE html><html><body><div data-composition-id="main" data-hf-root data-duration="5" data-width="1920" data-height="1080"${attrs}></div></body></html>`;
18
+
19
+ async function rootOf(attrs?: string) {
20
+ const session = await openComposition(html(attrs), { history: false });
21
+ const root = findCompositionRoot(session.getRootElements());
22
+ if (!root) throw new Error("no root");
23
+ return root;
24
+ }
25
+
26
+ describe("findCompositionRoot and settingsOf", () => {
27
+ it("finds the composition root and reads its settings", async () => {
28
+ const root = await rootOf(' data-motion-blur="on" data-motion-blur-shutter="360"');
29
+ expect(settingsOf(root.attributes)).toMatchObject({
30
+ enabled: true,
31
+ shutterDeg: 360,
32
+ tier: "auto",
33
+ });
34
+ });
35
+
36
+ it("reads defaults from a root that has no blur attributes", async () => {
37
+ expect(settingsOf((await rootOf()).attributes)).toMatchObject({
38
+ enabled: false,
39
+ shutterDeg: 240,
40
+ });
41
+ });
42
+
43
+ it("is null for a document with nothing in it", async () => {
44
+ const session = await openComposition("<!DOCTYPE html><html><body></body></html>", {
45
+ history: false,
46
+ });
47
+ expect(findCompositionRoot(session.getRootElements())).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe("changedEdits", () => {
52
+ it("lists only the attributes that would change", async () => {
53
+ const root = await rootOf(' data-motion-blur="on"');
54
+ const base = settingsOf(root.attributes);
55
+ expect(changedEdits(root.attributes, base)).toEqual([]);
56
+ expect(changedEdits(root.attributes, { ...base, shutterDeg: 360, tier: "filter" })).toEqual([
57
+ { name: "data-motion-blur-shutter", value: "360" },
58
+ { name: "data-motion-blur-tier", value: "filter" },
59
+ ]);
60
+ expect(changedEdits(root.attributes, { ...base, enabled: false })).toEqual([
61
+ { name: "data-motion-blur", value: null },
62
+ ]);
63
+ });
64
+ });
65
+
66
+ describe("withEdits and sameAttributes", () => {
67
+ it("applies sets and removals without touching the input", () => {
68
+ const before = { "data-motion-blur": "on", "data-motion-blur-shutter": "360" };
69
+ const after = withEdits(before, [
70
+ { name: "data-motion-blur-shutter", value: null },
71
+ { name: "data-motion-blur-tier", value: "filter" },
72
+ ]);
73
+ expect(after).toEqual({ "data-motion-blur": "on", "data-motion-blur-tier": "filter" });
74
+ expect(before).toEqual({ "data-motion-blur": "on", "data-motion-blur-shutter": "360" });
75
+ });
76
+
77
+ it("compares attribute sets by content", () => {
78
+ expect(sameAttributes({ a: "1" }, { a: "1" })).toBe(true);
79
+ expect(sameAttributes({ a: "1" }, { a: "2" })).toBe(false);
80
+ expect(sameAttributes({ a: "1" }, {})).toBe(false);
81
+ expect(sameAttributes(null, {})).toBe(false);
82
+ expect(sameAttributes(null, null)).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("readPreviewRootAttributes", () => {
87
+ it("reads only the blur attributes from the preview's composition root", () => {
88
+ const doc = document.implementation.createHTMLDocument("p");
89
+ doc.body.innerHTML =
90
+ '<div data-composition-id="main" data-width="1920" data-motion-blur="on" data-motion-blur-shutter="360"></div>';
91
+ const frame = { contentDocument: doc } as unknown as HTMLIFrameElement;
92
+ expect(readPreviewRootAttributes(frame)).toEqual({
93
+ "data-motion-blur": "on",
94
+ "data-motion-blur-shutter": "360",
95
+ });
96
+ });
97
+
98
+ it("is null when there is no preview, no root, or the document cannot be read", () => {
99
+ expect(readPreviewRootAttributes(null)).toBeNull();
100
+ const empty = document.implementation.createHTMLDocument("p");
101
+ expect(
102
+ readPreviewRootAttributes({ contentDocument: empty } as unknown as HTMLIFrameElement),
103
+ ).toBeNull();
104
+ const blocked = {
105
+ get contentDocument(): never {
106
+ throw new Error("cross-origin");
107
+ },
108
+ } as unknown as HTMLIFrameElement;
109
+ expect(readPreviewRootAttributes(blocked)).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("levelOf and patchForLevel", () => {
114
+ const on = (shutterDeg: number, enabled = true) => ({
115
+ enabled,
116
+ shutterDeg,
117
+ samples: null,
118
+ events: false,
119
+ tier: "auto" as const,
120
+ });
121
+
122
+ it("reads off, subtle and strong from the root's settings", () => {
123
+ expect(levelOf(on(240, false))).toBe("off");
124
+ expect(levelOf(on(240))).toBe("subtle");
125
+ expect(levelOf(on(180))).toBe("subtle");
126
+ expect(levelOf(on(360))).toBe("subtle");
127
+ expect(levelOf(on(480))).toBe("subtle");
128
+ expect(levelOf(on(481))).toBe("strong");
129
+ expect(levelOf(on(720))).toBe("strong");
130
+ });
131
+
132
+ it("sets Subtle to the default blur and Strong to the maximum, and leaves the rest alone for Off", () => {
133
+ expect(patchForLevel("subtle")).toEqual({ enabled: true, shutterDeg: 240, samples: null });
134
+ expect(patchForLevel("strong")).toEqual({ enabled: true, shutterDeg: 720, samples: 64 });
135
+ expect(patchForLevel("off")).toEqual({ enabled: false });
136
+ });
137
+
138
+ it("reads back as the level that was chosen", () => {
139
+ for (const level of ["subtle", "strong"] as const) {
140
+ expect(levelOf({ ...on(300), ...patchForLevel(level) })).toBe(level);
141
+ }
142
+ });
143
+ });
@@ -0,0 +1,98 @@
1
+ /** What the Motion blur panel reads from and writes to a composition root, and how it describes the preview. */
2
+
3
+ import type { ElementSnapshot } from "@hyperframes/sdk";
4
+ import {
5
+ MOTION_BLUR_ATTR,
6
+ MOTION_BLUR_LIMITS,
7
+ type MotionBlurSettings,
8
+ motionBlurAttributeEdits,
9
+ readMotionBlurSettings,
10
+ } from "@hyperframes/core/motion-blur-settings";
11
+
12
+ export interface CompositionRoot {
13
+ id: string;
14
+ attributes: Readonly<Record<string, string>>;
15
+ }
16
+
17
+ /** The composition's own root: the top-level element that names a composition, else the first. */
18
+ export function findCompositionRoot(roots: readonly ElementSnapshot[]): CompositionRoot | null {
19
+ const root = roots.find((el) => "data-composition-id" in el.attributes) ?? roots[0];
20
+ return root ? { id: root.id, attributes: root.attributes } : null;
21
+ }
22
+
23
+ export type RootAttributes = Readonly<Record<string, string>>;
24
+
25
+ export const settingsOf = (attributes: RootAttributes): MotionBlurSettings =>
26
+ readMotionBlurSettings((name) => attributes[name] ?? null);
27
+
28
+ /** Only the attributes whose value would change, so a no-op edit writes nothing. */
29
+ export function changedEdits(
30
+ attributes: RootAttributes,
31
+ next: MotionBlurSettings,
32
+ ): Array<{ name: string; value: string | null }> {
33
+ return Object.entries(motionBlurAttributeEdits(next))
34
+ .filter(([name, value]) => (attributes[name] ?? null) !== value)
35
+ .map(([name, value]) => ({ name, value }));
36
+ }
37
+
38
+ /** `attributes` with `edits` applied: a value sets, null removes. */
39
+ export function withEdits(
40
+ attributes: RootAttributes,
41
+ edits: ReadonlyArray<{ name: string; value: string | null }>,
42
+ ): RootAttributes {
43
+ const next: Record<string, string> = { ...attributes };
44
+ for (const { name, value } of edits) {
45
+ if (value === null) delete next[name];
46
+ else next[name] = value;
47
+ }
48
+ return next;
49
+ }
50
+
51
+ /** The blur attributes on the live preview's composition root: what actually runs, after an undo or an outside edit too. */
52
+ export function readPreviewRootAttributes(iframe: HTMLIFrameElement | null): RootAttributes | null {
53
+ try {
54
+ const root = iframe?.contentDocument?.querySelector("[data-composition-id]");
55
+ if (!root) return null;
56
+ const found: Record<string, string> = {};
57
+ for (const name of Object.values(MOTION_BLUR_ATTR)) {
58
+ const value = root.getAttribute(name);
59
+ if (value !== null) found[name] = value;
60
+ }
61
+ return found;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export const sameAttributes = (a: RootAttributes | null, b: RootAttributes | null): boolean =>
68
+ a === b ||
69
+ (a !== null && b !== null && Object.keys({ ...a, ...b }).every((name) => a[name] === b[name]));
70
+
71
+ export type BlurLevel = "off" | "subtle" | "strong";
72
+
73
+ export const BLUR_LEVELS: ReadonlyArray<{ level: BlurLevel; label: string }> = [
74
+ { level: "off", label: "Off" },
75
+ { level: "subtle", label: "Subtle" },
76
+ { level: "strong", label: "Strong" },
77
+ ];
78
+
79
+ /** Subtle is the default blur; Strong opens the shutter and the samples as far as they go. */
80
+ const LEVEL_SETTINGS = {
81
+ subtle: { shutterDeg: MOTION_BLUR_LIMITS.shutterDeg.default, samples: null },
82
+ strong: {
83
+ shutterDeg: MOTION_BLUR_LIMITS.shutterDeg.max,
84
+ samples: MOTION_BLUR_LIMITS.samples.max,
85
+ },
86
+ } as const;
87
+ /** Halfway between the two shutters: above it a root reads as Strong, so a hand-set 360 reads as Subtle. */
88
+ const STRONG_ABOVE = (LEVEL_SETTINGS.subtle.shutterDeg + LEVEL_SETTINGS.strong.shutterDeg) / 2;
89
+
90
+ export function levelOf(settings: MotionBlurSettings): BlurLevel {
91
+ if (!settings.enabled) return "off";
92
+ return settings.shutterDeg > STRONG_ABOVE ? "strong" : "subtle";
93
+ }
94
+
95
+ /** What choosing `level` changes; Off leaves the shutter, samples and every other setting where they were. */
96
+ export function patchForLevel(level: BlurLevel): Partial<MotionBlurSettings> {
97
+ return level === "off" ? { enabled: false } : { enabled: true, ...LEVEL_SETTINGS[level] };
98
+ }
@@ -108,3 +108,35 @@ it("wraps its child in a box-less span so a disabled child still gets hover", ()
108
108
  expect(trigger.parentElement?.className).toBe("contents");
109
109
  expect(trigger.parentElement?.parentElement?.parentElement).toBe(document.body);
110
110
  });
111
+
112
+ it("keeps a label on one line unless it is given lines", async () => {
113
+ const trigger = mount();
114
+ act(() => trigger.focus());
115
+ await settle();
116
+
117
+ expect(bubble()?.className).toContain("whitespace-nowrap");
118
+ expect(bubble()?.querySelector("span")).toBeNull();
119
+ });
120
+
121
+ it("wraps a long label at a reading width and cuts it at its lines", async () => {
122
+ const label = "Make a 30 second launch promo for the spring release. ".repeat(6).trim();
123
+ const host = mountHost(
124
+ <Tooltip label={label} delay={0} lines={3}>
125
+ <button type="button" data-testid="long">
126
+ P
127
+ </button>
128
+ </Tooltip>,
129
+ );
130
+ act(() => host.querySelector<HTMLElement>('[data-testid="long"]')?.focus());
131
+ await settle();
132
+
133
+ const tip = bubble();
134
+ expect(tip?.className).toContain("whitespace-normal");
135
+ expect(tip?.className, "a long URL or path breaks too").toContain("break-words");
136
+ expect(tip?.className).toContain("max-w-[300px]");
137
+ expect(tip?.className).not.toContain("whitespace-nowrap");
138
+ const text = tip?.querySelector<HTMLElement>("span");
139
+ expect(text?.textContent).toBe(label);
140
+ expect(text?.className).toBe("line-clamp-(--tooltip-lines)");
141
+ expect(text?.style.getPropertyValue("--tooltip-lines")).toBe("3");
142
+ });
@@ -4,7 +4,14 @@
4
4
  */
5
5
 
6
6
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
7
- import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
7
+ import {
8
+ cloneElement,
9
+ useId,
10
+ useRef,
11
+ useState,
12
+ type CSSProperties,
13
+ type ReactElement,
14
+ } from "react";
8
15
  import { cn } from "./cn";
9
16
  import { floatingMotion, POPUP_LAYER } from "./Menu";
10
17
 
@@ -16,6 +23,8 @@ interface TooltipProps {
16
23
  delay?: number;
17
24
  disabled?: boolean;
18
25
  side?: "top" | "bottom" | "left" | "right";
26
+ /** Wraps a long label at a reading width and cuts it at this many lines with an ellipsis. Unset, one line. */
27
+ lines?: number;
19
28
  }
20
29
 
21
30
  /** Matches the old bubble's gap from its trigger, and its viewport margin. */
@@ -28,6 +37,7 @@ export function Tooltip({
28
37
  delay = 400,
29
38
  side = "top",
30
39
  disabled = false,
40
+ lines,
31
41
  }: TooltipProps) {
32
42
  const [open, setOpen] = useState(false);
33
43
  const boxRef = useRef<HTMLSpanElement>(null);
@@ -52,11 +62,21 @@ export function Tooltip({
52
62
  id={tooltipId}
53
63
  role="tooltip"
54
64
  className={cn(
55
- "pointer-events-none rounded-md border border-border-input bg-surface px-2 py-1 text-step-10 font-medium text-text-1 whitespace-nowrap shadow-menu",
65
+ "pointer-events-none rounded-md border border-border-input bg-surface px-2 py-1 text-step-10 font-medium text-text-1 shadow-menu",
66
+ lines ? "max-w-[300px] whitespace-normal break-words" : "whitespace-nowrap",
56
67
  floatingMotion("duration-tooltip"),
57
68
  )}
58
69
  >
59
- {label}
70
+ {lines ? (
71
+ <span
72
+ className="line-clamp-(--tooltip-lines)"
73
+ style={{ "--tooltip-lines": lines } as CSSProperties}
74
+ >
75
+ {label}
76
+ </span>
77
+ ) : (
78
+ label
79
+ )}
60
80
  </BaseTooltip.Popup>
61
81
  </BaseTooltip.Positioner>
62
82
  </BaseTooltip.Portal>
@@ -105,7 +105,7 @@ describe("usePanelLayout over the dock", () => {
105
105
  act(() => view.layout.setRightCollapsed(true));
106
106
  const hidden = vi.mocked(controller.setGroupVisible).mock.calls;
107
107
  expect(hidden.map(([id]) => id).sort()).toEqual(
108
- ["design", "layers", "renders", "slideshow", "variables"].sort(),
108
+ ["design", "layers", "motionBlur", "renders", "slideshow", "variables"].sort(),
109
109
  );
110
110
  expect(hidden.every(([, visible]) => visible === false)).toBe(true);
111
111
  });