@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,872 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Window } from "happy-dom";
3
+ import {
4
+ buildDomEditStylePatchOperation,
5
+ buildElementAgentPrompt,
6
+ collectDomEditLayerItems,
7
+ countDomEditChildLayers,
8
+ findElementForSelection,
9
+ findElementForTimelineElement,
10
+ getDomEditNonEditableReason,
11
+ getDomEditTargetKey,
12
+ isTextEditableSelection,
13
+ serializeDomEditTextFields,
14
+ type DomEditSelection,
15
+ resolveDomEditCapabilities,
16
+ resolveDomEditSelection,
17
+ } from "./domEditing";
18
+
19
+ function createDocument(markup: string): Document {
20
+ const window = new Window();
21
+ Object.assign(window, { SyntaxError });
22
+ window.document.body.innerHTML = markup;
23
+ return window.document;
24
+ }
25
+
26
+ describe("resolveDomEditCapabilities", () => {
27
+ it("marks absolute px-positioned layers as movable and resizable", () => {
28
+ expect(
29
+ resolveDomEditCapabilities({
30
+ selector: "#card",
31
+ inlineStyles: {
32
+ left: "120px",
33
+ top: "80px",
34
+ width: "240px",
35
+ height: "140px",
36
+ },
37
+ computedStyles: {
38
+ position: "absolute",
39
+ left: "120px",
40
+ top: "80px",
41
+ width: "240px",
42
+ height: "140px",
43
+ transform: "none",
44
+ },
45
+ isCompositionHost: false,
46
+ isMasterView: false,
47
+ }),
48
+ ).toEqual({
49
+ canSelect: true,
50
+ canEditStyles: true,
51
+ canMove: true,
52
+ canResize: true,
53
+ canApplyManualOffset: true,
54
+ canApplyManualSize: true,
55
+ canApplyManualRotation: true,
56
+ reasonIfDisabled: undefined,
57
+ });
58
+ });
59
+
60
+ it("rejects flex/grid children for move and resize", () => {
61
+ expect(
62
+ resolveDomEditCapabilities({
63
+ selector: "#chip",
64
+ tagName: "div",
65
+ inlineStyles: {},
66
+ computedStyles: {
67
+ position: "static",
68
+ display: "block",
69
+ left: "auto",
70
+ top: "auto",
71
+ width: "180px",
72
+ height: "64px",
73
+ transform: "none",
74
+ },
75
+ isCompositionHost: false,
76
+ isMasterView: false,
77
+ }),
78
+ ).toMatchObject({
79
+ canSelect: true,
80
+ canEditStyles: true,
81
+ canMove: false,
82
+ canResize: false,
83
+ canApplyManualOffset: true,
84
+ canApplyManualSize: true,
85
+ canApplyManualRotation: true,
86
+ reasonIfDisabled: undefined,
87
+ });
88
+ });
89
+
90
+ it("rejects transform-driven geometry", () => {
91
+ expect(
92
+ resolveDomEditCapabilities({
93
+ selector: "#card",
94
+ inlineStyles: {
95
+ left: "120px",
96
+ top: "80px",
97
+ width: "240px",
98
+ height: "140px",
99
+ },
100
+ computedStyles: {
101
+ position: "absolute",
102
+ left: "120px",
103
+ top: "80px",
104
+ width: "240px",
105
+ height: "140px",
106
+ transform: "matrix(1, 0, 0, 1, 12, 0)",
107
+ },
108
+ isCompositionHost: false,
109
+ isMasterView: false,
110
+ }),
111
+ ).toMatchObject({
112
+ canMove: false,
113
+ canResize: false,
114
+ canApplyManualOffset: true,
115
+ canApplyManualSize: true,
116
+ canApplyManualRotation: true,
117
+ });
118
+ });
119
+
120
+ it("treats identity transforms left behind by animation libraries as movable", () => {
121
+ expect(
122
+ resolveDomEditCapabilities({
123
+ selector: "#card",
124
+ inlineStyles: {
125
+ left: "120px",
126
+ top: "80px",
127
+ width: "240px",
128
+ height: "140px",
129
+ },
130
+ computedStyles: {
131
+ position: "absolute",
132
+ left: "120px",
133
+ top: "80px",
134
+ width: "240px",
135
+ height: "140px",
136
+ transform: "matrix(1, 0, 0, 1, 0, 0)",
137
+ },
138
+ isCompositionHost: false,
139
+ isMasterView: false,
140
+ }),
141
+ ).toMatchObject({
142
+ canMove: true,
143
+ canResize: true,
144
+ canApplyManualOffset: true,
145
+ });
146
+ });
147
+
148
+ it("treats identity matrix3d transforms as movable", () => {
149
+ expect(
150
+ resolveDomEditCapabilities({
151
+ selector: "#card",
152
+ inlineStyles: {
153
+ left: "120px",
154
+ top: "80px",
155
+ width: "240px",
156
+ height: "140px",
157
+ },
158
+ computedStyles: {
159
+ position: "absolute",
160
+ left: "120px",
161
+ top: "80px",
162
+ width: "240px",
163
+ height: "140px",
164
+ transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
165
+ },
166
+ isCompositionHost: false,
167
+ isMasterView: false,
168
+ }),
169
+ ).toMatchObject({
170
+ canMove: true,
171
+ canResize: true,
172
+ });
173
+ });
174
+
175
+ it("allows imported absolute media to resize from computed px geometry", () => {
176
+ expect(
177
+ resolveDomEditCapabilities({
178
+ selector: "#photo",
179
+ inlineStyles: {
180
+ inset: "0",
181
+ width: "100%",
182
+ height: "100%",
183
+ },
184
+ computedStyles: {
185
+ position: "absolute",
186
+ left: "0px",
187
+ top: "0px",
188
+ width: "330px",
189
+ height: "228px",
190
+ transform: "none",
191
+ },
192
+ isCompositionHost: false,
193
+ isMasterView: false,
194
+ }),
195
+ ).toMatchObject({
196
+ canMove: true,
197
+ canResize: true,
198
+ });
199
+ });
200
+ });
201
+
202
+ describe("resolveDomEditSelection", () => {
203
+ it("keeps composition host transforms disabled in master view", () => {
204
+ expect(
205
+ resolveDomEditCapabilities({
206
+ selector: "#detail-host",
207
+ inlineStyles: {
208
+ left: "80px",
209
+ top: "60px",
210
+ width: "320px",
211
+ height: "220px",
212
+ },
213
+ computedStyles: {
214
+ position: "absolute",
215
+ left: "80px",
216
+ top: "60px",
217
+ width: "320px",
218
+ height: "220px",
219
+ transform: "none",
220
+ },
221
+ isCompositionHost: true,
222
+ isMasterView: true,
223
+ }),
224
+ ).toEqual({
225
+ canSelect: true,
226
+ canEditStyles: false,
227
+ canMove: true,
228
+ canResize: true,
229
+ canApplyManualOffset: false,
230
+ canApplyManualSize: false,
231
+ canApplyManualRotation: false,
232
+ reasonIfDisabled: "Select an internal layer to transform it.",
233
+ });
234
+ });
235
+
236
+ it("resolves child clicks inside a composition host to the child in master view", () => {
237
+ const document = createDocument(`
238
+ <div data-composition-id="main">
239
+ <div
240
+ id="detail-host"
241
+ class="clip"
242
+ data-composition-id="detail-card"
243
+ data-composition-file="compositions/detail-card.html"
244
+ >
245
+ <span id="inner-copy">Nested scene</span>
246
+ </div>
247
+ </div>
248
+ `);
249
+
250
+ const child = document.getElementById("inner-copy") as HTMLElement;
251
+ const selection = resolveDomEditSelection(child, {
252
+ activeCompositionPath: null,
253
+ isMasterView: true,
254
+ });
255
+
256
+ expect(selection?.id).toBe("inner-copy");
257
+ expect(selection?.sourceFile).toBe("compositions/detail-card.html");
258
+ expect(selection?.isCompositionHost).toBe(false);
259
+ expect(selection?.capabilities.canApplyManualOffset).toBe(true);
260
+ expect(selection?.capabilities.canEditStyles).toBe(true);
261
+ });
262
+
263
+ it("does not prefer a scene host clip ancestor when selecting inside it", () => {
264
+ const document = createDocument(`
265
+ <div data-composition-id="main">
266
+ <div
267
+ id="detail-host"
268
+ class="clip"
269
+ data-composition-id="detail-card"
270
+ data-composition-file="compositions/detail-card.html"
271
+ >
272
+ <span id="inner-copy">Nested scene</span>
273
+ </div>
274
+ </div>
275
+ `);
276
+
277
+ const child = document.getElementById("inner-copy") as HTMLElement;
278
+ const selection = resolveDomEditSelection(child, {
279
+ activeCompositionPath: null,
280
+ isMasterView: true,
281
+ preferClipAncestor: true,
282
+ });
283
+
284
+ expect(selection?.id).toBe("inner-copy");
285
+ expect(selection?.sourceFile).toBe("compositions/detail-card.html");
286
+ expect(selection?.isCompositionHost).toBe(false);
287
+ });
288
+
289
+ it("still prefers an internal clip ancestor inside a scene", () => {
290
+ const document = createDocument(`
291
+ <div data-composition-id="main">
292
+ <div
293
+ id="detail-host"
294
+ class="clip"
295
+ data-composition-id="detail-card"
296
+ data-composition-file="compositions/detail-card.html"
297
+ >
298
+ <section id="nested-card" class="clip">
299
+ <span id="inner-copy">Nested scene</span>
300
+ </section>
301
+ </div>
302
+ </div>
303
+ `);
304
+
305
+ const child = document.getElementById("inner-copy") as HTMLElement;
306
+ const selection = resolveDomEditSelection(child, {
307
+ activeCompositionPath: null,
308
+ isMasterView: true,
309
+ preferClipAncestor: true,
310
+ });
311
+
312
+ expect(selection?.id).toBe("nested-card");
313
+ expect(selection?.sourceFile).toBe("compositions/detail-card.html");
314
+ expect(selection?.isCompositionHost).toBe(false);
315
+ });
316
+
317
+ it("scopes class selector indexing to the same source file", () => {
318
+ const document = createDocument(`
319
+ <div data-composition-id="main">
320
+ <div class="chip">Root chip</div>
321
+ <div data-composition-id="nested" data-composition-file="compositions/nested.html">
322
+ <div class="chip">Nested chip</div>
323
+ </div>
324
+ </div>
325
+ `);
326
+
327
+ const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement;
328
+ const selection = resolveDomEditSelection(rootChip, {
329
+ activeCompositionPath: null,
330
+ isMasterView: true,
331
+ });
332
+
333
+ expect(selection?.sourceFile).toBe("index.html");
334
+ expect(selection?.selector).toBe(".chip");
335
+ expect(selection?.selectorIndex).toBe(0);
336
+ expect(findElementForSelection(document, selection!, null)).toBe(rootChip);
337
+ });
338
+
339
+ it("resolves nested duplicate ids from master view without treating root as the nested source", () => {
340
+ const document = createDocument(`
341
+ <div data-composition-id="main">
342
+ <div id="card">Root card</div>
343
+ <div data-composition-id="nested" data-composition-file="scenes/nested.html">
344
+ <div id="card">Nested card</div>
345
+ </div>
346
+ </div>
347
+ `);
348
+
349
+ const nestedCard = document.querySelector(
350
+ '[data-composition-file="scenes/nested.html"] #card',
351
+ ) as HTMLElement;
352
+ const selection = resolveDomEditSelection(nestedCard, {
353
+ activeCompositionPath: null,
354
+ isMasterView: true,
355
+ });
356
+
357
+ expect(selection?.sourceFile).toBe("scenes/nested.html");
358
+ expect(findElementForSelection(document, selection!, null)).toBe(nestedCard);
359
+ });
360
+
361
+ it("does not throw when a generated timeline identity is passed as a selector", () => {
362
+ const document = createDocument(`
363
+ <div data-composition-id="main">
364
+ <div class="topline">Logo</div>
365
+ </div>
366
+ `);
367
+
368
+ expect(() =>
369
+ findElementForSelection(
370
+ document,
371
+ {
372
+ id: "index.html:Hyperframes Logo Light:0",
373
+ selector:
374
+ '[data-composition-id="index.html:Hyperframes Logo Light:0"],#index.html:Hyperframes Logo Light:0',
375
+ sourceFile: "index.html",
376
+ },
377
+ null,
378
+ ),
379
+ ).not.toThrow();
380
+ expect(
381
+ findElementForSelection(
382
+ document,
383
+ {
384
+ id: "index.html:Hyperframes Logo Light:0",
385
+ selector:
386
+ '[data-composition-id="index.html:Hyperframes Logo Light:0"],#index.html:Hyperframes Logo Light:0',
387
+ sourceFile: "index.html",
388
+ },
389
+ null,
390
+ ),
391
+ ).toBeNull();
392
+ });
393
+
394
+ it("escapes ids and composition ids when creating stable selectors", () => {
395
+ const document = createDocument(`
396
+ <div data-composition-id="main">
397
+ <div id="logo:light">Logo</div>
398
+ <div data-composition-id="scene:one">Scene</div>
399
+ </div>
400
+ `);
401
+ const logo = document.getElementById("logo:light") as HTMLElement;
402
+ const scene = Array.from(document.querySelectorAll("[data-composition-id]")).find(
403
+ (element) => element.getAttribute("data-composition-id") === "scene:one",
404
+ ) as HTMLElement;
405
+
406
+ const logoSelection = resolveDomEditSelection(logo, {
407
+ activeCompositionPath: null,
408
+ isMasterView: true,
409
+ });
410
+ const sceneSelection = resolveDomEditSelection(scene, {
411
+ activeCompositionPath: null,
412
+ isMasterView: true,
413
+ });
414
+
415
+ expect(logoSelection?.selector).not.toBe("#logo:light");
416
+ expect(findElementForSelection(document, logoSelection!, null)).toBe(logo);
417
+ expect(sceneSelection?.selector).toBe('[data-composition-id="scene:one"]');
418
+ expect(findElementForSelection(document, sceneSelection!, null)).toBe(scene);
419
+ });
420
+
421
+ it("prefers the nearest clip ancestor on single-click style selection", () => {
422
+ const document = createDocument(`
423
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
424
+ <p id="copy">Hello</p>
425
+ </section>
426
+ `);
427
+
428
+ const child = document.getElementById("copy") as HTMLElement;
429
+ const selection = resolveDomEditSelection(child, {
430
+ activeCompositionPath: null,
431
+ isMasterView: false,
432
+ preferClipAncestor: true,
433
+ });
434
+
435
+ expect(selection?.id).toBe("card");
436
+ expect(selection?.selector).toBe("#card");
437
+ });
438
+
439
+ it("can resolve the exact child when clip-ancestor preference is disabled", () => {
440
+ const document = createDocument(`
441
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
442
+ <p id="copy">Hello</p>
443
+ </section>
444
+ `);
445
+
446
+ const child = document.getElementById("copy") as HTMLElement;
447
+ const selection = resolveDomEditSelection(child, {
448
+ activeCompositionPath: null,
449
+ isMasterView: false,
450
+ preferClipAncestor: false,
451
+ });
452
+
453
+ expect(selection?.id).toBe("copy");
454
+ expect(selection?.selector).toBe("#copy");
455
+ });
456
+
457
+ it("collects simple child text blocks as separate editable fields", () => {
458
+ const document = createDocument(`
459
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
460
+ <strong>Headline</strong>
461
+ <span>Supporting copy</span>
462
+ </section>
463
+ `);
464
+
465
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
466
+ activeCompositionPath: null,
467
+ isMasterView: false,
468
+ });
469
+
470
+ expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]);
471
+ expect(selection?.textFields.map((field) => field.value)).toEqual([
472
+ "Headline",
473
+ "Supporting copy",
474
+ ]);
475
+ });
476
+
477
+ it("preserves user-entered text spacing in editable text fields", () => {
478
+ const document = createDocument(`
479
+ <section id="card" class="clip" style="position: absolute;">
480
+ <strong>Headline with trailing space </strong>
481
+ </section>
482
+ `);
483
+
484
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
485
+ activeCompositionPath: null,
486
+ isMasterView: false,
487
+ });
488
+
489
+ expect(selection?.textFields[0]?.value).toBe("Headline with trailing space ");
490
+ });
491
+
492
+ it("keeps an emptied text layer editable so users can type into it again", () => {
493
+ const document = createDocument(`
494
+ <div id="card" class="clip" style="position: absolute;"></div>
495
+ `);
496
+
497
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
498
+ activeCompositionPath: null,
499
+ isMasterView: false,
500
+ });
501
+
502
+ expect(selection?.textFields).toMatchObject([
503
+ {
504
+ key: "self:0:div",
505
+ label: "Content",
506
+ value: "",
507
+ source: "self",
508
+ },
509
+ ]);
510
+ expect(selection ? isTextEditableSelection(selection) : false).toBe(true);
511
+ });
512
+
513
+ it("keeps emptied child text layers editable after their content is cleared", () => {
514
+ const document = createDocument(`
515
+ <div id="card" class="clip" style="position: absolute;">
516
+ <strong></strong>
517
+ <span></span>
518
+ </div>
519
+ `);
520
+
521
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
522
+ activeCompositionPath: null,
523
+ isMasterView: false,
524
+ });
525
+
526
+ expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
527
+ expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]);
528
+ });
529
+
530
+ it("explains anonymous child elements that resolve to an editable parent", () => {
531
+ const document = createDocument(`
532
+ <div data-composition-id="main">
533
+ <div id="card">
534
+ <strong>Headline</strong>
535
+ </div>
536
+ </div>
537
+ `);
538
+
539
+ const child = document.querySelector("strong") as HTMLElement;
540
+ const selection = resolveDomEditSelection(child, {
541
+ activeCompositionPath: null,
542
+ isMasterView: false,
543
+ preferClipAncestor: false,
544
+ });
545
+
546
+ expect(selection?.id).toBe("card");
547
+ expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card");
548
+ });
549
+
550
+ it("does not mark an element as non-editable when Studio can edit it directly", () => {
551
+ const document = createDocument(`
552
+ <div data-composition-id="main">
553
+ <div id="card">Editable</div>
554
+ </div>
555
+ `);
556
+
557
+ const element = document.getElementById("card") as HTMLElement;
558
+ const selection = resolveDomEditSelection(element, {
559
+ activeCompositionPath: null,
560
+ isMasterView: false,
561
+ });
562
+
563
+ expect(getDomEditNonEditableReason(element, selection)).toBeNull();
564
+ });
565
+
566
+ it("keeps duplicate class targets distinct for history keys", () => {
567
+ const first = getDomEditTargetKey({
568
+ sourceFile: "index.html",
569
+ selector: ".card",
570
+ selectorIndex: 0,
571
+ });
572
+ const second = getDomEditTargetKey({
573
+ sourceFile: "index.html",
574
+ selector: ".card",
575
+ selectorIndex: 1,
576
+ });
577
+
578
+ expect(first).not.toBe(second);
579
+ });
580
+
581
+ it("resolves generated timeline ids without throwing", () => {
582
+ const document = createDocument(`
583
+ <div data-composition-id="hook">
584
+ <div class="topline">Topline</div>
585
+ </div>
586
+ `);
587
+
588
+ expect(
589
+ findElementForTimelineElement(
590
+ document,
591
+ { id: "index.html:Hyperframes Logo Light:0", sourceFile: "index.html" },
592
+ {
593
+ activeCompositionPath: null,
594
+ isMasterView: true,
595
+ },
596
+ ),
597
+ ).toBeNull();
598
+ });
599
+
600
+ it("falls back to the root composition for standalone manifest clips without DOM targets", () => {
601
+ const document = createDocument(`
602
+ <div data-composition-id="hook">
603
+ <div class="topline">Topline</div>
604
+ <div class="scene-shell">Scene</div>
605
+ </div>
606
+ `);
607
+ const root = document.querySelector("[data-composition-id]") as HTMLElement;
608
+
609
+ expect(
610
+ findElementForTimelineElement(
611
+ document,
612
+ { id: "compositions/hook.html:Hyperframes Logo Light:0" },
613
+ {
614
+ activeCompositionPath: "compositions/hook.html",
615
+ isMasterView: false,
616
+ },
617
+ ),
618
+ ).toBe(root);
619
+ });
620
+
621
+ it("resolves the standalone composition root when the fallback clip carries source metadata", () => {
622
+ const document = createDocument(`
623
+ <div data-composition-id="manual">
624
+ <div class="scene-shell">Scene</div>
625
+ </div>
626
+ `);
627
+ const root = document.querySelector("[data-composition-id]") as HTMLElement;
628
+
629
+ expect(
630
+ findElementForTimelineElement(
631
+ document,
632
+ {
633
+ id: "manual",
634
+ compositionSrc: "compositions/manual.html",
635
+ selector: '[data-composition-id="manual"]',
636
+ sourceFile: "compositions/manual.html",
637
+ },
638
+ {
639
+ activeCompositionPath: "compositions/manual.html",
640
+ isMasterView: false,
641
+ },
642
+ ),
643
+ ).toBe(root);
644
+ });
645
+
646
+ it("does not fall back to the root composition when an explicit timeline selector misses", () => {
647
+ const document = createDocument(`
648
+ <div data-composition-id="hook">
649
+ <div class="topline">Topline</div>
650
+ </div>
651
+ `);
652
+
653
+ expect(
654
+ findElementForTimelineElement(
655
+ document,
656
+ { selector: ".missing", sourceFile: "compositions/hook.html" },
657
+ {
658
+ activeCompositionPath: "compositions/hook.html",
659
+ isMasterView: false,
660
+ },
661
+ ),
662
+ ).toBeNull();
663
+ });
664
+ });
665
+
666
+ describe("patch builders and prompt builder", () => {
667
+ it("builds style patch operations", () => {
668
+ expect(buildDomEditStylePatchOperation("background-color", "rgb(15, 23, 42)")).toEqual({
669
+ type: "inline-style",
670
+ property: "background-color",
671
+ value: "rgb(15, 23, 42)",
672
+ });
673
+ });
674
+
675
+ it("builds an agent prompt with source and selector context", () => {
676
+ const selection = {
677
+ element: {} as HTMLElement,
678
+ id: "editable-card",
679
+ selector: "#editable-card",
680
+ selectorIndex: undefined,
681
+ sourceFile: "index.html",
682
+ compositionPath: "index.html",
683
+ compositionSrc: undefined,
684
+ isCompositionHost: false,
685
+ label: "Drag me first",
686
+ tagName: "div",
687
+ boundingBox: { x: 108, y: 112, width: 380, height: 196 },
688
+ textContent: "Drag me first",
689
+ dataAttributes: {},
690
+ inlineStyles: {
691
+ left: "108px",
692
+ top: "112px",
693
+ width: "380px",
694
+ height: "196px",
695
+ },
696
+ computedStyles: {
697
+ position: "absolute",
698
+ left: "108px",
699
+ top: "112px",
700
+ width: "380px",
701
+ height: "196px",
702
+ color: "rgb(248, 250, 252)",
703
+ },
704
+ textFields: [
705
+ {
706
+ key: "self:0:div",
707
+ label: "Content",
708
+ value: "Drag me first",
709
+ tagName: "div",
710
+ attributes: [],
711
+ inlineStyles: {},
712
+ computedStyles: {},
713
+ source: "self",
714
+ },
715
+ ],
716
+ capabilities: {
717
+ canSelect: true,
718
+ canEditStyles: true,
719
+ canMove: true,
720
+ canResize: true,
721
+ canApplyManualOffset: true,
722
+ canApplyManualSize: true,
723
+ canApplyManualRotation: true,
724
+ },
725
+ } satisfies DomEditSelection;
726
+
727
+ const prompt = buildElementAgentPrompt({
728
+ selection,
729
+ currentTime: 1.25,
730
+ tagSnippet: `<div id="editable-card" style="position:absolute; left: 108px; top: 112px; width: 380px; height: 196px; color: rgb(248, 250, 252)"`,
731
+ });
732
+
733
+ expect(prompt).toContain("## HyperFrames element edit request v1");
734
+ expect(prompt).toContain("Schema version: 1");
735
+ expect(prompt).toContain("Source file: index.html");
736
+ expect(prompt).toContain("Selector: #editable-card");
737
+ expect(prompt).toContain("Playback time:");
738
+ expect(prompt).toContain("Text fields:");
739
+ expect(prompt).toContain('key=self:0:div; tag=<div>; source=self; text="Drag me first"');
740
+ expect(prompt).toContain("Inline styles:");
741
+ expect(prompt).toContain("Computed styles (browser-resolved):");
742
+ expect(prompt).toContain("Target HTML:");
743
+ expect(prompt).toContain("Guardrails:");
744
+ expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
745
+ });
746
+
747
+ it("uses an absolute source path in copied agent prompts when provided", () => {
748
+ const selection = {
749
+ element: {} as HTMLElement,
750
+ id: "editable-card",
751
+ selector: "#editable-card",
752
+ selectorIndex: undefined,
753
+ sourceFile: "index.html",
754
+ compositionPath: "index.html",
755
+ compositionSrc: undefined,
756
+ isCompositionHost: false,
757
+ label: "Drag me first",
758
+ tagName: "div",
759
+ boundingBox: { x: 108, y: 112, width: 380, height: 196 },
760
+ textContent: "Drag me first",
761
+ dataAttributes: {},
762
+ inlineStyles: {},
763
+ computedStyles: {},
764
+ textFields: [],
765
+ capabilities: {
766
+ canSelect: true,
767
+ canEditStyles: true,
768
+ canMove: true,
769
+ canResize: true,
770
+ canApplyManualOffset: true,
771
+ canApplyManualSize: true,
772
+ canApplyManualRotation: true,
773
+ },
774
+ } satisfies DomEditSelection;
775
+
776
+ const prompt = buildElementAgentPrompt({
777
+ selection,
778
+ currentTime: 1.25,
779
+ sourceFilePath: "/tmp/hf-studio-project/index.html",
780
+ });
781
+
782
+ expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
783
+ expect(prompt).not.toContain("Source file: index.html");
784
+ });
785
+
786
+ it("serializes child text fields back into HTML", () => {
787
+ expect(
788
+ serializeDomEditTextFields([
789
+ {
790
+ key: "child:0:strong",
791
+ label: "Text 1",
792
+ value: "Headline <1>",
793
+ tagName: "strong",
794
+ attributes: [],
795
+ inlineStyles: {
796
+ "font-size": "22px",
797
+ },
798
+ computedStyles: {},
799
+ source: "child",
800
+ },
801
+ {
802
+ key: "child:1:span",
803
+ label: "Text 2",
804
+ value: "Details & more",
805
+ tagName: "span",
806
+ attributes: [],
807
+ inlineStyles: {},
808
+ computedStyles: {},
809
+ source: "child",
810
+ },
811
+ ]),
812
+ ).toBe(
813
+ '<strong data-hf-text-key="child:0:strong" style="font-size: 22px">Headline &lt;1&gt;</strong><span data-hf-text-key="child:1:span">Details &amp; more</span>',
814
+ );
815
+ });
816
+
817
+ it("collects nested timeline layers with stable keys and child counts", () => {
818
+ const doc = createDocument(`
819
+ <div data-composition-id="hook" data-composition-file="compositions/hook.html">
820
+ <section class="scene-shell">
821
+ <div class="topline">
822
+ <span class="brand">HyperFrames</span>
823
+ <span class="badge">Alpha</span>
824
+ </div>
825
+ </section>
826
+ </div>
827
+ `);
828
+ const root = doc.querySelector(".scene-shell") as HTMLElement;
829
+ const layers = collectDomEditLayerItems(root, {
830
+ activeCompositionPath: "compositions/hook.html",
831
+ isMasterView: false,
832
+ });
833
+
834
+ expect(
835
+ countDomEditChildLayers(root, {
836
+ activeCompositionPath: "compositions/hook.html",
837
+ isMasterView: false,
838
+ }),
839
+ ).toBe(3);
840
+ expect(layers.map((layer) => layer.label)).toEqual([
841
+ "Scene Shell",
842
+ "Topline",
843
+ "Brand",
844
+ "Badge",
845
+ ]);
846
+ expect(layers[0]?.childCount).toBe(1);
847
+ expect(layers.find((layer) => layer.label === "Brand")?.key).toBe(
848
+ "compositions/hook.html:.brand:0",
849
+ );
850
+ });
851
+
852
+ it("collects timeline layers with SVG descendants without crashing", () => {
853
+ const doc = createDocument(`
854
+ <div data-composition-id="hook" data-composition-file="compositions/hook.html">
855
+ <section class="scene-shell">
856
+ <svg class="brand-mark" viewBox="0 0 24 24">
857
+ <path class="brand-path" d="M0 0h24v24H0z"></path>
858
+ </svg>
859
+ <div class="title">HyperFrames</div>
860
+ </section>
861
+ </div>
862
+ `);
863
+ const root = doc.querySelector(".scene-shell") as HTMLElement;
864
+
865
+ expect(() =>
866
+ collectDomEditLayerItems(root, {
867
+ activeCompositionPath: "compositions/hook.html",
868
+ isMasterView: false,
869
+ }),
870
+ ).not.toThrow();
871
+ });
872
+ });