@hyperframes/studio 0.5.0-alpha.10 → 0.5.0-alpha.11

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.
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
5
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
6
6
  import { NLEPreview } from "./NLEPreview";
7
7
  import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
8
+ import {
9
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
10
+ getTimelineToggleTitle,
11
+ } from "../../utils/timelineDiscovery";
8
12
 
9
13
  interface NLELayoutProps {
10
14
  projectId: string;
@@ -198,6 +202,7 @@ export const NLELayout = memo(function NLELayout({
198
202
 
199
203
  // Resizable timeline height
200
204
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
205
+ const isTimelineVisible = timelineVisible ?? true;
201
206
  const isDragging = useRef(false);
202
207
  const containerRef = useRef<HTMLDivElement>(null);
203
208
 
@@ -371,16 +376,11 @@ export const NLELayout = memo(function NLELayout({
371
376
  onNavigate={handleNavigateComposition}
372
377
  />
373
378
  )}
374
- <PlayerControls
375
- onTogglePlay={togglePlay}
376
- onSeek={seek}
377
- timelineVisible={timelineVisible ?? true}
378
- onToggleTimeline={onToggleTimeline}
379
- />
379
+ <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
380
380
  </div>
381
381
  </div>
382
382
 
383
- {(timelineVisible ?? true) && (
383
+ {isTimelineVisible ? (
384
384
  <>
385
385
  {/* Resize divider */}
386
386
  <div
@@ -422,7 +422,42 @@ export const NLELayout = memo(function NLELayout({
422
422
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
423
423
  </div>
424
424
  </>
425
- )}
425
+ ) : onToggleTimeline ? (
426
+ <div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
427
+ <div className="flex h-10 items-center justify-between px-3">
428
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
429
+ Timeline
430
+ </div>
431
+ <button
432
+ type="button"
433
+ onClick={onToggleTimeline}
434
+ className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
435
+ title={getTimelineToggleTitle(false)}
436
+ aria-label="Show timeline editor"
437
+ >
438
+ <svg
439
+ width="13"
440
+ height="13"
441
+ viewBox="0 0 24 24"
442
+ fill="none"
443
+ stroke="currentColor"
444
+ strokeWidth="1.7"
445
+ strokeLinecap="round"
446
+ strokeLinejoin="round"
447
+ aria-hidden="true"
448
+ >
449
+ <rect x="3" y="13" width="18" height="8" rx="1" />
450
+ <path d="M7 9h10" />
451
+ <path d="M8 5h8" />
452
+ </svg>
453
+ <span>Show</span>
454
+ <span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
455
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
456
+ </span>
457
+ </button>
458
+ </div>
459
+ </div>
460
+ ) : null}
426
461
  </div>
427
462
  );
428
463
  });
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
35
35
  codeChildren?: ReactNode;
36
36
  onLint?: () => void;
37
37
  linting?: boolean;
38
+ onToggleCollapse?: () => void;
38
39
  }
39
40
 
40
41
  export const LeftSidebar = memo(function LeftSidebar({
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
57
58
  codeChildren,
58
59
  onLint,
59
60
  linting,
61
+ onToggleCollapse,
60
62
  }: LeftSidebarProps) {
61
63
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
62
64
 
@@ -89,43 +91,69 @@ export const LeftSidebar = memo(function LeftSidebar({
89
91
  >
90
92
  {/* Tabs — Code first */}
91
93
  <div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
92
- <div
93
- className="grid gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
94
- style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
95
- >
96
- <button
97
- type="button"
98
- onClick={() => selectTab("code")}
99
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
100
- tab === "code"
101
- ? "bg-neutral-800 text-white"
102
- : "text-neutral-500 hover:text-neutral-200"
103
- }`}
104
- >
105
- Code
106
- </button>
107
- <button
108
- type="button"
109
- onClick={() => selectTab("compositions")}
110
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
111
- tab === "compositions"
112
- ? "bg-neutral-800 text-white"
113
- : "text-neutral-500 hover:text-neutral-200"
114
- }`}
115
- >
116
- Compositions
117
- </button>
118
- <button
119
- type="button"
120
- onClick={() => selectTab("assets")}
121
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
122
- tab === "assets"
123
- ? "bg-neutral-800 text-white"
124
- : "text-neutral-500 hover:text-neutral-200"
125
- }`}
94
+ <div className="flex items-center gap-2">
95
+ <div
96
+ className="grid min-w-0 flex-1 gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
97
+ style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
126
98
  >
127
- Assets
128
- </button>
99
+ <button
100
+ type="button"
101
+ onClick={() => selectTab("code")}
102
+ className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
103
+ tab === "code"
104
+ ? "bg-neutral-800 text-white"
105
+ : "text-neutral-500 hover:text-neutral-200"
106
+ }`}
107
+ >
108
+ Code
109
+ </button>
110
+ <button
111
+ type="button"
112
+ onClick={() => selectTab("compositions")}
113
+ className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
114
+ tab === "compositions"
115
+ ? "bg-neutral-800 text-white"
116
+ : "text-neutral-500 hover:text-neutral-200"
117
+ }`}
118
+ >
119
+ Compositions
120
+ </button>
121
+ <button
122
+ type="button"
123
+ onClick={() => selectTab("assets")}
124
+ className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
125
+ tab === "assets"
126
+ ? "bg-neutral-800 text-white"
127
+ : "text-neutral-500 hover:text-neutral-200"
128
+ }`}
129
+ >
130
+ Assets
131
+ </button>
132
+ </div>
133
+ {onToggleCollapse && (
134
+ <button
135
+ type="button"
136
+ onClick={onToggleCollapse}
137
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
138
+ title="Hide sidebar"
139
+ aria-label="Hide sidebar"
140
+ >
141
+ <svg
142
+ width="14"
143
+ height="14"
144
+ viewBox="0 0 24 24"
145
+ fill="none"
146
+ stroke="currentColor"
147
+ strokeWidth="1.5"
148
+ strokeLinecap="round"
149
+ strokeLinejoin="round"
150
+ aria-hidden="true"
151
+ >
152
+ <path d="m14 7-5 5 5 5" />
153
+ <path d="M19 4v16" />
154
+ </svg>
155
+ </button>
156
+ )}
129
157
  </div>
130
158
  </div>
131
159
 
@@ -0,0 +1,255 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createEmptyEditHistory } from "../utils/editHistory";
3
+ import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
4
+ import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
5
+ import {
6
+ createPersistentEditHistoryController,
7
+ createPersistentEditHistoryStore,
8
+ } from "./usePersistentEditHistory";
9
+
10
+ describe("createPersistentEditHistoryController", () => {
11
+ it("records history and reloads it for the same project", async () => {
12
+ const storage = createMemoryEditHistoryStorage();
13
+ const first = await createPersistentEditHistoryController({
14
+ projectId: "project-1",
15
+ storage,
16
+ now: () => 100,
17
+ onChange: () => {},
18
+ });
19
+
20
+ await first.recordEdit({
21
+ label: "Move layer",
22
+ kind: "manual",
23
+ files: { "index.html": { before: "a", after: "b" } },
24
+ });
25
+
26
+ const second = await createPersistentEditHistoryController({
27
+ projectId: "project-1",
28
+ storage,
29
+ now: () => 200,
30
+ onChange: () => {},
31
+ });
32
+
33
+ expect(second.snapshot().canUndo).toBe(true);
34
+ expect(second.snapshot().undoLabel).toBe("Move layer");
35
+ expect(second.snapshot().undoPaths).toEqual(["index.html"]);
36
+ });
37
+
38
+ it("undo applies files through the provided callback and persists redo state", async () => {
39
+ const storage = createMemoryEditHistoryStorage();
40
+ const controller = await createPersistentEditHistoryController({
41
+ projectId: "project-1",
42
+ storage,
43
+ now: () => 100,
44
+ onChange: () => {},
45
+ });
46
+ await controller.recordEdit({
47
+ label: "Move layer",
48
+ kind: "manual",
49
+ files: { "index.html": { before: "a", after: "b" } },
50
+ });
51
+
52
+ const result = await controller.undo({
53
+ readFile: async (path) => {
54
+ expect(path).toBe("index.html");
55
+ return "b";
56
+ },
57
+ writeFile: async (path, content) => {
58
+ expect(path).toBe("index.html");
59
+ expect(content).toBe("a");
60
+ },
61
+ });
62
+ expect(result.ok).toBe(true);
63
+
64
+ expect(controller.snapshot().canUndo).toBe(false);
65
+ expect(controller.snapshot().canRedo).toBe(true);
66
+ expect(controller.snapshot().redoPaths).toEqual(["index.html"]);
67
+ });
68
+
69
+ it("keeps in-memory history when storage saves fail", async () => {
70
+ const storage: EditHistoryStorageAdapter = {
71
+ async get() {
72
+ return null;
73
+ },
74
+ async set() {
75
+ throw new Error("IndexedDB unavailable");
76
+ },
77
+ async delete() {},
78
+ };
79
+ const controller = await createPersistentEditHistoryController({
80
+ projectId: "project-1",
81
+ storage,
82
+ now: () => 100,
83
+ onChange: () => {},
84
+ });
85
+
86
+ await expect(
87
+ controller.recordEdit({
88
+ label: "Move layer",
89
+ kind: "manual",
90
+ files: { "index.html": { before: "a", after: "b" } },
91
+ }),
92
+ ).resolves.toBeUndefined();
93
+
94
+ expect(controller.snapshot().canUndo).toBe(true);
95
+ });
96
+
97
+ it("serializes concurrent record edits against the latest state", async () => {
98
+ const storage = createMemoryEditHistoryStorage();
99
+ let timestamp = 100;
100
+ const store = createPersistentEditHistoryStore({
101
+ projectId: "project-1",
102
+ storage,
103
+ initialState: createEmptyEditHistory(),
104
+ now: () => timestamp++,
105
+ onChange: () => {},
106
+ });
107
+
108
+ await Promise.all([
109
+ store.recordEdit({
110
+ label: "Move layer",
111
+ kind: "manual",
112
+ files: { "index.html": { before: "a", after: "b" } },
113
+ }),
114
+ store.recordEdit({
115
+ label: "Resize layer",
116
+ kind: "manual",
117
+ files: { "index.html": { before: "b", after: "c" } },
118
+ }),
119
+ ]);
120
+
121
+ expect(store.snapshot().state.undo.map((entry) => entry.label)).toEqual([
122
+ "Move layer",
123
+ "Resize layer",
124
+ ]);
125
+ });
126
+
127
+ it("still coalesces concurrent source edits that share a coalesce key", async () => {
128
+ const storage = createMemoryEditHistoryStorage();
129
+ let timestamp = 100;
130
+ const store = createPersistentEditHistoryStore({
131
+ projectId: "project-1",
132
+ storage,
133
+ initialState: createEmptyEditHistory(),
134
+ now: () => timestamp++,
135
+ onChange: () => {},
136
+ });
137
+
138
+ await Promise.all([
139
+ store.recordEdit({
140
+ label: "Edit source",
141
+ kind: "source",
142
+ coalesceKey: "source:index.html",
143
+ files: { "index.html": { before: "a", after: "b" } },
144
+ }),
145
+ store.recordEdit({
146
+ label: "Edit source",
147
+ kind: "source",
148
+ coalesceKey: "source:index.html",
149
+ files: { "index.html": { before: "b", after: "c" } },
150
+ }),
151
+ ]);
152
+
153
+ expect(store.snapshot().state.undo).toHaveLength(1);
154
+ expect(store.snapshot().state.undo[0].files["index.html"].before).toBe("a");
155
+ expect(store.snapshot().state.undo[0].files["index.html"].after).toBe("c");
156
+ });
157
+
158
+ it("reads undo hashes from the live top entry during queued undo calls", async () => {
159
+ const storage = createMemoryEditHistoryStorage();
160
+ let timestamp = 100;
161
+ const store = createPersistentEditHistoryStore({
162
+ projectId: "project-1",
163
+ storage,
164
+ initialState: createEmptyEditHistory(),
165
+ now: () => timestamp++,
166
+ onChange: () => {},
167
+ });
168
+ await store.recordEdit({
169
+ label: "Edit first file",
170
+ kind: "manual",
171
+ files: { "first.html": { before: "first-before", after: "first-after" } },
172
+ });
173
+ await store.recordEdit({
174
+ label: "Edit second file",
175
+ kind: "manual",
176
+ files: { "second.html": { before: "second-before", after: "second-after" } },
177
+ });
178
+
179
+ const files: Record<string, string> = {
180
+ "first.html": "first-after",
181
+ "second.html": "second-after",
182
+ };
183
+ const readPaths: string[] = [];
184
+
185
+ await Promise.all([
186
+ store.undo({
187
+ readFile: async (path) => {
188
+ readPaths.push(path);
189
+ return files[path];
190
+ },
191
+ writeFile: async (path, content) => {
192
+ files[path] = content;
193
+ },
194
+ }),
195
+ store.undo({
196
+ readFile: async (path) => {
197
+ readPaths.push(path);
198
+ return files[path];
199
+ },
200
+ writeFile: async (path, content) => {
201
+ files[path] = content;
202
+ },
203
+ }),
204
+ ]);
205
+
206
+ expect(readPaths).toEqual(["second.html", "first.html"]);
207
+ expect(files).toEqual({
208
+ "first.html": "first-before",
209
+ "second.html": "second-before",
210
+ });
211
+ expect(store.snapshot().canUndo).toBe(false);
212
+ expect(store.snapshot().canRedo).toBe(true);
213
+ });
214
+
215
+ it("rolls back files when an undo write fails partway through", async () => {
216
+ const storage = createMemoryEditHistoryStorage();
217
+ const store = createPersistentEditHistoryStore({
218
+ projectId: "project-1",
219
+ storage,
220
+ initialState: createEmptyEditHistory(),
221
+ now: () => 100,
222
+ onChange: () => {},
223
+ });
224
+ await store.recordEdit({
225
+ label: "Edit files",
226
+ kind: "manual",
227
+ files: {
228
+ "first.html": { before: "first-before", after: "first-after" },
229
+ "second.html": { before: "second-before", after: "second-after" },
230
+ },
231
+ });
232
+
233
+ const files: Record<string, string> = {
234
+ "first.html": "first-after",
235
+ "second.html": "second-after",
236
+ };
237
+ const result = store.undo({
238
+ readFile: async (path) => files[path],
239
+ writeFile: async (path, content) => {
240
+ if (path === "second.html" && content === "second-before") {
241
+ throw new Error("write failed");
242
+ }
243
+ files[path] = content;
244
+ },
245
+ });
246
+
247
+ await expect(result).rejects.toThrow("write failed");
248
+ expect(files).toEqual({
249
+ "first.html": "first-after",
250
+ "second.html": "second-after",
251
+ });
252
+ expect(store.snapshot().undoLabel).toBe("Edit files");
253
+ expect(store.snapshot().canRedo).toBe(false);
254
+ });
255
+ });