@hyperframes/studio 0.5.5 → 0.6.0-alpha.2

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 (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-UWFaHilT.css +1 -0
  3. package/dist/assets/index-cPJbxeAk.js +107 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +129 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -0,0 +1,945 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { Window } from "happy-dom";
3
+ import type { DomEditSelection } from "./domEditing";
4
+ import {
5
+ STUDIO_OFFSET_X_PROP,
6
+ STUDIO_OFFSET_Y_PROP,
7
+ STUDIO_ROTATION_PROP,
8
+ STUDIO_WIDTH_PROP,
9
+ applyStudioBoxSize,
10
+ applyStudioBoxSizeDraft,
11
+ applyStudioManualEditManifest,
12
+ applyStudioPathOffset,
13
+ applyStudioPathOffsetDraft,
14
+ applyStudioRotation,
15
+ applyStudioRotationDraft,
16
+ beginStudioManualEditGesture,
17
+ captureStudioBoxSize,
18
+ captureStudioRotation,
19
+ emptyStudioManualEditManifest,
20
+ endStudioManualEditGesture,
21
+ installStudioManualEditSeekReapply,
22
+ isStudioManualEditManifestPath,
23
+ parseStudioManualEditManifest,
24
+ readStudioFileChangePath,
25
+ readStudioBoxSize,
26
+ readStudioPathOffset,
27
+ readStudioRotation,
28
+ removeStudioManualEditsForSelection,
29
+ restoreStudioBoxSize,
30
+ restoreStudioRotation,
31
+ serializeStudioManualEditManifest,
32
+ upsertStudioBoxSizeEdit,
33
+ upsertStudioPathOffsetEdit,
34
+ upsertStudioRotationEdit,
35
+ } from "./manualEdits";
36
+
37
+ function createDocument(markup: string): Document {
38
+ const window = new Window();
39
+ window.document.body.innerHTML = markup;
40
+ return window.document;
41
+ }
42
+
43
+ function createSelection(): DomEditSelection {
44
+ return {
45
+ element: {} as HTMLElement,
46
+ id: "card",
47
+ selector: "#card",
48
+ selectorIndex: undefined,
49
+ sourceFile: "index.html",
50
+ compositionPath: "index.html",
51
+ compositionSrc: undefined,
52
+ isCompositionHost: false,
53
+ label: "Card",
54
+ tagName: "div",
55
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
56
+ textContent: null,
57
+ dataAttributes: {},
58
+ inlineStyles: {},
59
+ computedStyles: {},
60
+ textFields: [],
61
+ capabilities: {
62
+ canSelect: true,
63
+ canEditStyles: true,
64
+ canMove: false,
65
+ canResize: false,
66
+ canApplyManualOffset: true,
67
+ canApplyManualSize: true,
68
+ canApplyManualRotation: true,
69
+ },
70
+ };
71
+ }
72
+
73
+ function mockBoundingRect(element: HTMLElement, width: number, height: number): void {
74
+ element.getBoundingClientRect = () =>
75
+ ({
76
+ x: 0,
77
+ y: 0,
78
+ left: 0,
79
+ top: 0,
80
+ right: width,
81
+ bottom: height,
82
+ width,
83
+ height,
84
+ toJSON: () => ({}),
85
+ }) as DOMRect;
86
+ }
87
+
88
+ function mockComputedStyle(element: HTMLElement, values: Record<string, string>): void {
89
+ const win = element.ownerDocument.defaultView;
90
+ if (!win) throw new Error("defaultView fixture missing");
91
+ win.getComputedStyle = ((target: Element) =>
92
+ ({
93
+ getPropertyValue: (property: string) => (target === element ? (values[property] ?? "") : ""),
94
+ }) as CSSStyleDeclaration) as typeof win.getComputedStyle;
95
+ }
96
+
97
+ describe("studio manual edits", () => {
98
+ it("upserts path offsets by stable target", () => {
99
+ const manifest = upsertStudioPathOffsetEdit(
100
+ emptyStudioManualEditManifest(),
101
+ createSelection(),
102
+ {
103
+ x: 12.4,
104
+ y: 30.6,
105
+ },
106
+ );
107
+ const updated = upsertStudioPathOffsetEdit(manifest, createSelection(), {
108
+ x: 20,
109
+ y: 42,
110
+ });
111
+
112
+ expect(updated.edits).toHaveLength(1);
113
+ expect(updated.edits[0]).toMatchObject({
114
+ kind: "path-offset",
115
+ target: { sourceFile: "index.html", selector: "#card", id: "card" },
116
+ x: 20,
117
+ y: 42,
118
+ });
119
+ });
120
+
121
+ it("upserts box sizes without replacing path offsets for the same target", () => {
122
+ const selection = createSelection();
123
+ const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
124
+ x: 12,
125
+ y: 30,
126
+ });
127
+ const updated = upsertStudioBoxSizeEdit(manifest, selection, {
128
+ width: 240.4,
129
+ height: 120.6,
130
+ });
131
+ const resized = upsertStudioBoxSizeEdit(updated, selection, {
132
+ width: 260,
133
+ height: 140,
134
+ });
135
+
136
+ expect(resized.edits).toHaveLength(2);
137
+ expect(resized.edits).toEqual(
138
+ expect.arrayContaining([
139
+ expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }),
140
+ expect.objectContaining({ kind: "box-size", width: 260, height: 140 }),
141
+ ]),
142
+ );
143
+ });
144
+
145
+ it("upserts rotations without replacing other manual edits for the same target", () => {
146
+ const selection = createSelection();
147
+ const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
148
+ x: 12,
149
+ y: 30,
150
+ });
151
+ const resized = upsertStudioBoxSizeEdit(manifest, selection, {
152
+ width: 240,
153
+ height: 120,
154
+ });
155
+ const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32.34 });
156
+ const updated = upsertStudioRotationEdit(rotated, selection, { angle: -14.96 });
157
+
158
+ expect(updated.edits).toHaveLength(3);
159
+ expect(updated.edits).toEqual(
160
+ expect.arrayContaining([
161
+ expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }),
162
+ expect.objectContaining({ kind: "box-size", width: 240, height: 120 }),
163
+ expect.objectContaining({ kind: "rotation", angle: -15 }),
164
+ ]),
165
+ );
166
+ });
167
+
168
+ it("removes all manual edits for the selected target", () => {
169
+ const selection = createSelection();
170
+ const otherSelection = {
171
+ ...createSelection(),
172
+ id: "other-card",
173
+ selector: "#other-card",
174
+ label: "Other card",
175
+ };
176
+ const moved = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
177
+ x: 12,
178
+ y: 30,
179
+ });
180
+ const resized = upsertStudioBoxSizeEdit(moved, selection, {
181
+ width: 240,
182
+ height: 120,
183
+ });
184
+ const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32 });
185
+ const manifest = upsertStudioPathOffsetEdit(rotated, otherSelection, { x: 4, y: 8 });
186
+
187
+ const updated = removeStudioManualEditsForSelection(manifest, selection);
188
+
189
+ expect(updated.edits).toHaveLength(1);
190
+ expect(updated.edits[0]).toMatchObject({
191
+ kind: "path-offset",
192
+ target: { id: "other-card", selector: "#other-card" },
193
+ x: 4,
194
+ y: 8,
195
+ });
196
+ });
197
+
198
+ it("round-trips valid manifest entries and drops invalid entries", () => {
199
+ const content = serializeStudioManualEditManifest({
200
+ version: 1,
201
+ edits: [
202
+ {
203
+ kind: "path-offset",
204
+ target: { sourceFile: "index.html", selector: "#card", id: "card" },
205
+ x: 10,
206
+ y: 20,
207
+ },
208
+ {
209
+ kind: "box-size",
210
+ target: { sourceFile: "index.html", selector: "#card", id: "card" },
211
+ width: 320,
212
+ height: 180,
213
+ },
214
+ {
215
+ kind: "rotation",
216
+ target: { sourceFile: "index.html", selector: "#card", id: "card" },
217
+ angle: 22.5,
218
+ },
219
+ ],
220
+ });
221
+
222
+ expect(parseStudioManualEditManifest(content).edits).toHaveLength(3);
223
+ expect(parseStudioManualEditManifest('{ "edits": [{ "kind": "path-offset" }] }').edits).toEqual(
224
+ [],
225
+ );
226
+ });
227
+
228
+ it("recognizes manual edit manifest file-change payloads", () => {
229
+ expect(readStudioFileChangePath({ path: ".hyperframes/studio-manual-edits.json" })).toBe(
230
+ ".hyperframes/studio-manual-edits.json",
231
+ );
232
+ expect(readStudioFileChangePath({ data: '{"path":"nested/file.html"}' })).toBe(
233
+ "nested/file.html",
234
+ );
235
+ expect(
236
+ isStudioManualEditManifestPath(
237
+ "/Users/example/project/.hyperframes/studio-manual-edits.json",
238
+ ),
239
+ ).toBe(true);
240
+ expect(isStudioManualEditManifestPath("index.html")).toBe(false);
241
+ });
242
+
243
+ it("applies offsets through CSS translate longhand", () => {
244
+ const document = createDocument(`<div id="card"></div>`);
245
+ const card = document.getElementById("card") as HTMLElement;
246
+
247
+ applyStudioPathOffset(card, { x: 14, y: -8 });
248
+
249
+ expect(readStudioPathOffset(card)).toEqual({ x: 14, y: -8 });
250
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("14px");
251
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("-8px");
252
+ expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
253
+ });
254
+
255
+ it("preserves authored inline translate as the additive path offset base", () => {
256
+ const document = createDocument(`<div id="card" style="translate: 10px 20px"></div>`);
257
+ const card = document.getElementById("card") as HTMLElement;
258
+
259
+ applyStudioPathOffset(card, { x: 14, y: -8 });
260
+
261
+ expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
262
+ expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
263
+ expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
264
+ expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_Y_PROP);
265
+ });
266
+
267
+ it("preserves stylesheet-authored transform longhands as additive bases", () => {
268
+ const document = createDocument(`<div id="card"></div>`);
269
+ const card = document.getElementById("card") as HTMLElement;
270
+ mockComputedStyle(card, {
271
+ translate: "10px 20px",
272
+ rotate: "8deg",
273
+ });
274
+
275
+ applyStudioPathOffset(card, { x: 14, y: -8 });
276
+ applyStudioRotation(card, { angle: 12 });
277
+
278
+ expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
279
+ expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
280
+ expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
281
+ expect(card.style.getPropertyValue("rotate")).toContain("8deg");
282
+ expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
283
+ });
284
+
285
+ it("clears computed transform bases without freezing them inline", () => {
286
+ const document = createDocument(`<div id="card"></div>`);
287
+ const card = document.getElementById("card") as HTMLElement;
288
+ mockComputedStyle(card, {
289
+ translate: "10px 20px",
290
+ rotate: "8deg",
291
+ });
292
+
293
+ applyStudioPathOffset(card, { x: 14, y: -8 });
294
+ applyStudioRotation(card, { angle: 12 });
295
+
296
+ expect(
297
+ applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
298
+ ).toBe(0);
299
+
300
+ expect(card.style.getPropertyValue("translate")).toBe("");
301
+ expect(card.style.getPropertyValue("rotate")).toBe("");
302
+ });
303
+
304
+ it("does not compound stale studio variables as authored transform bases", () => {
305
+ const document = createDocument(`<div id="card"></div>`);
306
+ const card = document.getElementById("card") as HTMLElement;
307
+
308
+ card.style.setProperty(
309
+ "translate",
310
+ `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
311
+ );
312
+ card.style.setProperty("rotate", `var(${STUDIO_ROTATION_PROP}, 0deg)`);
313
+
314
+ applyStudioPathOffset(card, { x: 14, y: -8 });
315
+ applyStudioRotation(card, { angle: 12 });
316
+
317
+ expect(card.style.getPropertyValue("translate")).toBe(
318
+ `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
319
+ );
320
+ expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
321
+ });
322
+
323
+ it("applies box sizes through CSS dimensions and flex sizing overrides", () => {
324
+ const document = createDocument(`
325
+ <div style="display: flex; flex-direction: row">
326
+ <div id="card" style="width: 160px; height: 90px"></div>
327
+ </div>
328
+ `);
329
+ const card = document.getElementById("card") as HTMLElement;
330
+ mockBoundingRect(card, 160, 90);
331
+
332
+ applyStudioBoxSize(card, { width: 240, height: 135 });
333
+
334
+ expect(readStudioBoxSize(card)).toEqual({ width: 240, height: 135 });
335
+ expect(card.style.getPropertyValue(STUDIO_WIDTH_PROP)).toBe("240px");
336
+ expect(card.style.getPropertyValue("width")).toBe("240px");
337
+ expect(card.style.getPropertyValue("height")).toBe("135px");
338
+ expect(card.style.getPropertyValue("flex-basis")).toBe("240px");
339
+ expect(card.style.getPropertyValue("flex-grow")).toBe("0");
340
+ expect(card.style.getPropertyValue("flex-shrink")).toBe("0");
341
+ expect(card.style.getPropertyValue("box-sizing")).toBe("border-box");
342
+ expect(card.style.getPropertyValue("scale")).toBe("");
343
+
344
+ applyStudioBoxSizeDraft(card, { width: 260, height: 150 });
345
+ expect(readStudioBoxSize(card)).toEqual({ width: 260, height: 150 });
346
+ expect(card.style.getPropertyValue("width")).toBe("260px");
347
+ expect(card.style.getPropertyValue("height")).toBe("150px");
348
+ expect(card.style.getPropertyValue("flex-basis")).toBe("260px");
349
+
350
+ const snapshot = captureStudioBoxSize(card);
351
+ applyStudioBoxSizeDraft(card, { width: 280, height: 160 });
352
+ restoreStudioBoxSize(card, snapshot);
353
+ expect(readStudioBoxSize(card)).toEqual({ width: 260, height: 150 });
354
+ expect(card.style.getPropertyValue("width")).toBe("260px");
355
+ expect(card.style.getPropertyValue("height")).toBe("150px");
356
+ expect(card.style.getPropertyValue("flex-basis")).toBe("260px");
357
+ });
358
+
359
+ it("applies rotations through CSS rotate longhand around the element center", () => {
360
+ const document = createDocument(
361
+ `<div id="card" style="rotate: 8deg; transform-origin: left top"></div>`,
362
+ );
363
+ const card = document.getElementById("card") as HTMLElement;
364
+
365
+ applyStudioRotation(card, { angle: 24.24 });
366
+
367
+ expect(readStudioRotation(card)).toEqual({ angle: 24.2 });
368
+ expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.2deg");
369
+ expect(card.style.getPropertyValue("rotate")).toContain("8deg");
370
+ expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
371
+ expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
372
+
373
+ applyStudioRotationDraft(card, { angle: -12.26 });
374
+ expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
375
+ expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
376
+ expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
377
+
378
+ const snapshot = captureStudioRotation(card);
379
+ applyStudioRotationDraft(card, { angle: 45 });
380
+ restoreStudioRotation(card, snapshot);
381
+ expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
382
+ expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
383
+ expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
384
+ });
385
+
386
+ it("does not recapture a studio rotation draft as the authored base", () => {
387
+ const document = createDocument(`<div id="card" style="rotate: 8deg"></div>`);
388
+ const card = document.getElementById("card") as HTMLElement;
389
+ const manifest = parseStudioManualEditManifest(`{
390
+ "version": 1,
391
+ "edits": [
392
+ {
393
+ "kind": "rotation",
394
+ "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
395
+ "angle": 35
396
+ }
397
+ ]
398
+ }`);
399
+
400
+ applyStudioRotation(card, { angle: 12 });
401
+ applyStudioRotationDraft(card, { angle: 35 });
402
+ expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + 35deg)");
403
+
404
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
405
+
406
+ expect(card.style.getPropertyValue("rotate")).toBe(
407
+ `calc(8deg + var(${STUDIO_ROTATION_PROP}, 0deg))`,
408
+ );
409
+ });
410
+
411
+ it("does not treat a base-free studio rotation draft as authored rotation", () => {
412
+ const document = createDocument(`<div id="card"></div>`);
413
+ const card = document.getElementById("card") as HTMLElement;
414
+ const manifest = parseStudioManualEditManifest(`{
415
+ "version": 1,
416
+ "edits": [
417
+ {
418
+ "kind": "rotation",
419
+ "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
420
+ "angle": 35
421
+ }
422
+ ]
423
+ }`);
424
+
425
+ applyStudioRotation(card, { angle: 12 });
426
+ applyStudioRotationDraft(card, { angle: 35 });
427
+ expect(card.style.getPropertyValue("rotate")).toBe("35deg");
428
+
429
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
430
+
431
+ expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
432
+ });
433
+
434
+ it("uses height for flex-basis inside column flex containers", () => {
435
+ const document = createDocument(`
436
+ <div style="display: flex; flex-direction: column">
437
+ <div id="card" style="width: 160px; height: 90px"></div>
438
+ </div>
439
+ `);
440
+ const card = document.getElementById("card") as HTMLElement;
441
+
442
+ applyStudioBoxSize(card, { width: 240, height: 135 });
443
+
444
+ expect(card.style.getPropertyValue("width")).toBe("240px");
445
+ expect(card.style.getPropertyValue("height")).toBe("135px");
446
+ expect(card.style.getPropertyValue("flex-basis")).toBe("135px");
447
+ });
448
+
449
+ it("uses additive CSS translate without mutating GSAP tweens during path-offset moves", () => {
450
+ const document = createDocument(`<div id="card"></div>`);
451
+ const card = document.getElementById("card") as HTMLElement;
452
+ const getTweensOf = vi.fn();
453
+ const getProperty = vi.fn();
454
+ const set = vi.fn();
455
+ const tickerTick = vi.fn();
456
+ const tween = {
457
+ vars: { x: 0, y: 10, startAt: { x: -240, y: -20 } },
458
+ targets: () => [card],
459
+ invalidate: vi.fn(),
460
+ parent: {
461
+ time: () => 1.25,
462
+ totalTime: vi.fn(),
463
+ invalidate: vi.fn(),
464
+ },
465
+ _startAt: {
466
+ vars: { x: -240, y: -20 },
467
+ invalidate: vi.fn(),
468
+ },
469
+ };
470
+
471
+ (
472
+ document.defaultView as unknown as {
473
+ gsap: {
474
+ getTweensOf: () => Array<typeof tween>;
475
+ getProperty: (_target: Element, property: string) => unknown;
476
+ set: (_target: Element, vars: Record<string, unknown>) => void;
477
+ ticker: { tick: () => void };
478
+ };
479
+ }
480
+ ).gsap = {
481
+ getTweensOf,
482
+ getProperty,
483
+ set,
484
+ ticker: { tick: tickerTick },
485
+ };
486
+
487
+ applyStudioPathOffset(card, { x: 30, y: -12 });
488
+
489
+ expect(tween.vars).toMatchObject({
490
+ x: 0,
491
+ y: 10,
492
+ startAt: { x: -240, y: -20 },
493
+ });
494
+ expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
495
+ expect(readStudioPathOffset(card)).toEqual({ x: 30, y: -12 });
496
+ expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
497
+ expect(getTweensOf).not.toHaveBeenCalled();
498
+ expect(getProperty).not.toHaveBeenCalled();
499
+ expect(set).not.toHaveBeenCalled();
500
+ expect(tickerTick).not.toHaveBeenCalled();
501
+
502
+ beginStudioManualEditGesture(card);
503
+ applyStudioPathOffsetDraft(card, { x: 35, y: -6 });
504
+
505
+ expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
506
+ expect(card.style.getPropertyValue("translate")).toBe("35px -6px");
507
+ expect(tween.vars).toMatchObject({
508
+ x: 0,
509
+ y: 10,
510
+ startAt: { x: -240, y: -20 },
511
+ });
512
+ expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
513
+ expect(tickerTick).not.toHaveBeenCalled();
514
+
515
+ applyStudioPathOffset(card, { x: 35, y: -6 });
516
+ endStudioManualEditGesture(card);
517
+
518
+ expect(tween.vars).toMatchObject({
519
+ x: 0,
520
+ y: 10,
521
+ startAt: { x: -240, y: -20 },
522
+ });
523
+ expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
524
+ expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
525
+
526
+ expect(
527
+ applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
528
+ ).toBe(0);
529
+ expect(tween.vars).toMatchObject({
530
+ x: 0,
531
+ y: 10,
532
+ startAt: { x: -240, y: -20 },
533
+ });
534
+ expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
535
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
536
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
537
+ expect(card.style.getPropertyValue("translate")).toBe("");
538
+ });
539
+
540
+ it("applies manifest offsets to matching preview elements", () => {
541
+ const document = createDocument(`<div id="card"></div>`);
542
+ const manifest = parseStudioManualEditManifest(`{
543
+ "version": 1,
544
+ "edits": [
545
+ {
546
+ "kind": "path-offset",
547
+ "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
548
+ "x": 32,
549
+ "y": 18
550
+ }
551
+ ]
552
+ }`);
553
+
554
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
555
+ expect(readStudioPathOffset(document.getElementById("card") as HTMLElement)).toEqual({
556
+ x: 32,
557
+ y: 18,
558
+ });
559
+ });
560
+
561
+ it("resolves manifest targets within the matching source file", () => {
562
+ const document = createDocument(`
563
+ <div data-composition-id="root">
564
+ <div id="card" class="tile"></div>
565
+ <div data-composition-id="nested" data-composition-file="scenes/nested.html">
566
+ <div id="card" class="tile"></div>
567
+ <div class="tile"></div>
568
+ </div>
569
+ </div>
570
+ `);
571
+ const htmlElement = document.defaultView?.HTMLElement;
572
+ if (!htmlElement) throw new Error("HTMLElement fixture missing");
573
+ const cards = Array.from(document.getElementsByTagName("*")).filter(
574
+ (element): element is HTMLElement => element instanceof htmlElement && element.id === "card",
575
+ );
576
+ const rootCard = cards[0];
577
+ const nestedCard = cards[1];
578
+ const tiles = Array.from(document.getElementsByTagName("*")).filter(
579
+ (element): element is HTMLElement =>
580
+ element instanceof htmlElement && element.classList.contains("tile"),
581
+ );
582
+ const nestedSecondTile = tiles[2];
583
+ if (!rootCard || !nestedCard || !nestedSecondTile) {
584
+ throw new Error("source-scoped fixture missing");
585
+ }
586
+
587
+ const manifest = parseStudioManualEditManifest(`{
588
+ "version": 1,
589
+ "edits": [
590
+ {
591
+ "kind": "path-offset",
592
+ "target": {
593
+ "sourceFile": "scenes/nested.html",
594
+ "selector": "#card",
595
+ "id": "card"
596
+ },
597
+ "x": 48,
598
+ "y": 16
599
+ },
600
+ {
601
+ "kind": "box-size",
602
+ "target": {
603
+ "sourceFile": "scenes/nested.html",
604
+ "selector": ".tile",
605
+ "selectorIndex": 1
606
+ },
607
+ "width": 220,
608
+ "height": 80
609
+ }
610
+ ]
611
+ }`);
612
+
613
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(2);
614
+ expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 });
615
+ expect(readStudioPathOffset(nestedCard)).toEqual({ x: 48, y: 16 });
616
+ expect(readStudioBoxSize(nestedSecondTile)).toEqual({ width: 220, height: 80 });
617
+ });
618
+
619
+ it("resolves manifest targets inside composition-file hosts without composition ids", () => {
620
+ const document = createDocument(`
621
+ <div data-composition-id="root">
622
+ <div id="card"></div>
623
+ <div data-composition-file="scenes/anonymous.html">
624
+ <div id="card"></div>
625
+ </div>
626
+ </div>
627
+ `);
628
+ const htmlElement = document.defaultView?.HTMLElement;
629
+ if (!htmlElement) throw new Error("HTMLElement fixture missing");
630
+ const cards = Array.from(document.getElementsByTagName("*")).filter(
631
+ (element): element is HTMLElement => element instanceof htmlElement && element.id === "card",
632
+ );
633
+ const rootCard = cards[0];
634
+ const nestedCard = cards[1];
635
+ if (!rootCard || !nestedCard) {
636
+ throw new Error("anonymous composition fixture missing");
637
+ }
638
+
639
+ const manifest = parseStudioManualEditManifest(`{
640
+ "version": 1,
641
+ "edits": [
642
+ {
643
+ "kind": "path-offset",
644
+ "target": {
645
+ "sourceFile": "scenes/anonymous.html",
646
+ "selector": "#card",
647
+ "id": "card"
648
+ },
649
+ "x": 24,
650
+ "y": 12
651
+ }
652
+ ]
653
+ }`);
654
+
655
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
656
+ expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 });
657
+ expect(readStudioPathOffset(nestedCard)).toEqual({ x: 24, y: 12 });
658
+ });
659
+
660
+ it("applies nested source edits while previewing a non-index parent composition", () => {
661
+ const document = createDocument(`
662
+ <div data-composition-id="parent">
663
+ <div id="parent-card"></div>
664
+ <div data-composition-file="scenes/child.html">
665
+ <div id="child-card"></div>
666
+ </div>
667
+ </div>
668
+ `);
669
+ const parentCard = document.getElementById("parent-card") as HTMLElement;
670
+ const childCard = document.getElementById("child-card") as HTMLElement;
671
+ const manifest = parseStudioManualEditManifest(`{
672
+ "version": 1,
673
+ "edits": [
674
+ {
675
+ "kind": "path-offset",
676
+ "target": {
677
+ "sourceFile": "scenes/parent.html",
678
+ "selector": "#parent-card",
679
+ "id": "parent-card"
680
+ },
681
+ "x": 12,
682
+ "y": 8
683
+ },
684
+ {
685
+ "kind": "path-offset",
686
+ "target": {
687
+ "sourceFile": "scenes/child.html",
688
+ "selector": "#child-card",
689
+ "id": "child-card"
690
+ },
691
+ "x": 36,
692
+ "y": 18
693
+ }
694
+ ]
695
+ }`);
696
+
697
+ expect(applyStudioManualEditManifest(document, manifest, "scenes/parent.html")).toBe(2);
698
+ expect(readStudioPathOffset(parentCard)).toEqual({ x: 12, y: 8 });
699
+ expect(readStudioPathOffset(childCard)).toEqual({ x: 36, y: 18 });
700
+ });
701
+
702
+ it("applies and clears manifest box sizes while restoring authored inline size", () => {
703
+ const document = createDocument(`
704
+ <div style="display: flex; flex-direction: row">
705
+ <div id="card" style="width: 160px; height: 90px"></div>
706
+ </div>
707
+ `);
708
+ const manifest = parseStudioManualEditManifest(`{
709
+ "version": 1,
710
+ "edits": [
711
+ {
712
+ "kind": "box-size",
713
+ "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
714
+ "width": 320,
715
+ "height": 180
716
+ }
717
+ ]
718
+ }`);
719
+ const card = document.getElementById("card") as HTMLElement;
720
+ mockBoundingRect(card, 160, 90);
721
+
722
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
723
+ expect(readStudioBoxSize(card)).toEqual({ width: 320, height: 180 });
724
+ expect(card.style.getPropertyValue("width")).toBe("320px");
725
+ expect(card.style.getPropertyValue("height")).toBe("180px");
726
+ expect(card.style.getPropertyValue("flex-basis")).toBe("320px");
727
+
728
+ expect(
729
+ applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
730
+ ).toBe(0);
731
+ expect(readStudioBoxSize(card)).toEqual({ width: 0, height: 0 });
732
+ expect(card.style.getPropertyValue("width")).toBe("160px");
733
+ expect(card.style.getPropertyValue("height")).toBe("90px");
734
+ expect(card.style.getPropertyValue("flex-basis")).toBe("");
735
+ expect(card.style.getPropertyValue("flex-grow")).toBe("");
736
+ expect(card.style.getPropertyValue("flex-shrink")).toBe("");
737
+ expect(card.style.getPropertyValue("scale")).toBe("");
738
+ });
739
+
740
+ it("applies and clears manifest rotations while restoring authored inline rotation", () => {
741
+ const document = createDocument(
742
+ `<div id="card" style="rotate: 8deg; transform-origin: left top"></div>`,
743
+ );
744
+ const manifest = parseStudioManualEditManifest(`{
745
+ "version": 1,
746
+ "edits": [
747
+ {
748
+ "kind": "rotation",
749
+ "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
750
+ "angle": 37.5
751
+ }
752
+ ]
753
+ }`);
754
+ const card = document.getElementById("card") as HTMLElement;
755
+
756
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
757
+ expect(readStudioRotation(card)).toEqual({ angle: 37.5 });
758
+ expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
759
+ expect(card.style.getPropertyValue("rotate")).toContain("8deg");
760
+ expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
761
+
762
+ expect(
763
+ applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
764
+ ).toBe(0);
765
+ expect(readStudioRotation(card)).toEqual({ angle: 0 });
766
+ expect(card.style.getPropertyValue("rotate")).toBe("8deg");
767
+ expect(card.style.getPropertyValue("transform-origin")).toBe("left top");
768
+ });
769
+
770
+ it("clears stale preview offsets that are no longer in the manifest", () => {
771
+ const document = createDocument(`<div id="card"></div>`);
772
+ const card = document.getElementById("card") as HTMLElement;
773
+
774
+ applyStudioPathOffset(card, { x: 24, y: 12 });
775
+ expect(readStudioPathOffset(card)).toEqual({ x: 24, y: 12 });
776
+
777
+ expect(
778
+ applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
779
+ ).toBe(0);
780
+
781
+ expect(readStudioPathOffset(card)).toEqual({ x: 0, y: 0 });
782
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
783
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
784
+ expect(card.style.getPropertyValue("translate")).toBe("");
785
+ });
786
+
787
+ it("restores authored inline translate when clearing offsets", () => {
788
+ const document = createDocument(`<div id="card" style="translate: 10px 20px"></div>`);
789
+ const card = document.getElementById("card") as HTMLElement;
790
+
791
+ applyStudioPathOffset(card, { x: 24, y: 12 });
792
+ expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
793
+
794
+ expect(
795
+ applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
796
+ ).toBe(0);
797
+
798
+ expect(card.style.getPropertyValue("translate")).toBe("10px 20px");
799
+ });
800
+
801
+ it("does not replay the manifest over an active manual edit gesture", () => {
802
+ const document = createDocument(`<div id="card"></div>`);
803
+ const card = document.getElementById("card") as HTMLElement;
804
+ const manifest = parseStudioManualEditManifest(`{
805
+ "version": 1,
806
+ "edits": [
807
+ {
808
+ "kind": "path-offset",
809
+ "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
810
+ "x": 8,
811
+ "y": 4
812
+ }
813
+ ]
814
+ }`);
815
+
816
+ applyStudioPathOffset(card, { x: 40, y: 24 });
817
+ const firstToken = beginStudioManualEditGesture(card);
818
+ const secondToken = beginStudioManualEditGesture(card);
819
+ endStudioManualEditGesture(card, firstToken);
820
+
821
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(0);
822
+ expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 });
823
+
824
+ endStudioManualEditGesture(card, secondToken);
825
+ expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
826
+ expect(readStudioPathOffset(card)).toEqual({ x: 8, y: 4 });
827
+ });
828
+
829
+ it("reapplies the latest preview manifest after wrapped seeks", () => {
830
+ const window = new Window();
831
+ const seekArgs: unknown[][] = [];
832
+ const previewWindow = window as unknown as Parameters<
833
+ typeof installStudioManualEditSeekReapply
834
+ >[0] & {
835
+ __player: Record<string, unknown>;
836
+ };
837
+ previewWindow.__player = {
838
+ seek: (...args: unknown[]) => {
839
+ seekArgs.push(args);
840
+ },
841
+ };
842
+
843
+ let applied = 0;
844
+ expect(
845
+ installStudioManualEditSeekReapply(previewWindow, () => {
846
+ applied += 1;
847
+ }),
848
+ ).toBe(true);
849
+ (previewWindow.__player.seek as (time: number, suppressEvents: boolean) => void)(1, false);
850
+ expect(applied).toBe(1);
851
+ expect(seekArgs).toEqual([[1, false]]);
852
+
853
+ expect(
854
+ installStudioManualEditSeekReapply(previewWindow, () => {
855
+ applied += 10;
856
+ }),
857
+ ).toBe(true);
858
+ (previewWindow.__player.seek as (time: number) => void)(2);
859
+ expect(applied).toBe(11);
860
+ });
861
+
862
+ it("reapplies manual edits while fresh playback is active", () => {
863
+ const window = new Window();
864
+ const frames: FrameRequestCallback[] = [];
865
+ let playing = false;
866
+ const previewWindow = window as unknown as Parameters<
867
+ typeof installStudioManualEditSeekReapply
868
+ >[0] & {
869
+ __player: Record<string, unknown>;
870
+ requestAnimationFrame: (callback: FrameRequestCallback) => number;
871
+ };
872
+ previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
873
+ frames.push(callback);
874
+ return frames.length;
875
+ };
876
+ previewWindow.__player = {
877
+ play: () => {
878
+ playing = true;
879
+ },
880
+ isPlaying: () => playing,
881
+ };
882
+
883
+ let applied = 0;
884
+ expect(
885
+ installStudioManualEditSeekReapply(previewWindow, () => {
886
+ applied += 1;
887
+ }),
888
+ ).toBe(true);
889
+
890
+ (previewWindow.__player.play as () => void)();
891
+ expect(applied).toBe(1);
892
+ expect(frames).toHaveLength(1);
893
+
894
+ frames.shift()?.(16);
895
+ expect(applied).toBe(2);
896
+ expect(frames).toHaveLength(1);
897
+
898
+ playing = false;
899
+ frames.shift()?.(32);
900
+ expect(applied).toBe(3);
901
+ expect(frames).toHaveLength(0);
902
+ });
903
+
904
+ it("stops playback reapply after an unpaused timeline has completed", () => {
905
+ const window = new Window();
906
+ const frames: FrameRequestCallback[] = [];
907
+ let currentTime = 0;
908
+ let paused = true;
909
+ const previewWindow = window as unknown as Parameters<
910
+ typeof installStudioManualEditSeekReapply
911
+ >[0] & {
912
+ __timeline: Record<string, unknown>;
913
+ requestAnimationFrame: (callback: FrameRequestCallback) => number;
914
+ };
915
+ previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
916
+ frames.push(callback);
917
+ return frames.length;
918
+ };
919
+ previewWindow.__timeline = {
920
+ play: () => {
921
+ paused = false;
922
+ },
923
+ paused: () => paused,
924
+ isActive: () => false,
925
+ time: () => currentTime,
926
+ duration: () => 2,
927
+ };
928
+
929
+ let applied = 0;
930
+ expect(
931
+ installStudioManualEditSeekReapply(previewWindow, () => {
932
+ applied += 1;
933
+ }),
934
+ ).toBe(true);
935
+
936
+ (previewWindow.__timeline.play as () => void)();
937
+ expect(applied).toBe(1);
938
+ expect(frames).toHaveLength(1);
939
+
940
+ currentTime = 2;
941
+ frames.shift()?.(16);
942
+ expect(applied).toBe(2);
943
+ expect(frames).toHaveLength(0);
944
+ });
945
+ });