@hyperframes/studio 0.4.24 → 0.5.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 (36) hide show
  1. package/dist/assets/index-BExHzIDS.js +105 -0
  2. package/dist/assets/index-BpcIkyVP.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +1327 -76
  6. package/src/components/editor/DomEditOverlay.tsx +410 -0
  7. package/src/components/editor/PropertyPanel.tsx +2462 -206
  8. package/src/components/editor/colorValue.test.ts +82 -0
  9. package/src/components/editor/colorValue.ts +175 -0
  10. package/src/components/editor/domEditing.test.ts +427 -0
  11. package/src/components/editor/domEditing.ts +733 -0
  12. package/src/components/editor/floatingPanel.test.ts +34 -0
  13. package/src/components/editor/floatingPanel.ts +54 -0
  14. package/src/components/editor/fontAssets.ts +32 -0
  15. package/src/components/editor/fontCatalog.ts +126 -0
  16. package/src/components/editor/gradientValue.test.ts +89 -0
  17. package/src/components/editor/gradientValue.ts +445 -0
  18. package/src/components/nle/NLELayout.tsx +9 -4
  19. package/src/components/nle/NLEPreview.tsx +50 -5
  20. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  21. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  22. package/src/components/sidebar/LeftSidebar.tsx +38 -33
  23. package/src/player/components/Player.tsx +18 -70
  24. package/src/player/components/Timeline.test.ts +0 -1
  25. package/src/player/components/Timeline.tsx +0 -3
  26. package/src/player/components/TimelineClip.tsx +20 -7
  27. package/src/player/components/timelineEditing.test.ts +0 -2
  28. package/src/player/components/timelineEditing.ts +0 -2
  29. package/src/player/hooks/useTimelinePlayer.ts +0 -17
  30. package/src/utils/mediaTypes.ts +1 -1
  31. package/src/utils/sourcePatcher.test.ts +128 -1
  32. package/src/utils/sourcePatcher.ts +130 -18
  33. package/src/utils/timelineAssetDrop.test.ts +31 -11
  34. package/src/utils/timelineAssetDrop.ts +22 -2
  35. package/dist/assets/index-CAscydDF.js +0 -115
  36. package/dist/assets/index-dpgHnQGg.css +0 -1
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ formatCssColor,
4
+ hsvToRgb,
5
+ mergeColorWithExistingAlpha,
6
+ parseCssColor,
7
+ rgbToHsv,
8
+ toColorPickerValue,
9
+ toHexColor,
10
+ } from "./colorValue";
11
+
12
+ describe("parseCssColor", () => {
13
+ it("parses rgb values", () => {
14
+ expect(parseCssColor("rgb(12, 34, 56)")).toEqual({
15
+ red: 12,
16
+ green: 34,
17
+ blue: 56,
18
+ alpha: 1,
19
+ });
20
+ });
21
+
22
+ it("parses rgba values", () => {
23
+ expect(parseCssColor("rgba(15, 23, 42, 0.64)")).toEqual({
24
+ red: 15,
25
+ green: 23,
26
+ blue: 42,
27
+ alpha: 0.64,
28
+ });
29
+ });
30
+
31
+ it("parses transparent", () => {
32
+ expect(parseCssColor("transparent")).toEqual({
33
+ red: 0,
34
+ green: 0,
35
+ blue: 0,
36
+ alpha: 0,
37
+ });
38
+ });
39
+ });
40
+
41
+ describe("toColorPickerValue", () => {
42
+ it("converts css color to hex", () => {
43
+ expect(toColorPickerValue("rgba(15, 23, 42, 0.64)")).toBe("#0f172a");
44
+ });
45
+ });
46
+
47
+ describe("toHexColor", () => {
48
+ it("formats rgb channels as hex", () => {
49
+ expect(toHexColor({ red: 15, green: 23, blue: 42 })).toBe("#0f172a");
50
+ });
51
+ });
52
+
53
+ describe("formatCssColor", () => {
54
+ it("formats opaque colors as rgb", () => {
55
+ expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 1 })).toBe("rgb(18, 52, 86)");
56
+ });
57
+
58
+ it("formats translucent colors as rgba", () => {
59
+ expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 0.64 })).toBe(
60
+ "rgba(18, 52, 86, 0.64)",
61
+ );
62
+ });
63
+ });
64
+
65
+ describe("rgb hsv conversion", () => {
66
+ it("round-trips primary color values", () => {
67
+ const hsv = rgbToHsv({ red: 47, green: 198, blue: 127 });
68
+ expect(hsvToRgb(hsv)).toEqual({ red: 47, green: 198, blue: 127 });
69
+ });
70
+ });
71
+
72
+ describe("mergeColorWithExistingAlpha", () => {
73
+ it("preserves alpha when the previous color was translucent", () => {
74
+ expect(mergeColorWithExistingAlpha("#123456", "rgba(15, 23, 42, 0.64)")).toBe(
75
+ "rgba(18, 52, 86, 0.64)",
76
+ );
77
+ });
78
+
79
+ it("returns rgb when the previous color was opaque", () => {
80
+ expect(mergeColorWithExistingAlpha("#123456", "rgb(15, 23, 42)")).toBe("rgb(18, 52, 86)");
81
+ });
82
+ });
@@ -0,0 +1,175 @@
1
+ export interface ParsedColor {
2
+ red: number;
3
+ green: number;
4
+ blue: number;
5
+ alpha: number;
6
+ }
7
+
8
+ export interface HsvColor {
9
+ hue: number;
10
+ saturation: number;
11
+ value: number;
12
+ }
13
+
14
+ function clampChannel(value: number): number {
15
+ return Math.max(0, Math.min(255, Math.round(value)));
16
+ }
17
+
18
+ function clampAlpha(value: number): number {
19
+ return Math.max(0, Math.min(1, value));
20
+ }
21
+
22
+ function toHex(value: number): string {
23
+ return clampChannel(value).toString(16).padStart(2, "0");
24
+ }
25
+
26
+ function formatAlpha(value: number): string {
27
+ return `${Math.round(clampAlpha(value) * 100) / 100}`;
28
+ }
29
+
30
+ export function parseCssColor(value: string): ParsedColor | null {
31
+ const trimmed = value.trim().toLowerCase();
32
+ if (!trimmed) return null;
33
+ if (trimmed === "transparent") {
34
+ return { red: 0, green: 0, blue: 0, alpha: 0 };
35
+ }
36
+
37
+ const shortHex = trimmed.match(/^#([0-9a-f]{3})$/i);
38
+ if (shortHex) {
39
+ const [r, g, b] = shortHex[1].split("");
40
+ return {
41
+ red: Number.parseInt(r + r, 16),
42
+ green: Number.parseInt(g + g, 16),
43
+ blue: Number.parseInt(b + b, 16),
44
+ alpha: 1,
45
+ };
46
+ }
47
+
48
+ const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
49
+ if (hex) {
50
+ return {
51
+ red: Number.parseInt(hex[1].slice(0, 2), 16),
52
+ green: Number.parseInt(hex[1].slice(2, 4), 16),
53
+ blue: Number.parseInt(hex[1].slice(4, 6), 16),
54
+ alpha: 1,
55
+ };
56
+ }
57
+
58
+ const rgba = trimmed.match(
59
+ /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
60
+ );
61
+ if (rgba) {
62
+ return {
63
+ red: clampChannel(Number.parseFloat(rgba[1])),
64
+ green: clampChannel(Number.parseFloat(rgba[2])),
65
+ blue: clampChannel(Number.parseFloat(rgba[3])),
66
+ alpha: clampAlpha(rgba[4] != null ? Number.parseFloat(rgba[4]) : 1),
67
+ };
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ export function toColorPickerValue(value: string): string {
74
+ const parsed = parseCssColor(value);
75
+ if (!parsed) return "#000000";
76
+ return toHexColor(parsed);
77
+ }
78
+
79
+ export function toHexColor(color: Pick<ParsedColor, "red" | "green" | "blue">): string {
80
+ return `#${toHex(color.red)}${toHex(color.green)}${toHex(color.blue)}`;
81
+ }
82
+
83
+ export function formatCssColor(color: ParsedColor): string {
84
+ const red = clampChannel(color.red);
85
+ const green = clampChannel(color.green);
86
+ const blue = clampChannel(color.blue);
87
+ const alpha = clampAlpha(color.alpha);
88
+
89
+ if (alpha >= 1) {
90
+ return `rgb(${red}, ${green}, ${blue})`;
91
+ }
92
+
93
+ return `rgba(${red}, ${green}, ${blue}, ${formatAlpha(alpha)})`;
94
+ }
95
+
96
+ export function rgbToHsv(color: Pick<ParsedColor, "red" | "green" | "blue">): HsvColor {
97
+ const red = clampChannel(color.red) / 255;
98
+ const green = clampChannel(color.green) / 255;
99
+ const blue = clampChannel(color.blue) / 255;
100
+ const max = Math.max(red, green, blue);
101
+ const min = Math.min(red, green, blue);
102
+ const delta = max - min;
103
+
104
+ let hue = 0;
105
+ if (delta !== 0) {
106
+ if (max === red) {
107
+ hue = 60 * (((green - blue) / delta) % 6);
108
+ } else if (max === green) {
109
+ hue = 60 * ((blue - red) / delta + 2);
110
+ } else {
111
+ hue = 60 * ((red - green) / delta + 4);
112
+ }
113
+ }
114
+
115
+ if (hue < 0) hue += 360;
116
+
117
+ return {
118
+ hue,
119
+ saturation: max === 0 ? 0 : delta / max,
120
+ value: max,
121
+ };
122
+ }
123
+
124
+ export function hsvToRgb(color: HsvColor): Pick<ParsedColor, "red" | "green" | "blue"> {
125
+ const hue = (((color.hue % 360) + 360) % 360) / 60;
126
+ const saturation = Math.max(0, Math.min(1, color.saturation));
127
+ const value = Math.max(0, Math.min(1, color.value));
128
+ const chroma = value * saturation;
129
+ const x = chroma * (1 - Math.abs((hue % 2) - 1));
130
+ const m = value - chroma;
131
+
132
+ let red = 0;
133
+ let green = 0;
134
+ let blue = 0;
135
+
136
+ if (hue >= 0 && hue < 1) {
137
+ red = chroma;
138
+ green = x;
139
+ } else if (hue >= 1 && hue < 2) {
140
+ red = x;
141
+ green = chroma;
142
+ } else if (hue >= 2 && hue < 3) {
143
+ green = chroma;
144
+ blue = x;
145
+ } else if (hue >= 3 && hue < 4) {
146
+ green = x;
147
+ blue = chroma;
148
+ } else if (hue >= 4 && hue < 5) {
149
+ red = x;
150
+ blue = chroma;
151
+ } else {
152
+ red = chroma;
153
+ blue = x;
154
+ }
155
+
156
+ return {
157
+ red: clampChannel((red + m) * 255),
158
+ green: clampChannel((green + m) * 255),
159
+ blue: clampChannel((blue + m) * 255),
160
+ };
161
+ }
162
+
163
+ export function mergeColorWithExistingAlpha(nextHex: string, previousValue: string): string {
164
+ const hex = nextHex.trim();
165
+ const match = hex.match(/^#([0-9a-f]{6})$/i);
166
+ if (!match) return previousValue;
167
+
168
+ const previous = parseCssColor(previousValue);
169
+ const red = Number.parseInt(match[1].slice(0, 2), 16);
170
+ const green = Number.parseInt(match[1].slice(2, 4), 16);
171
+ const blue = Number.parseInt(match[1].slice(4, 6), 16);
172
+ const alpha = previous?.alpha ?? 1;
173
+
174
+ return formatCssColor({ red, green, blue, alpha });
175
+ }
@@ -0,0 +1,427 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Window } from "happy-dom";
3
+ import {
4
+ buildDomEditMovePatchOperations,
5
+ buildDomEditResizePatchOperations,
6
+ buildDomEditStylePatchOperation,
7
+ buildElementAgentPrompt,
8
+ findElementForSelection,
9
+ isTextEditableSelection,
10
+ serializeDomEditTextFields,
11
+ type DomEditSelection,
12
+ resolveDomEditCapabilities,
13
+ resolveDomEditSelection,
14
+ } from "./domEditing";
15
+
16
+ function createDocument(markup: string): Document {
17
+ const window = new Window();
18
+ Object.assign(window, { SyntaxError });
19
+ window.document.body.innerHTML = markup;
20
+ return window.document;
21
+ }
22
+
23
+ describe("resolveDomEditCapabilities", () => {
24
+ it("marks absolute px-positioned layers as movable and resizable", () => {
25
+ expect(
26
+ resolveDomEditCapabilities({
27
+ selector: "#card",
28
+ inlineStyles: {
29
+ left: "120px",
30
+ top: "80px",
31
+ width: "240px",
32
+ height: "140px",
33
+ },
34
+ computedStyles: {
35
+ position: "absolute",
36
+ left: "120px",
37
+ top: "80px",
38
+ width: "240px",
39
+ height: "140px",
40
+ transform: "none",
41
+ },
42
+ isCompositionHost: false,
43
+ isMasterView: false,
44
+ }),
45
+ ).toEqual({
46
+ canSelect: true,
47
+ canEditStyles: true,
48
+ canMove: true,
49
+ canResize: true,
50
+ canDetachFromLayout: false,
51
+ reasonIfDisabled: undefined,
52
+ });
53
+ });
54
+
55
+ it("rejects flex/grid children for move and resize", () => {
56
+ expect(
57
+ resolveDomEditCapabilities({
58
+ selector: "#chip",
59
+ tagName: "div",
60
+ inlineStyles: {},
61
+ computedStyles: {
62
+ position: "static",
63
+ display: "block",
64
+ left: "auto",
65
+ top: "auto",
66
+ width: "180px",
67
+ height: "64px",
68
+ transform: "none",
69
+ },
70
+ isCompositionHost: false,
71
+ isMasterView: false,
72
+ }),
73
+ ).toMatchObject({
74
+ canSelect: true,
75
+ canEditStyles: true,
76
+ canMove: false,
77
+ canResize: false,
78
+ canDetachFromLayout: true,
79
+ reasonIfDisabled: "This layer is controlled by layout.",
80
+ });
81
+ });
82
+
83
+ it("rejects transform-driven geometry", () => {
84
+ expect(
85
+ resolveDomEditCapabilities({
86
+ selector: "#card",
87
+ inlineStyles: {
88
+ left: "120px",
89
+ top: "80px",
90
+ width: "240px",
91
+ height: "140px",
92
+ },
93
+ computedStyles: {
94
+ position: "absolute",
95
+ left: "120px",
96
+ top: "80px",
97
+ width: "240px",
98
+ height: "140px",
99
+ transform: "matrix(1, 0, 0, 1, 12, 0)",
100
+ },
101
+ isCompositionHost: false,
102
+ isMasterView: false,
103
+ }),
104
+ ).toMatchObject({
105
+ canMove: false,
106
+ canResize: false,
107
+ canDetachFromLayout: false,
108
+ });
109
+ });
110
+
111
+ it("allows imported absolute media to resize from computed px geometry", () => {
112
+ expect(
113
+ resolveDomEditCapabilities({
114
+ selector: "#photo",
115
+ inlineStyles: {
116
+ inset: "0",
117
+ width: "100%",
118
+ height: "100%",
119
+ },
120
+ computedStyles: {
121
+ position: "absolute",
122
+ left: "0px",
123
+ top: "0px",
124
+ width: "330px",
125
+ height: "228px",
126
+ transform: "none",
127
+ },
128
+ isCompositionHost: false,
129
+ isMasterView: false,
130
+ }),
131
+ ).toMatchObject({
132
+ canMove: true,
133
+ canResize: true,
134
+ });
135
+ });
136
+ });
137
+
138
+ describe("resolveDomEditSelection", () => {
139
+ it("allows moving composition hosts in master view while keeping contents drill-down only", () => {
140
+ expect(
141
+ resolveDomEditCapabilities({
142
+ selector: "#detail-host",
143
+ inlineStyles: {
144
+ left: "80px",
145
+ top: "60px",
146
+ width: "320px",
147
+ height: "220px",
148
+ },
149
+ computedStyles: {
150
+ position: "absolute",
151
+ left: "80px",
152
+ top: "60px",
153
+ width: "320px",
154
+ height: "220px",
155
+ transform: "none",
156
+ },
157
+ isCompositionHost: true,
158
+ isMasterView: true,
159
+ }),
160
+ ).toEqual({
161
+ canSelect: true,
162
+ canEditStyles: false,
163
+ canMove: true,
164
+ canResize: true,
165
+ canDetachFromLayout: false,
166
+ reasonIfDisabled: undefined,
167
+ });
168
+ });
169
+
170
+ it("resolves child clicks inside a composition host back to the host in master view", () => {
171
+ const document = createDocument(`
172
+ <div data-composition-id="main">
173
+ <div id="detail-host" data-composition-src="compositions/detail-card.html">
174
+ <span id="inner-copy">Nested scene</span>
175
+ </div>
176
+ </div>
177
+ `);
178
+
179
+ const child = document.getElementById("inner-copy") as HTMLElement;
180
+ const selection = resolveDomEditSelection(child, {
181
+ activeCompositionPath: null,
182
+ isMasterView: true,
183
+ });
184
+
185
+ expect(selection?.id).toBe("detail-host");
186
+ expect(selection?.isCompositionHost).toBe(true);
187
+ expect(selection?.capabilities.canMove).toBe(false);
188
+ expect(selection?.capabilities.canEditStyles).toBe(false);
189
+ });
190
+
191
+ it("scopes class selector indexing to the same source file", () => {
192
+ const document = createDocument(`
193
+ <div data-composition-id="main">
194
+ <div class="chip">Root chip</div>
195
+ <div data-composition-id="nested" data-composition-file="compositions/nested.html">
196
+ <div class="chip">Nested chip</div>
197
+ </div>
198
+ </div>
199
+ `);
200
+
201
+ const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement;
202
+ const selection = resolveDomEditSelection(rootChip, {
203
+ activeCompositionPath: null,
204
+ isMasterView: true,
205
+ });
206
+
207
+ expect(selection?.sourceFile).toBe("index.html");
208
+ expect(selection?.selector).toBe(".chip");
209
+ expect(selection?.selectorIndex).toBe(0);
210
+ expect(findElementForSelection(document, selection!, null)).toBe(rootChip);
211
+ });
212
+
213
+ it("prefers the nearest clip ancestor on single-click style selection", () => {
214
+ const document = createDocument(`
215
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
216
+ <p id="copy">Hello</p>
217
+ </section>
218
+ `);
219
+
220
+ const child = document.getElementById("copy") as HTMLElement;
221
+ const selection = resolveDomEditSelection(child, {
222
+ activeCompositionPath: null,
223
+ isMasterView: false,
224
+ preferClipAncestor: true,
225
+ });
226
+
227
+ expect(selection?.id).toBe("card");
228
+ expect(selection?.selector).toBe("#card");
229
+ });
230
+
231
+ it("collects simple child text blocks as separate editable fields", () => {
232
+ const document = createDocument(`
233
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
234
+ <strong>Headline</strong>
235
+ <span>Supporting copy</span>
236
+ </section>
237
+ `);
238
+
239
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
240
+ activeCompositionPath: null,
241
+ isMasterView: false,
242
+ });
243
+
244
+ expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]);
245
+ expect(selection?.textFields.map((field) => field.value)).toEqual([
246
+ "Headline",
247
+ "Supporting copy",
248
+ ]);
249
+ });
250
+
251
+ it("preserves user-entered text spacing in editable text fields", () => {
252
+ const document = createDocument(`
253
+ <section id="card" class="clip" style="position: absolute;">
254
+ <strong>Headline with trailing space </strong>
255
+ </section>
256
+ `);
257
+
258
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
259
+ activeCompositionPath: null,
260
+ isMasterView: false,
261
+ });
262
+
263
+ expect(selection?.textFields[0]?.value).toBe("Headline with trailing space ");
264
+ });
265
+
266
+ it("keeps an emptied text layer editable so users can type into it again", () => {
267
+ const document = createDocument(`
268
+ <div id="card" class="clip" style="position: absolute;"></div>
269
+ `);
270
+
271
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
272
+ activeCompositionPath: null,
273
+ isMasterView: false,
274
+ });
275
+
276
+ expect(selection?.textFields).toMatchObject([
277
+ {
278
+ key: "self:0:div",
279
+ label: "Content",
280
+ value: "",
281
+ source: "self",
282
+ },
283
+ ]);
284
+ expect(selection ? isTextEditableSelection(selection) : false).toBe(true);
285
+ });
286
+
287
+ it("keeps emptied child text layers editable after their content is cleared", () => {
288
+ const document = createDocument(`
289
+ <div id="card" class="clip" style="position: absolute;">
290
+ <strong></strong>
291
+ <span></span>
292
+ </div>
293
+ `);
294
+
295
+ const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
296
+ activeCompositionPath: null,
297
+ isMasterView: false,
298
+ });
299
+
300
+ expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
301
+ expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]);
302
+ });
303
+ });
304
+
305
+ describe("patch builders and prompt builder", () => {
306
+ it("builds move patch operations for left/top", () => {
307
+ expect(buildDomEditMovePatchOperations(140.4, 82.1)).toEqual([
308
+ { type: "inline-style", property: "left", value: "140px" },
309
+ { type: "inline-style", property: "top", value: "82px" },
310
+ ]);
311
+ });
312
+
313
+ it("builds resize patch operations for width/height", () => {
314
+ expect(buildDomEditResizePatchOperations(301.6, 210.1)).toEqual([
315
+ { type: "inline-style", property: "width", value: "302px" },
316
+ { type: "inline-style", property: "height", value: "210px" },
317
+ ]);
318
+ });
319
+
320
+ it("builds style patch operations", () => {
321
+ expect(buildDomEditStylePatchOperation("background-color", "rgb(15, 23, 42)")).toEqual({
322
+ type: "inline-style",
323
+ property: "background-color",
324
+ value: "rgb(15, 23, 42)",
325
+ });
326
+ });
327
+
328
+ it("builds an agent prompt with source and selector context", () => {
329
+ const selection = {
330
+ element: {} as HTMLElement,
331
+ id: "editable-card",
332
+ selector: "#editable-card",
333
+ selectorIndex: undefined,
334
+ sourceFile: "index.html",
335
+ compositionPath: "index.html",
336
+ compositionSrc: undefined,
337
+ isCompositionHost: false,
338
+ label: "Drag me first",
339
+ tagName: "div",
340
+ boundingBox: { x: 108, y: 112, width: 380, height: 196 },
341
+ textContent: "Drag me first",
342
+ dataAttributes: {},
343
+ inlineStyles: {
344
+ left: "108px",
345
+ top: "112px",
346
+ width: "380px",
347
+ height: "196px",
348
+ },
349
+ computedStyles: {
350
+ position: "absolute",
351
+ left: "108px",
352
+ top: "112px",
353
+ width: "380px",
354
+ height: "196px",
355
+ color: "rgb(248, 250, 252)",
356
+ },
357
+ textFields: [
358
+ {
359
+ key: "self:0:div",
360
+ label: "Content",
361
+ value: "Drag me first",
362
+ tagName: "div",
363
+ attributes: [],
364
+ inlineStyles: {},
365
+ computedStyles: {},
366
+ source: "self",
367
+ },
368
+ ],
369
+ capabilities: {
370
+ canSelect: true,
371
+ canEditStyles: true,
372
+ canMove: true,
373
+ canResize: true,
374
+ },
375
+ } satisfies DomEditSelection;
376
+
377
+ const prompt = buildElementAgentPrompt({
378
+ selection,
379
+ currentTime: 1.25,
380
+ tagSnippet: `<div id="editable-card" style="position:absolute; left: 108px; top: 112px; width: 380px; height: 196px; color: rgb(248, 250, 252)"`,
381
+ });
382
+
383
+ expect(prompt).toContain("## HyperFrames element edit request v1");
384
+ expect(prompt).toContain("Schema version: 1");
385
+ expect(prompt).toContain("Source file: index.html");
386
+ expect(prompt).toContain("Selector: #editable-card");
387
+ expect(prompt).toContain("Playback time:");
388
+ expect(prompt).toContain("Text fields:");
389
+ expect(prompt).toContain('key=self:0:div; tag=<div>; source=self; text="Drag me first"');
390
+ expect(prompt).toContain("Inline styles:");
391
+ expect(prompt).toContain("Computed styles (browser-resolved):");
392
+ expect(prompt).toContain("Target HTML:");
393
+ expect(prompt).toContain("Guardrails:");
394
+ expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
395
+ });
396
+
397
+ it("serializes child text fields back into HTML", () => {
398
+ expect(
399
+ serializeDomEditTextFields([
400
+ {
401
+ key: "child:0:strong",
402
+ label: "Text 1",
403
+ value: "Headline <1>",
404
+ tagName: "strong",
405
+ attributes: [],
406
+ inlineStyles: {
407
+ "font-size": "22px",
408
+ },
409
+ computedStyles: {},
410
+ source: "child",
411
+ },
412
+ {
413
+ key: "child:1:span",
414
+ label: "Text 2",
415
+ value: "Details & more",
416
+ tagName: "span",
417
+ attributes: [],
418
+ inlineStyles: {},
419
+ computedStyles: {},
420
+ source: "child",
421
+ },
422
+ ]),
423
+ ).toBe(
424
+ '<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>',
425
+ );
426
+ });
427
+ });