@hyperframes/studio 0.5.0-alpha.13 → 0.5.0-alpha.15

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 (33) hide show
  1. package/dist/assets/{hyperframes-player-vibA20NC.js → hyperframes-player-Cd8vYWxP.js} +2 -2
  2. package/dist/assets/index-DFLVGWTx.js +106 -0
  3. package/dist/assets/index-mXJ-UH9F.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +785 -377
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  13. package/src/components/editor/DomEditOverlay.tsx +970 -115
  14. package/src/components/editor/PropertyPanel.tsx +91 -83
  15. package/src/components/editor/domEditing.test.ts +161 -29
  16. package/src/components/editor/domEditing.ts +84 -113
  17. package/src/components/editor/manualEdits.test.ts +945 -0
  18. package/src/components/editor/manualEdits.ts +1397 -0
  19. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  20. package/src/components/editor/manualOffsetDrag.ts +307 -0
  21. package/src/components/renders/RenderQueue.tsx +10 -3
  22. package/src/hooks/usePersistentEditHistory.test.ts +1 -0
  23. package/src/hooks/usePersistentEditHistory.ts +3 -2
  24. package/src/player/components/CompositionThumbnail.test.ts +1 -1
  25. package/src/player/components/CompositionThumbnail.tsx +1 -1
  26. package/src/player/components/Player.tsx +54 -9
  27. package/src/player/hooks/useTimelinePlayer.test.ts +1 -0
  28. package/src/utils/clipboard.test.ts +1 -0
  29. package/src/utils/frameCapture.ts +3 -1
  30. package/src/utils/projectRouting.test.ts +87 -0
  31. package/src/utils/projectRouting.ts +27 -0
  32. package/dist/assets/index-JhhmFie-.js +0 -105
  33. package/dist/assets/index-KioPDrX6.css +0 -1
@@ -38,6 +38,7 @@ import {
38
38
  type GradientModel,
39
39
  } from "./gradientValue";
40
40
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
41
+ import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
41
42
  import {
42
43
  COMMON_LOCAL_FONT_FAMILIES,
43
44
  googleFontStylesheetUrl,
@@ -54,17 +55,17 @@ interface PropertyPanelProps {
54
55
  copiedAgentPrompt: boolean;
55
56
  onClearSelection: () => void;
56
57
  onSetStyle: (prop: string, value: string) => void;
58
+ onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
59
+ onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
57
60
  onSetText: (value: string, fieldKey?: string) => void;
58
61
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
59
62
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
60
63
  onRemoveTextField: (fieldKey: string) => void;
61
- onDetachFromLayout: () => void;
64
+ onResetManualEdits: (element: DomEditSelection) => void;
62
65
  onAskAgent: () => void;
63
- onCopyAgentInstruction: (instruction: string) => void | Promise<void>;
64
66
  onImportAssets?: (files: FileList) => Promise<string[]>;
65
67
  fontAssets?: ImportedFontAsset[];
66
68
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
67
- allowLayoutDetach?: boolean;
68
69
  }
69
70
 
70
71
  const FIELD =
@@ -117,17 +118,6 @@ interface FontOption {
117
118
 
118
119
  const COLOR_PICKER_SIZE = { width: 292, height: 386 };
119
120
 
120
- function buildMakeMovableAgentInstruction(reason: string): string {
121
- return [
122
- "Make this selected HyperFrames element movable in Studio.",
123
- `Studio blocked direct move/resize because: ${reason}`,
124
- "Refactor only this element safely.",
125
- "Preserve its current visual position, timing, and sibling layout intent where possible.",
126
- "If it is transform-driven, replace transform-based positioning with absolute pixel geometry.",
127
- "If layout flow owns it, detach conservatively with position: absolute, px left/top/width/height, and margin: 0.",
128
- ].join(" ");
129
- }
130
-
131
121
  function colorFromCss(value: string): ParsedColor {
132
122
  return parseCssColor(value) ?? { red: 0, green: 0, blue: 0, alpha: 1 };
133
123
  }
@@ -184,6 +174,17 @@ function parseNumericToken(value: string | undefined): ParsedNumericToken | null
184
174
  };
185
175
  }
186
176
 
177
+ function parsePxMetricValue(value: string): number | null {
178
+ const token = parseNumericToken(value);
179
+ if (!token) return null;
180
+ if (token.unit && token.unit.toLowerCase() !== "px") return null;
181
+ return token.value;
182
+ }
183
+
184
+ function formatPxMetricValue(value: number): string {
185
+ return `${formatNumericValue(value)}px`;
186
+ }
187
+
187
188
  function adjustNumericToken(
188
189
  value: string,
189
190
  direction: 1 | -1,
@@ -1975,17 +1976,17 @@ export const PropertyPanel = memo(function PropertyPanel({
1975
1976
  copiedAgentPrompt,
1976
1977
  onClearSelection,
1977
1978
  onSetStyle,
1979
+ onSetManualOffset,
1980
+ onSetManualSize,
1978
1981
  onSetText,
1979
1982
  onSetTextFieldStyle,
1980
1983
  onAddTextField,
1981
1984
  onRemoveTextField,
1982
- onDetachFromLayout,
1985
+ onResetManualEdits,
1983
1986
  onAskAgent,
1984
- onCopyAgentInstruction,
1985
1987
  onImportAssets,
1986
1988
  fontAssets = [],
1987
1989
  onImportFonts,
1988
- allowLayoutDetach = true,
1989
1990
  }: PropertyPanelProps) {
1990
1991
  const styles = element?.computedStyles ?? EMPTY_STYLES;
1991
1992
  const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
@@ -2021,28 +2022,60 @@ export const PropertyPanel = memo(function PropertyPanel({
2021
2022
  <Eye size={18} className="mb-3 text-neutral-600" />
2022
2023
  <p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
2023
2024
  <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
2024
- The inspector is tuned for direct DOM edits with safer geometry controls, color picking,
2025
- and cleaner grouped layer controls.
2025
+ The inspector is tuned for element edits with safer geometry controls, color picking, and
2026
+ cleaner grouped layer controls.
2026
2027
  </p>
2027
2028
  </div>
2028
2029
  );
2029
2030
  }
2030
2031
 
2031
2032
  const styleEditingDisabled = !element.capabilities.canEditStyles;
2032
- const moveEditingDisabled = !element.capabilities.canMove;
2033
- const resizeEditingDisabled = !element.capabilities.canResize;
2033
+ const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
2034
+ const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
2034
2035
  const isFlex = styles.display === "flex" || styles.display === "inline-flex";
2035
2036
  const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
2036
2037
  const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
2037
2038
  const clipContent = ["hidden", "clip"].includes((styles.overflow ?? "").trim());
2038
2039
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
2039
2040
  const showEditableSections = element.capabilities.canEditStyles;
2040
- const disabledMoveReason =
2041
- allowLayoutDetach &&
2042
- element.capabilities.reasonIfDisabled &&
2043
- !element.capabilities.canDetachFromLayout
2044
- ? element.capabilities.reasonIfDisabled
2045
- : null;
2041
+ const manualOffset = readStudioPathOffset(element.element);
2042
+ const manualSize = readStudioBoxSize(element.element);
2043
+ const resolvedWidth =
2044
+ manualSize.width > 0
2045
+ ? manualSize.width
2046
+ : (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
2047
+ const resolvedHeight =
2048
+ manualSize.height > 0
2049
+ ? manualSize.height
2050
+ : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
2051
+
2052
+ const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
2053
+ const parsed = parsePxMetricValue(nextValue);
2054
+ if (parsed == null) return;
2055
+ const current = readStudioPathOffset(element.element);
2056
+ onSetManualOffset(element, {
2057
+ x: axis === "x" ? parsed : current.x,
2058
+ y: axis === "y" ? parsed : current.y,
2059
+ });
2060
+ };
2061
+
2062
+ const commitManualSize = (axis: "width" | "height", nextValue: string) => {
2063
+ const parsed = parsePxMetricValue(nextValue);
2064
+ if (parsed == null || parsed <= 0) return;
2065
+ const current = readStudioBoxSize(element.element);
2066
+ const width =
2067
+ current.width > 0
2068
+ ? current.width
2069
+ : (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
2070
+ const height =
2071
+ current.height > 0
2072
+ ? current.height
2073
+ : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
2074
+ onSetManualSize(element, {
2075
+ width: axis === "width" ? parsed : width,
2076
+ height: axis === "height" ? parsed : height,
2077
+ });
2078
+ };
2046
2079
 
2047
2080
  const handleFillModeChange = (nextMode: string) => {
2048
2081
  setPreferredFillMode(nextMode);
@@ -2078,14 +2111,25 @@ export const PropertyPanel = memo(function PropertyPanel({
2078
2111
  <X size={13} />
2079
2112
  </button>
2080
2113
  </div>
2081
- <button
2082
- type="button"
2083
- onClick={onAskAgent}
2084
- className="mt-4 inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
2085
- >
2086
- <MessageSquare size={15} />
2087
- <span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
2088
- </button>
2114
+ <div className="mt-4 flex min-w-0 flex-wrap items-center gap-2">
2115
+ <button
2116
+ type="button"
2117
+ onClick={onAskAgent}
2118
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
2119
+ >
2120
+ <MessageSquare size={15} />
2121
+ <span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
2122
+ </button>
2123
+ <button
2124
+ type="button"
2125
+ onClick={() => onResetManualEdits(element)}
2126
+ title="Reset move, size, and rotation edits"
2127
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-neutral-500 hover:text-white"
2128
+ >
2129
+ <RotateCcw size={14} />
2130
+ <span>Reset edits</span>
2131
+ </button>
2132
+ </div>
2089
2133
  </div>
2090
2134
 
2091
2135
  <div className="flex-1 overflow-y-auto">
@@ -2093,65 +2137,29 @@ export const PropertyPanel = memo(function PropertyPanel({
2093
2137
  <div className={RESPONSIVE_GRID}>
2094
2138
  <MetricField
2095
2139
  label="X"
2096
- value={styles.left ?? "auto"}
2097
- disabled={moveEditingDisabled}
2098
- onCommit={(next) => onSetStyle("left", next)}
2140
+ value={formatPxMetricValue(manualOffset.x)}
2141
+ disabled={manualOffsetEditingDisabled}
2142
+ onCommit={(next) => commitManualOffset("x", next)}
2099
2143
  />
2100
2144
  <MetricField
2101
2145
  label="Y"
2102
- value={styles.top ?? "auto"}
2103
- disabled={moveEditingDisabled}
2104
- onCommit={(next) => onSetStyle("top", next)}
2146
+ value={formatPxMetricValue(manualOffset.y)}
2147
+ disabled={manualOffsetEditingDisabled}
2148
+ onCommit={(next) => commitManualOffset("y", next)}
2105
2149
  />
2106
2150
  <MetricField
2107
2151
  label="W"
2108
- value={styles.width ?? "auto"}
2109
- disabled={resizeEditingDisabled}
2110
- onCommit={(next) => onSetStyle("width", next)}
2152
+ value={formatPxMetricValue(resolvedWidth)}
2153
+ disabled={manualSizeEditingDisabled}
2154
+ onCommit={(next) => commitManualSize("width", next)}
2111
2155
  />
2112
2156
  <MetricField
2113
2157
  label="H"
2114
- value={styles.height ?? "auto"}
2115
- disabled={resizeEditingDisabled}
2116
- onCommit={(next) => onSetStyle("height", next)}
2158
+ value={formatPxMetricValue(resolvedHeight)}
2159
+ disabled={manualSizeEditingDisabled}
2160
+ onCommit={(next) => commitManualSize("height", next)}
2117
2161
  />
2118
2162
  </div>
2119
- {disabledMoveReason && (
2120
- <div className="mt-4 flex min-w-0 flex-wrap items-center justify-between gap-3 border-l border-amber-500/40 pl-3">
2121
- <div className="min-w-0 text-[11px] leading-5 text-neutral-400">
2122
- <div className="font-medium text-amber-100">{disabledMoveReason}</div>
2123
- <div>Copy a targeted prompt so an agent can refactor this layer safely.</div>
2124
- </div>
2125
- <button
2126
- type="button"
2127
- onClick={() => {
2128
- void Promise.resolve(
2129
- onCopyAgentInstruction(buildMakeMovableAgentInstruction(disabledMoveReason)),
2130
- );
2131
- }}
2132
- className="inline-flex h-8 flex-shrink-0 items-center rounded-lg border border-neutral-700 bg-neutral-950 px-3 text-[11px] font-medium text-neutral-100 transition-colors hover:border-amber-400/70 hover:text-amber-100"
2133
- >
2134
- {copiedAgentPrompt ? "Prompt copied" : "Ask agent to make movable"}
2135
- </button>
2136
- </div>
2137
- )}
2138
- {allowLayoutDetach && element.capabilities.canDetachFromLayout && (
2139
- <div className="mt-4 flex min-w-0 flex-wrap items-center justify-between gap-3 border-l border-amber-500/40 pl-3">
2140
- <div className="min-w-0 text-[11px] leading-5 text-neutral-400">
2141
- <div className="font-medium text-neutral-200">
2142
- This layer is controlled by layout.
2143
- </div>
2144
- <div>Detaches from flex/grid flow and preserves current visual position.</div>
2145
- </div>
2146
- <button
2147
- type="button"
2148
- onClick={onDetachFromLayout}
2149
- className="inline-flex h-8 flex-shrink-0 items-center rounded-lg border border-neutral-700 bg-neutral-950 px-3 text-[11px] font-medium text-neutral-100 transition-colors hover:border-amber-400/70 hover:text-amber-100"
2150
- >
2151
- Make movable
2152
- </button>
2153
- </div>
2154
- )}
2155
2163
  </Section>
2156
2164
 
2157
2165
  {showEditableSections && isFlex && (
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { Window } from "happy-dom";
3
3
  import {
4
- buildDomEditMovePatchOperations,
5
- buildDomEditResizePatchOperations,
6
4
  buildDomEditStylePatchOperation,
7
5
  buildElementAgentPrompt,
8
6
  findElementForSelection,
7
+ getDomEditNonEditableReason,
8
+ getDomEditTargetKey,
9
9
  isTextEditableSelection,
10
10
  serializeDomEditTextFields,
11
11
  type DomEditSelection,
@@ -47,7 +47,9 @@ describe("resolveDomEditCapabilities", () => {
47
47
  canEditStyles: true,
48
48
  canMove: true,
49
49
  canResize: true,
50
- canDetachFromLayout: false,
50
+ canApplyManualOffset: true,
51
+ canApplyManualSize: true,
52
+ canApplyManualRotation: true,
51
53
  reasonIfDisabled: undefined,
52
54
  });
53
55
  });
@@ -75,8 +77,10 @@ describe("resolveDomEditCapabilities", () => {
75
77
  canEditStyles: true,
76
78
  canMove: false,
77
79
  canResize: false,
78
- canDetachFromLayout: true,
79
- reasonIfDisabled: "This layer is controlled by layout.",
80
+ canApplyManualOffset: true,
81
+ canApplyManualSize: true,
82
+ canApplyManualRotation: true,
83
+ reasonIfDisabled: undefined,
80
84
  });
81
85
  });
82
86
 
@@ -104,7 +108,9 @@ describe("resolveDomEditCapabilities", () => {
104
108
  ).toMatchObject({
105
109
  canMove: false,
106
110
  canResize: false,
107
- canDetachFromLayout: false,
111
+ canApplyManualOffset: true,
112
+ canApplyManualSize: true,
113
+ canApplyManualRotation: true,
108
114
  });
109
115
  });
110
116
 
@@ -132,7 +138,7 @@ describe("resolveDomEditCapabilities", () => {
132
138
  ).toMatchObject({
133
139
  canMove: true,
134
140
  canResize: true,
135
- canDetachFromLayout: false,
141
+ canApplyManualOffset: true,
136
142
  });
137
143
  });
138
144
 
@@ -191,7 +197,7 @@ describe("resolveDomEditCapabilities", () => {
191
197
  });
192
198
 
193
199
  describe("resolveDomEditSelection", () => {
194
- it("allows moving composition hosts in master view while keeping contents drill-down only", () => {
200
+ it("keeps composition host transforms disabled in master view", () => {
195
201
  expect(
196
202
  resolveDomEditCapabilities({
197
203
  selector: "#detail-host",
@@ -217,15 +223,49 @@ describe("resolveDomEditSelection", () => {
217
223
  canEditStyles: false,
218
224
  canMove: true,
219
225
  canResize: true,
220
- canDetachFromLayout: false,
221
- reasonIfDisabled: undefined,
226
+ canApplyManualOffset: false,
227
+ canApplyManualSize: false,
228
+ canApplyManualRotation: false,
229
+ reasonIfDisabled: "Select an internal layer to transform it.",
230
+ });
231
+ });
232
+
233
+ it("resolves child clicks inside a composition host to the child in master view", () => {
234
+ const document = createDocument(`
235
+ <div data-composition-id="main">
236
+ <div
237
+ id="detail-host"
238
+ class="clip"
239
+ data-composition-id="detail-card"
240
+ data-composition-file="compositions/detail-card.html"
241
+ >
242
+ <span id="inner-copy">Nested scene</span>
243
+ </div>
244
+ </div>
245
+ `);
246
+
247
+ const child = document.getElementById("inner-copy") as HTMLElement;
248
+ const selection = resolveDomEditSelection(child, {
249
+ activeCompositionPath: null,
250
+ isMasterView: true,
222
251
  });
252
+
253
+ expect(selection?.id).toBe("inner-copy");
254
+ expect(selection?.sourceFile).toBe("compositions/detail-card.html");
255
+ expect(selection?.isCompositionHost).toBe(false);
256
+ expect(selection?.capabilities.canApplyManualOffset).toBe(true);
257
+ expect(selection?.capabilities.canEditStyles).toBe(true);
223
258
  });
224
259
 
225
- it("resolves child clicks inside a composition host back to the host in master view", () => {
260
+ it("does not prefer a scene host clip ancestor when selecting inside it", () => {
226
261
  const document = createDocument(`
227
262
  <div data-composition-id="main">
228
- <div id="detail-host" data-composition-src="compositions/detail-card.html">
263
+ <div
264
+ id="detail-host"
265
+ class="clip"
266
+ data-composition-id="detail-card"
267
+ data-composition-file="compositions/detail-card.html"
268
+ >
229
269
  <span id="inner-copy">Nested scene</span>
230
270
  </div>
231
271
  </div>
@@ -235,12 +275,40 @@ describe("resolveDomEditSelection", () => {
235
275
  const selection = resolveDomEditSelection(child, {
236
276
  activeCompositionPath: null,
237
277
  isMasterView: true,
278
+ preferClipAncestor: true,
279
+ });
280
+
281
+ expect(selection?.id).toBe("inner-copy");
282
+ expect(selection?.sourceFile).toBe("compositions/detail-card.html");
283
+ expect(selection?.isCompositionHost).toBe(false);
284
+ });
285
+
286
+ it("still prefers an internal clip ancestor inside a scene", () => {
287
+ const document = createDocument(`
288
+ <div data-composition-id="main">
289
+ <div
290
+ id="detail-host"
291
+ class="clip"
292
+ data-composition-id="detail-card"
293
+ data-composition-file="compositions/detail-card.html"
294
+ >
295
+ <section id="nested-card" class="clip">
296
+ <span id="inner-copy">Nested scene</span>
297
+ </section>
298
+ </div>
299
+ </div>
300
+ `);
301
+
302
+ const child = document.getElementById("inner-copy") as HTMLElement;
303
+ const selection = resolveDomEditSelection(child, {
304
+ activeCompositionPath: null,
305
+ isMasterView: true,
306
+ preferClipAncestor: true,
238
307
  });
239
308
 
240
- expect(selection?.id).toBe("detail-host");
241
- expect(selection?.isCompositionHost).toBe(true);
242
- expect(selection?.capabilities.canMove).toBe(false);
243
- expect(selection?.capabilities.canEditStyles).toBe(false);
309
+ expect(selection?.id).toBe("nested-card");
310
+ expect(selection?.sourceFile).toBe("compositions/detail-card.html");
311
+ expect(selection?.isCompositionHost).toBe(false);
244
312
  });
245
313
 
246
314
  it("scopes class selector indexing to the same source file", () => {
@@ -265,6 +333,28 @@ describe("resolveDomEditSelection", () => {
265
333
  expect(findElementForSelection(document, selection!, null)).toBe(rootChip);
266
334
  });
267
335
 
336
+ it("resolves nested duplicate ids from master view without treating root as the nested source", () => {
337
+ const document = createDocument(`
338
+ <div data-composition-id="main">
339
+ <div id="card">Root card</div>
340
+ <div data-composition-id="nested" data-composition-file="scenes/nested.html">
341
+ <div id="card">Nested card</div>
342
+ </div>
343
+ </div>
344
+ `);
345
+
346
+ const nestedCard = document.querySelector(
347
+ '[data-composition-file="scenes/nested.html"] #card',
348
+ ) as HTMLElement;
349
+ const selection = resolveDomEditSelection(nestedCard, {
350
+ activeCompositionPath: null,
351
+ isMasterView: true,
352
+ });
353
+
354
+ expect(selection?.sourceFile).toBe("scenes/nested.html");
355
+ expect(findElementForSelection(document, selection!, null)).toBe(nestedCard);
356
+ });
357
+
268
358
  it("prefers the nearest clip ancestor on single-click style selection", () => {
269
359
  const document = createDocument(`
270
360
  <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
@@ -373,23 +463,60 @@ describe("resolveDomEditSelection", () => {
373
463
  expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
374
464
  expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]);
375
465
  });
376
- });
377
466
 
378
- describe("patch builders and prompt builder", () => {
379
- it("builds move patch operations for left/top", () => {
380
- expect(buildDomEditMovePatchOperations(140.4, 82.1)).toEqual([
381
- { type: "inline-style", property: "left", value: "140px" },
382
- { type: "inline-style", property: "top", value: "82px" },
383
- ]);
467
+ it("explains anonymous child elements that resolve to an editable parent", () => {
468
+ const document = createDocument(`
469
+ <div data-composition-id="main">
470
+ <div id="card">
471
+ <strong>Headline</strong>
472
+ </div>
473
+ </div>
474
+ `);
475
+
476
+ const child = document.querySelector("strong") as HTMLElement;
477
+ const selection = resolveDomEditSelection(child, {
478
+ activeCompositionPath: null,
479
+ isMasterView: false,
480
+ preferClipAncestor: false,
481
+ });
482
+
483
+ expect(selection?.id).toBe("card");
484
+ expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card");
384
485
  });
385
486
 
386
- it("builds resize patch operations for width/height", () => {
387
- expect(buildDomEditResizePatchOperations(301.6, 210.1)).toEqual([
388
- { type: "inline-style", property: "width", value: "302px" },
389
- { type: "inline-style", property: "height", value: "210px" },
390
- ]);
487
+ it("does not mark an element as non-editable when Studio can edit it directly", () => {
488
+ const document = createDocument(`
489
+ <div data-composition-id="main">
490
+ <div id="card">Editable</div>
491
+ </div>
492
+ `);
493
+
494
+ const element = document.getElementById("card") as HTMLElement;
495
+ const selection = resolveDomEditSelection(element, {
496
+ activeCompositionPath: null,
497
+ isMasterView: false,
498
+ });
499
+
500
+ expect(getDomEditNonEditableReason(element, selection)).toBeNull();
501
+ });
502
+
503
+ it("keeps duplicate class targets distinct for history keys", () => {
504
+ const first = getDomEditTargetKey({
505
+ sourceFile: "index.html",
506
+ selector: ".card",
507
+ selectorIndex: 0,
508
+ });
509
+ const second = getDomEditTargetKey({
510
+ sourceFile: "index.html",
511
+ selector: ".card",
512
+ selectorIndex: 1,
513
+ });
514
+
515
+ expect(first).not.toBe(second);
391
516
  });
517
+ });
392
518
 
519
+ describe("patch builders and prompt builder", () => {
393
520
  it("builds style patch operations", () => {
394
521
  expect(buildDomEditStylePatchOperation("background-color", "rgb(15, 23, 42)")).toEqual({
395
522
  type: "inline-style",
@@ -444,6 +571,9 @@ describe("patch builders and prompt builder", () => {
444
571
  canEditStyles: true,
445
572
  canMove: true,
446
573
  canResize: true,
574
+ canApplyManualOffset: true,
575
+ canApplyManualSize: true,
576
+ canApplyManualRotation: true,
447
577
  },
448
578
  } satisfies DomEditSelection;
449
579
 
@@ -490,7 +620,9 @@ describe("patch builders and prompt builder", () => {
490
620
  canEditStyles: true,
491
621
  canMove: true,
492
622
  canResize: true,
493
- canDetachFromLayout: false,
623
+ canApplyManualOffset: true,
624
+ canApplyManualSize: true,
625
+ canApplyManualRotation: true,
494
626
  },
495
627
  } satisfies DomEditSelection;
496
628