@hyperframes/studio 0.8.51 → 0.8.53-desktop.0

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 (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -0,0 +1,201 @@
1
+ // The hex ratchet (R12, AE2): a per-file baseline of colour literals that only ever goes down.
2
+ // Per file so two sweep PRs touching one file conflict on its line and force a recount.
3
+ // The baseline is never regenerated by a normal run: a missing one fails, and rewriting it needs
4
+ // the named flag, so the number in git is one a human accepted.
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { describe, expect, it } from "vitest";
9
+ import { listSourceFiles, REPO_ROOT, STYLES_DIR } from "./styleSources";
10
+
11
+ const BASELINE_PATH = path.join(STYLES_DIR, "hex-baseline.json");
12
+
13
+ const WRITE_FLAG = "HEX_BASELINE_WRITE";
14
+ const LOWER_COMMAND = `${WRITE_FLAG}=1 bunx vitest run src/styles/hexRatchet.test.ts`;
15
+
16
+ // A literal colour: `#abc`, `#aabbcc`, `#aabbccdd`, or functional notation. This test owns the
17
+ // regex, so the baseline is whatever this rule sees.
18
+ const COLOR_LITERAL = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})\b|\b(?:rgba?|hsla?)\(/gi;
19
+
20
+ /** Files whose whole job is to hold colour values. */
21
+ function isTokenSource(relative: string): boolean {
22
+ return /styles\/(theme\.css|tailwind-preset\.shared\.js|tailwind-preset\.ts)$/.test(relative);
23
+ }
24
+
25
+ function isScanned(relative: string): boolean {
26
+ if (isTokenSource(relative) || /\.test\.tsx?$/.test(relative)) return false;
27
+ return /\.(tsx?|css)$/.test(relative);
28
+ }
29
+
30
+ /** Strings match first and are kept, so `"image/*"` or `"a//b"` never opens a comment. */
31
+ const STRING_OR_COMMENT =
32
+ /("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
33
+
34
+ /** Comments hold issue numbers like `#2291` that read as hex; count code only. */
35
+ function stripComments(text: string, lineComments: boolean): string {
36
+ return text.replace(STRING_OR_COMMENT, (match, string?: string) => {
37
+ if (string !== undefined) return match;
38
+ return match.startsWith("//") && !lineComments ? match : "";
39
+ });
40
+ }
41
+
42
+ function countColorLiterals(text: string, lineComments = true): number {
43
+ return [...stripComments(text, lineComments).matchAll(COLOR_LITERAL)].length;
44
+ }
45
+
46
+ /** Repository-relative path to colour-literal count, for every scanned file. */
47
+ function scan(): Map<string, number> {
48
+ const counts = new Map<string, number>();
49
+ for (const [file, text] of listSourceFiles(isScanned, REPO_ROOT)) {
50
+ const count = countColorLiterals(text, !file.endsWith(".css"));
51
+ if (count > 0) counts.set(file, count);
52
+ }
53
+ return counts;
54
+ }
55
+
56
+ interface Baseline {
57
+ readonly total: number;
58
+ readonly files: Readonly<Record<string, number>>;
59
+ }
60
+
61
+ function toBaseline(counts: ReadonlyMap<string, number>): Baseline {
62
+ const files: Record<string, number> = {};
63
+ let total = 0;
64
+ for (const key of [...counts.keys()].sort()) {
65
+ files[key] = counts.get(key) ?? 0;
66
+ total += files[key];
67
+ }
68
+ return { total, files };
69
+ }
70
+
71
+ /** Every file whose count moved, split by direction. */
72
+ function compare(counts: ReadonlyMap<string, number>, baseline: Baseline) {
73
+ const risen: string[] = [];
74
+ const fallen: string[] = [];
75
+ // A file missing from the baseline has a baseline of zero, so a rename
76
+ // cannot smuggle its colours past the ratchet.
77
+ for (const [file, count] of counts) {
78
+ const allowed = baseline.files[file] ?? 0;
79
+ if (count > allowed) risen.push(`${file}: ${count} colour literals, baseline ${allowed}`);
80
+ if (count < allowed) fallen.push(`${file}: ${count}, baseline ${allowed}`);
81
+ }
82
+ for (const [file, allowed] of Object.entries(baseline.files)) {
83
+ if (!counts.has(file)) fallen.push(`${file}: now 0, baseline ${allowed}`);
84
+ }
85
+ return { risen, fallen };
86
+ }
87
+
88
+ // A rise is a failure. A fall is reported with the command that banks it, because a baseline
89
+ // left high is a budget the next change can spend.
90
+ function verdict(counts: ReadonlyMap<string, number>, baseline: Baseline | undefined): string[] {
91
+ if (baseline === undefined) {
92
+ return [`src/styles/hex-baseline.json is missing. Write it with: ${LOWER_COMMAND}`];
93
+ }
94
+ const { risen, fallen } = compare(counts, baseline);
95
+ if (risen.length > 0) {
96
+ return [
97
+ "Colour literals belong in src/styles/theme.css.",
98
+ ...risen,
99
+ `If these are unavoidable, lower is the only direction: ${LOWER_COMMAND}`,
100
+ ];
101
+ }
102
+ if (fallen.length > 0) return [`Colour literals went down. Bank it: ${LOWER_COMMAND}`, ...fallen];
103
+ return [];
104
+ }
105
+
106
+ function readBaseline(): Baseline | undefined {
107
+ if (!existsSync(BASELINE_PATH)) return undefined;
108
+ return JSON.parse(readFileSync(BASELINE_PATH, "utf8")) as Baseline;
109
+ }
110
+
111
+ describe("colour literal counter", () => {
112
+ it("counts a hex in a style object, in canvas code and in a stylesheet", () => {
113
+ expect(countColorLiterals(`<div style={{ background: "#161618" }} />`)).toBe(1);
114
+ expect(countColorLiterals(`context.fillStyle = "#090b0e";`)).toBe(1);
115
+ expect(countColorLiterals(`.ring { box-shadow: 0 0 0 1px rgb(110 231 183); }`)).toBe(1);
116
+ expect(countColorLiterals(`a { color: rgba(0, 0, 0, 0.5); border-color: hsl(0 0% 4%); }`)).toBe(
117
+ 2,
118
+ );
119
+ });
120
+
121
+ it("does not mistake a fragment or an issue number for a colour", () => {
122
+ // Known limit: an identifier that is exactly 3, 4, 6 or 8 hex characters
123
+ // in code reads as a colour; the baseline absorbs it.
124
+ expect(countColorLiterals(`href="#section-two" // see #12345`)).toBe(0);
125
+ });
126
+
127
+ it("keeps a colour that follows comment-shaped text inside a string", () => {
128
+ expect(countColorLiterals(`accept="image/*"; const c = "#161618"; x = "*/";`)).toBe(1);
129
+ expect(countColorLiterals("const u = `x//y`; const c = '#161618';")).toBe(1);
130
+ expect(countColorLiterals(`.a { background: url(//cdn.test/a.png) #161618; }`, false)).toBe(1);
131
+ });
132
+
133
+ it("ignores a colour-shaped issue number in a line or block comment", () => {
134
+ expect(countColorLiterals(`const a = 1; // decision (#2291)`)).toBe(0);
135
+ expect(countColorLiterals(`/** see #2291 and rgba(0,0,0,1) */\nconst a = 1;`)).toBe(0);
136
+ expect(countColorLiterals(`fetch("https://x.test/#abc"); const c = "#161618"; // #2291`)).toBe(
137
+ 2,
138
+ );
139
+ });
140
+
141
+ it("leaves the token sources and the tests out of the scan", () => {
142
+ expect(isScanned("packages/studio/src/styles/theme.css")).toBe(false);
143
+ expect(isScanned("packages/studio/src/styles/tailwind-preset.shared.js")).toBe(false);
144
+ expect(isScanned("packages/studio/src/styles/theme.test.ts")).toBe(false);
145
+ expect(isScanned("packages/studio/src/components/ui/Button.tsx")).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe("hex ratchet", () => {
150
+ const baseline: Baseline = { total: 2, files: { "a.tsx": 2 } };
151
+
152
+ it("fails when a file's count rises, naming the file and both numbers", () => {
153
+ // Covers AE2.
154
+ const message = verdict(new Map([["a.tsx", 3]]), baseline).join("\n");
155
+
156
+ expect(message).toContain("a.tsx: 3 colour literals, baseline 2");
157
+ expect(message).toContain(LOWER_COMMAND);
158
+ });
159
+
160
+ it("passes when a count falls and says how to bank it", () => {
161
+ const message = verdict(new Map([["a.tsx", 1]]), baseline).join("\n");
162
+
163
+ expect(message).toContain("Colour literals went down");
164
+ expect(message).toContain(LOWER_COMMAND);
165
+ });
166
+
167
+ it("treats a file the baseline has never seen as a baseline of zero", () => {
168
+ const empty: Baseline = { total: 0, files: {} };
169
+
170
+ expect(verdict(new Map([["new.tsx", 0]]), empty)).toEqual([]);
171
+ expect(verdict(new Map([["new.tsx", 1]]), empty).join("\n")).toContain(
172
+ "new.tsx: 1 colour literals, baseline 0",
173
+ );
174
+ });
175
+
176
+ it("round-trips: a baseline written from a scan accepts that same scan", () => {
177
+ const counts = new Map([
178
+ ["b.css", 4],
179
+ ["a.tsx", 2],
180
+ ]);
181
+ const written = toBaseline(counts);
182
+
183
+ expect(Object.keys(written.files)).toEqual(["a.tsx", "b.css"]);
184
+ expect(written.total).toBe(6);
185
+ expect(verdict(counts, written)).toEqual([]);
186
+ });
187
+
188
+ it("fails with the flag named when there is no baseline at all", () => {
189
+ expect(verdict(new Map([["a.tsx", 0]]), undefined).join("\n")).toContain(`${WRITE_FLAG}=1`);
190
+ });
191
+
192
+ it("holds Studio at or below its committed colour-literal baseline", () => {
193
+ const counts = scan();
194
+ if (process.env[WRITE_FLAG] === "1") {
195
+ writeFileSync(BASELINE_PATH, `${JSON.stringify(toBaseline(counts), null, 2)}\n`);
196
+ }
197
+ const committed = readBaseline();
198
+
199
+ expect(verdict(counts, committed)).toEqual([]);
200
+ });
201
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { listSourceFiles, toPosixPath } from "./styleSources";
3
+
4
+ describe("style source paths", () => {
5
+ it("turns a Windows-style relative path into the forward-slash form the baseline uses", () => {
6
+ expect(toPosixPath("packages\\studio\\src\\App.tsx")).toBe("packages/studio/src/App.tsx");
7
+ });
8
+
9
+ it("leaves a forward-slash path alone", () => {
10
+ expect(toPosixPath("packages/studio/src/App.tsx")).toBe("packages/studio/src/App.tsx");
11
+ });
12
+
13
+ it("hands the keep filter and the map key the same forward-slash path", () => {
14
+ const seen: string[] = [];
15
+ const files = listSourceFiles((file) => (seen.push(file), file.endsWith("styleSources.ts")));
16
+
17
+ expect([...files.keys()]).toEqual(["styles/styleSources.ts"]);
18
+ expect(seen.every((file) => !file.includes("\\"))).toBe(true);
19
+ });
20
+ });
@@ -0,0 +1,47 @@
1
+ // Where the style tests read from: Tailwind's entry resolved from `node_modules`, and the file list
2
+ // that `@source` in `studio.css` describes, shared so the gate, ratchet and theme test agree.
3
+
4
+ import { createRequire } from "node:module";
5
+ import { readdirSync, readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ export const STYLES_DIR = path.dirname(fileURLToPath(import.meta.url));
10
+ const SRC_DIR = path.dirname(STYLES_DIR);
11
+ export const REPO_ROOT = path.resolve(SRC_DIR, "../../..");
12
+
13
+ const require = createRequire(import.meta.url);
14
+ export const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
15
+
16
+ /** Resolves `@import` for Tailwind's compiler: bare `tailwindcss`, or a path. */
17
+ export function loadStylesheet(id: string, base: string) {
18
+ const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
19
+ return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
20
+ }
21
+
22
+ /** Forward slashes on every platform, so keys match the baseline file and the gate's messages. */
23
+ export const toPosixPath = (relativePath: string): string => relativePath.replace(/\\/g, "/");
24
+
25
+ /**
26
+ * Studio's own sources, keyed by path relative to `from`, for every file the
27
+ * caller keeps. Build output and dependencies are never walked.
28
+ */
29
+ export function listSourceFiles(
30
+ keep: (relativePath: string) => boolean,
31
+ from: string = SRC_DIR,
32
+ ): Map<string, string> {
33
+ const files = new Map<string, string>();
34
+ const walk = (dir: string) => {
35
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
36
+ const full = path.join(dir, entry.name);
37
+ if (entry.isDirectory()) {
38
+ if (entry.name !== "node_modules" && entry.name !== "dist") walk(full);
39
+ } else {
40
+ const relative = toPosixPath(path.relative(from, full));
41
+ if (keep(relative)) files.set(relative, readFileSync(full, "utf8"));
42
+ }
43
+ }
44
+ };
45
+ walk(SRC_DIR);
46
+ return files;
47
+ }
@@ -41,12 +41,48 @@
41
41
  --color-media: #00e3ff;
42
42
  --color-container: #f5a623;
43
43
 
44
+ /* The two stops of the HeyGen brand gradient, worn by the header logo. Fixed brand values
45
+ * rather than roles, so they are not reachable as a background or text colour by mistake. */
46
+ --color-brand-gradient-from: #06e3fa;
47
+ --color-brand-gradient-to: #4fdb5e;
48
+
49
+ /* The timeline's own surfaces: darker and bluer than the shared scale, so they
50
+ * get their own names instead of snapping to the nearest --color-bg/-surface
51
+ * step (AD147: a host themes the timeline by overriding these). */
52
+ --timeline-shell-bg: #0a0a0b;
53
+ --timeline-shell-border: rgba(255, 255, 255, 0.05);
54
+ --timeline-ruler-border: rgba(255, 255, 255, 0.16);
55
+ --timeline-row-bg: #101014;
56
+ --timeline-row-border: rgba(255, 255, 255, 0.06);
57
+ --timeline-gutter-bg: #0e0f12;
58
+ --timeline-gutter-border: rgba(255, 255, 255, 0.1);
59
+ --timeline-text-primary: rgba(255, 255, 255, 0.92);
60
+ --timeline-text-secondary: rgba(255, 255, 255, 0.62);
61
+ --timeline-tick-text: rgba(255, 255, 255, 0.34);
62
+ --timeline-tick-major: rgba(255, 255, 255, 0.1);
63
+ --timeline-tick-minor: rgba(255, 255, 255, 0.06);
64
+ --timeline-clip-bg: #141922;
65
+ --timeline-clip-bg-active: #181e28;
66
+ --timeline-clip-border: rgba(255, 255, 255, 0.1);
67
+ --timeline-clip-border-hover: rgba(255, 255, 255, 0.18);
68
+ --timeline-clip-border-active: rgba(255, 255, 255, 0.24);
69
+ --timeline-handle: rgba(255, 255, 255, 0.2);
70
+ --timeline-resize-seam: rgba(255, 255, 255, 0.12);
71
+ --timeline-resize-active: rgba(255, 255, 255, 0.24);
72
+ --timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
73
+ --timeline-track-clip-active: rgba(60, 230, 172, 0.16);
74
+ --timeline-track-label: rgba(255, 255, 255, 0.5);
75
+
44
76
  /* -------------------------------------------------------------- geometry */
45
77
 
46
78
  --radius-sm: 0.25rem;
47
79
  --radius-md: 0.375rem;
48
80
  --radius-lg: 0.5rem;
49
81
 
82
+ /* The one radius every push-button wears, named by its role so a button does
83
+ * not have to know which step of the scale it sits on. */
84
+ --radius-button: var(--radius-md);
85
+
50
86
  /* Control heights: 24, 28 and 32 px. `--spacing-*` gives `h-ctl`, `min-h-ctl`
51
87
  * and friends. */
52
88
  --spacing-ctl-sm: 24px;
@@ -80,12 +116,23 @@
80
116
  --shadow-menu: 0 8px 24px rgb(0 0 0 / 0.45), 0 0 0 1px rgb(0 0 0 / 0.4);
81
117
  --shadow-popover: 0 16px 40px rgb(0 0 0 / 0.55), 0 0 0 1px rgb(0 0 0 / 0.45);
82
118
 
119
+ /* The primary button is the one light surface in a dark UI, so it reads as a
120
+ * raised key rather than a floating panel: a tight contact shadow, a soft
121
+ * ambient one, and an inner top highlight. */
122
+ --shadow-btn-primary:
123
+ 0 1px 2px rgb(0 0 0 / 0.6), 0 2px 6px rgb(0 0 0 / 0.35), inset 0 1px 0 rgb(255 255 255 / 0.65);
124
+
83
125
  /* ---------------------------------------------------------------- motion */
84
126
 
85
127
  --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
86
128
  --ease-out-expo: cubic-bezier(0.23, 1, 0.32, 1);
87
129
  --ease-in-out-circ: cubic-bezier(0.77, 0, 0.175, 1);
88
130
 
131
+ /* The curve for a small state change (hover, press, focus), named by role so
132
+ * a control does not have to pick one. The other two stay for motion that is
133
+ * meant to be noticed. */
134
+ --ease-standard: var(--ease-out-quint);
135
+
89
136
  /* Durations have no Tailwind namespace, so these are plain variables paired
90
137
  * with the `@utility` rules at the bottom of this file. All are at or under
91
138
  * 200 ms. */
@@ -126,11 +173,21 @@
126
173
  --color-panel-text-3: var(--color-text-3);
127
174
  --color-panel-text-4: var(--color-text-4);
128
175
  --color-panel-text-5: var(--color-text-5);
176
+ --color-panel-bg-soft: var(--color-bg-2);
177
+ --color-panel-bg-2: var(--color-bg-2);
129
178
  --color-panel-accent: var(--color-accent);
130
179
  --color-panel-danger: var(--color-danger);
131
180
  --color-panel-media: var(--color-media);
132
181
  --color-panel-container: var(--color-container);
133
182
 
183
+ /* Names Studio's markup invented that no config ever defined, so the classes
184
+ * compiled to nothing. Aliased to the token that was meant, rather than
185
+ * given values of their own, so there is still one source per decision.
186
+ * U12 rewrites the call sites and deletes these. */
187
+ --color-surface-hover: var(--color-hover);
188
+ --color-accent-red: var(--color-danger);
189
+ --text-2xs: var(--text-step-10);
190
+
134
191
  /* ------------------------------------------- deprecated: Tailwind palette */
135
192
 
136
193
  /* Verbatim Tailwind 4.3.3 defaults for stock colours markup still names, so
@@ -4,10 +4,8 @@
4
4
  * property, a utility that stops compiling, the stock Tailwind palette
5
5
  * leaking back in, or the JS preset / legacy colours drifting from it. */
6
6
 
7
- import { createRequire } from "node:module";
8
7
  import { readFileSync } from "node:fs";
9
8
  import path from "node:path";
10
- import { fileURLToPath } from "node:url";
11
9
  import React, { act } from "react";
12
10
  import { createRoot } from "react-dom/client";
13
11
  import { Gear, IconContext } from "@phosphor-icons/react";
@@ -15,23 +13,12 @@ import { compile } from "tailwindcss";
15
13
  import { afterEach, describe, expect, it } from "vitest";
16
14
  import studioPreset from "./tailwind-preset.shared.js";
17
15
  import { readIconTokens } from "./iconTokens";
16
+ import { loadStylesheet, STYLES_DIR, TAILWIND_DIR } from "./styleSources";
18
17
 
19
18
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
19
 
21
- const STYLES_DIR = path.dirname(fileURLToPath(import.meta.url));
22
- const require = createRequire(import.meta.url);
23
- const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
24
-
25
- function loadStylesheet(id: string, base: string) {
26
- const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
27
- return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
28
- }
29
-
30
20
  async function buildSource(source: string, candidates: string[]): Promise<string> {
31
- const compiled = await compile(source, {
32
- base: STYLES_DIR,
33
- loadStylesheet,
34
- });
21
+ const compiled = await compile(source, { base: STYLES_DIR, loadStylesheet });
35
22
  return compiled.build(candidates);
36
23
  }
37
24
 
@@ -0,0 +1,109 @@
1
+ // The token gate (R11, AE1). Tailwind silently drops a class it cannot compile, so `rounded-button`
2
+ // renders as no radius and nothing goes red. This compiles Studio's real entry stylesheet with every
3
+ // class the source claims and reports any candidate producing no selector as `file: class`.
4
+ // Tailwind is the only judge, so there is no allowlist; a failing class is fixed, never excused.
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { compile } from "tailwindcss";
9
+ import { describe, expect, it } from "vitest";
10
+ import { extractClassCandidates } from "./classCandidates";
11
+ import { listSourceFiles, loadStylesheet, STYLES_DIR } from "./styleSources";
12
+
13
+ // A class selector in the emitted CSS, escapes removed: `.hover\:bg-surface\/50` reads back as its candidate.
14
+ const CLASS_SELECTOR = /\.((?:\\.|[^\s.,{}()>+~:[\]#'"*/\\])+)/g;
15
+
16
+ // Tailwind's `group`/`peer` variant markers (`group/card`) produce no rule of their own, so no
17
+ // compiled selector can vouch for them. Nothing else is exempt.
18
+ const MARKERS = new Set(["group", "peer"]);
19
+
20
+ // `hf-` is HyperFrames' reserved prefix for semantic hooks, not utilities, so Tailwind is the
21
+ // wrong judge of them.
22
+ const HOOK_PREFIX = /^hf-/;
23
+
24
+ /** Compile Studio's stylesheet and return every class it can produce. */
25
+ async function resolvable(candidates: string[]): Promise<Set<string>> {
26
+ const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
27
+ base: STYLES_DIR,
28
+ loadStylesheet,
29
+ });
30
+ const css = compiled.build(candidates);
31
+ const selectors = new Set<string>();
32
+ for (const [, selector] of css.matchAll(CLASS_SELECTOR)) {
33
+ selectors.add(selector.replace(/\\(.)/g, "$1"));
34
+ }
35
+ return selectors;
36
+ }
37
+
38
+ // `file: class` for every class the sources claim that Tailwind cannot make. Sources are passed
39
+ // in so the fixtures exercise the same code path as the tree.
40
+ async function unresolved(sources: ReadonlyMap<string, string>): Promise<string[]> {
41
+ const claims = new Map<string, string[]>();
42
+ for (const [file, source] of sources) {
43
+ for (const candidate of extractClassCandidates(source)) {
44
+ const seen = claims.get(candidate.base);
45
+ if (seen) seen.push(file);
46
+ else claims.set(candidate.base, [file]);
47
+ }
48
+ }
49
+ const produced = await resolvable([...claims.keys()]);
50
+ const failures: string[] = [];
51
+ for (const [candidate, files] of claims) {
52
+ if (produced.has(candidate) || MARKERS.has(candidate.split("/")[0])) continue;
53
+ if (HOOK_PREFIX.test(candidate)) continue;
54
+ for (const file of files) failures.push(`${file}: ${candidate}`);
55
+ }
56
+ return failures.sort();
57
+ }
58
+
59
+ // Every file the gate reads, mirroring the `@source` globs in `studio.css`. Tests are excluded:
60
+ // a fixture in a test is not markup.
61
+ function studioSources(): Map<string, string> {
62
+ return listSourceFiles((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file));
63
+ }
64
+
65
+ describe("token gate", () => {
66
+ it("names the file and the class when a class resolves to nothing", async () => {
67
+ // AE1: `rounded-hologram` looks like a token name but is defined nowhere. The map is named
68
+ // `buttonSizes`, not `sizeStyles`: the `cn()` that consumes it puts it in front of the gate.
69
+ const failures = await unresolved(
70
+ new Map([
71
+ [
72
+ "ui/Button.tsx",
73
+ `const buttonSizes = { md: "h-8 rounded-hologram text-mega" };
74
+ const Button = ({ size }) => <button className={cn(buttonSizes[size])} />;`,
75
+ ],
76
+ ]),
77
+ );
78
+
79
+ expect(failures).toEqual(["ui/Button.tsx: rounded-hologram", "ui/Button.tsx: text-mega"]);
80
+ });
81
+
82
+ it("accepts an arbitrary value and reports it as one", async () => {
83
+ const source = `<div className="text-[11px]" />`;
84
+
85
+ expect(await unresolved(new Map([["a.tsx", source]]))).toEqual([]);
86
+ expect(extractClassCandidates(source).filter((c) => c.arbitrary)).toHaveLength(1);
87
+ });
88
+
89
+ it("accepts variants, static utilities and Studio's own CSS classes", async () => {
90
+ // `timeline-clip` is a plain rule in `studio.css`, not a utility.
91
+ const source = `<div className="hover:bg-surface/50 text-center border-dashed timeline-clip" />`;
92
+
93
+ expect(await unresolved(new Map([["a.tsx", source]]))).toEqual([]);
94
+ });
95
+
96
+ it("leaves Studio's own hook prefix and Tailwind's variant markers alone", async () => {
97
+ const source = `<div className="group group/card peer hf-fx-node" />`;
98
+
99
+ expect(await unresolved(new Map([["a.tsx", source]]))).toEqual([]);
100
+ });
101
+
102
+ it("claims nothing for a template chunk that touches an interpolation", async () => {
103
+ expect(await unresolved(new Map([["a.tsx", "<div className={`w-${i}`} />"]]))).toEqual([]);
104
+ });
105
+
106
+ it("resolves every class Studio's own source claims", async () => {
107
+ expect(await unresolved(studioSources())).toEqual([]);
108
+ });
109
+ });
@@ -3,7 +3,7 @@
3
3
  * Handles inline style updates, attribute changes, and text content.
4
4
  */
5
5
 
6
- function escapeRegex(s: string): string {
6
+ export function escapeRegex(s: string): string {
7
7
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
8
  }
9
9
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import {
5
+ historyTooltipLabel,
5
6
  findMatchingTimelineElementId,
6
7
  findTimelineIdByAncestor,
7
8
  resolveDroppedAssetDimensions,
@@ -217,3 +218,20 @@ describe("resolveElementTrack", () => {
217
218
  expect(resolveElementTrack({ authoredTrack: undefined, track: 3.6 })).toBe(4);
218
219
  });
219
220
  });
221
+
222
+ describe("historyTooltipLabel", () => {
223
+ // A disabled control gets no pointer events, so its tooltip cannot be opened
224
+ // to read. The wording is checked here instead of through the DOM.
225
+ it("names the action that would be undone", () => {
226
+ expect(historyTooltipLabel("undo", "Move layer")).toMatch(/^Undo Move layer \(.+\)$/);
227
+ });
228
+
229
+ it("names the action that would be redone", () => {
230
+ expect(historyTooltipLabel("redo", "Move layer")).toMatch(/^Redo Move layer \(.+\)$/);
231
+ });
232
+
233
+ it("keeps the shortcut when the history is empty", () => {
234
+ expect(historyTooltipLabel("undo", undefined)).toMatch(/^Undo \(.+\)$/);
235
+ expect(historyTooltipLabel("redo", null)).toMatch(/^Redo \(.+\)$/);
236
+ });
237
+ });
@@ -22,13 +22,6 @@ export type RightPanelTab =
22
22
  | "block-params"
23
23
  | "slideshow"
24
24
  | "variables";
25
- export type RightInspectorPane = "layers" | "design";
26
-
27
- export interface RightInspectorPanes {
28
- layers: boolean;
29
- design: boolean;
30
- }
31
-
32
25
  export interface AgentModalAnchorPoint {
33
26
  x: number;
34
27
  y: number;
@@ -119,13 +112,23 @@ export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean
119
112
  return isTypingTarget(target);
120
113
  }
121
114
 
122
- export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
115
+ function getHistoryShortcutLabel(action: "undo" | "redo"): string {
123
116
  const isMac =
124
117
  typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
125
118
  const modifier = isMac ? "Cmd" : "Ctrl";
126
119
  return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
127
120
  }
128
121
 
122
+ /** The Undo / Redo tooltip: the shortcut always, the last action's name when there is one. */
123
+ export function historyTooltipLabel(
124
+ action: "undo" | "redo",
125
+ lastAction: string | null | undefined,
126
+ ): string {
127
+ const shortcut = getHistoryShortcutLabel(action);
128
+ const verb = action === "undo" ? "Undo" : "Redo";
129
+ return lastAction ? `${verb} ${lastAction} (${shortcut})` : `${verb} (${shortcut})`;
130
+ }
131
+
129
132
  export type ElementMatchSelection = Pick<
130
133
  DomEditSelection,
131
134
  | "id"
@@ -20,7 +20,6 @@ describe("studio UI preferences", () => {
20
20
  const storage = createStorage();
21
21
 
22
22
  writeStudioUiPreferences({ timelineVisible: false }, storage);
23
- writeStudioUiPreferences({ leftWidth: 384, rightWidth: 424 }, storage);
24
23
  writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
25
24
  writeStudioUiPreferences({ audioMuted: true }, storage);
26
25
  writeStudioUiPreferences({ audioVolume: 0.4 }, storage);
@@ -28,8 +27,6 @@ describe("studio UI preferences", () => {
28
27
 
29
28
  expect(readStudioUiPreferences(storage)).toEqual({
30
29
  timelineVisible: false,
31
- leftWidth: 384,
32
- rightWidth: 424,
33
30
  playbackRate: 1.5,
34
31
  audioMuted: true,
35
32
  audioVolume: 0.4,
@@ -57,9 +54,6 @@ describe("studio UI preferences", () => {
57
54
  storage.setItem(
58
55
  "hf-studio-ui-preferences",
59
56
  JSON.stringify({
60
- leftCollapsed: "yes",
61
- leftWidth: "wide",
62
- rightWidth: Number.NaN,
63
57
  timelineVisible: true,
64
58
  playbackRate: Number.NaN,
65
59
  audioMuted: "false",
@@ -119,28 +113,28 @@ describe("thumbnailMode preference", () => {
119
113
  describe("per-project scoping", () => {
120
114
  it("keeps two projects' preferences independent", () => {
121
115
  const storage = createStorage();
122
- writeStudioUiPreferences({ leftWidth: 300 }, storage, "alpha");
123
- writeStudioUiPreferences({ leftWidth: 500 }, storage, "beta");
116
+ writeStudioUiPreferences({ playbackRate: 1.25 }, storage, "alpha");
117
+ writeStudioUiPreferences({ playbackRate: 2 }, storage, "beta");
124
118
 
125
- expect(readStudioUiPreferences(storage, "alpha").leftWidth).toBe(300);
126
- expect(readStudioUiPreferences(storage, "beta").leftWidth).toBe(500);
119
+ expect(readStudioUiPreferences(storage, "alpha").playbackRate).toBe(1.25);
120
+ expect(readStudioUiPreferences(storage, "beta").playbackRate).toBe(2);
127
121
  });
128
122
 
129
123
  it("a project with nothing saved yet inherits the pre-scoping global entry once", () => {
130
124
  const storage = createStorage();
131
- storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
125
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ playbackRate: 0.5 }));
132
126
 
133
- expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(600);
127
+ expect(readStudioUiPreferences(storage, "gamma").playbackRate).toBe(0.5);
134
128
  });
135
129
 
136
130
  it("stops inheriting the global entry once the project has its own write", () => {
137
131
  const storage = createStorage();
138
- storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
132
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ playbackRate: 0.5 }));
139
133
 
140
- writeStudioUiPreferences({ leftWidth: 250 }, storage, "gamma");
134
+ writeStudioUiPreferences({ playbackRate: 1.75 }, storage, "gamma");
141
135
 
142
- expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(250);
143
- expect(readStudioUiPreferences(storage, null).leftWidth).toBe(600);
136
+ expect(readStudioUiPreferences(storage, "gamma").playbackRate).toBe(1.75);
137
+ expect(readStudioUiPreferences(storage, null).playbackRate).toBe(0.5);
144
138
  });
145
139
  });
146
140