@hyperframes/studio 0.6.0-alpha.2 → 0.6.0-alpha.4

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.
package/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  useRef,
5
5
  useEffect,
6
6
  useMemo,
7
+ type CSSProperties,
7
8
  type MouseEvent,
8
9
  type ReactNode,
9
10
  } from "react";
@@ -78,10 +79,10 @@ import {
78
79
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
79
80
  STUDIO_MOTION_PANEL_ENABLED,
80
81
  STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
82
+ STUDIO_PREVIEW_SELECTION_ENABLED,
81
83
  STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
82
84
  } from "./components/editor/manualEditingAvailability";
83
85
  import {
84
- buildDefaultDomEditTextField,
85
86
  buildDomEditStylePatchOperation,
86
87
  buildDomEditTextPatchOperation,
87
88
  buildElementAgentPrompt,
@@ -91,9 +92,12 @@ import {
91
92
  findElementForTimelineElement,
92
93
  getDomEditLayerKey,
93
94
  getDomEditTargetKey,
95
+ isLargeRasterDomEditSelection,
94
96
  isTextEditableSelection,
97
+ resolveVisualDomEditSelectionTarget,
95
98
  serializeDomEditTextFields,
96
99
  resolveDomEditSelection,
100
+ type DomEditViewport,
97
101
  type DomEditLayerItem,
98
102
  type DomEditTextField,
99
103
  type DomEditSelection,
@@ -358,10 +362,64 @@ function isManualGeometryStyleProperty(property: string): boolean {
358
362
  return property === "left" || property === "top" || property === "width" || property === "height";
359
363
  }
360
364
 
365
+ interface PreviewLocalPointer {
366
+ x: number;
367
+ y: number;
368
+ viewport: DomEditViewport;
369
+ }
370
+
371
+ interface AgentModalAnchorPoint {
372
+ x: number;
373
+ y: number;
374
+ }
375
+
376
+ function resolvePreviewLocalPointer(
377
+ iframe: HTMLIFrameElement,
378
+ doc: Document,
379
+ win: Window,
380
+ clientX: number,
381
+ clientY: number,
382
+ ): PreviewLocalPointer | null {
383
+ const iframeRect = iframe.getBoundingClientRect();
384
+ const root =
385
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
386
+ const rootRect = root?.getBoundingClientRect();
387
+ const rootWidth = rootRect?.width || win.innerWidth;
388
+ const rootHeight = rootRect?.height || win.innerHeight;
389
+ if (!rootWidth || !rootHeight) return null;
390
+
391
+ const scaleX = iframeRect.width / rootWidth;
392
+ const scaleY = iframeRect.height / rootHeight;
393
+ return {
394
+ x: (clientX - iframeRect.left) / scaleX,
395
+ y: (clientY - iframeRect.top) / scaleY,
396
+ viewport: { width: rootWidth, height: rootHeight },
397
+ };
398
+ }
399
+
400
+ function getPreviewLocalPointer(
401
+ iframe: HTMLIFrameElement,
402
+ clientX: number,
403
+ clientY: number,
404
+ ): PreviewLocalPointer | null {
405
+ let doc: Document | null = null;
406
+ let win: Window | null = null;
407
+ try {
408
+ doc = iframe.contentDocument;
409
+ win = iframe.contentWindow;
410
+ } catch {
411
+ return null;
412
+ }
413
+ if (!doc || !win) return null;
414
+
415
+ return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
416
+ }
417
+
361
418
  function getPreviewTargetFromPointer(
362
419
  iframe: HTMLIFrameElement,
363
420
  clientX: number,
364
421
  clientY: number,
422
+ activeCompositionPath: string | null,
365
423
  ): HTMLElement | null {
366
424
  let doc: Document | null = null;
367
425
  let win: Window | null = null;
@@ -373,20 +431,35 @@ function getPreviewTargetFromPointer(
373
431
  }
374
432
  if (!doc || !win) return null;
375
433
 
376
- const iframeRect = iframe.getBoundingClientRect();
377
- const root =
378
- doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
379
- const rootRect = root?.getBoundingClientRect();
380
- const rootWidth = rootRect?.width || win.innerWidth;
381
- const rootHeight = rootRect?.height || win.innerHeight;
382
- if (!rootWidth || !rootHeight) return null;
434
+ const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
435
+ if (!localPointer) return null;
383
436
 
384
- const scaleX = iframeRect.width / rootWidth;
385
- const scaleY = iframeRect.height / rootHeight;
386
- const localX = (clientX - iframeRect.left) / scaleX;
387
- const localY = (clientY - iframeRect.top) / scaleY;
437
+ if (typeof doc.elementsFromPoint === "function") {
438
+ const visualTarget = resolveVisualDomEditSelectionTarget(
439
+ doc.elementsFromPoint(localPointer.x, localPointer.y),
440
+ {
441
+ activeCompositionPath,
442
+ },
443
+ );
444
+ if (visualTarget) return visualTarget;
445
+ }
388
446
 
389
- return getEventTargetElement(doc.elementFromPoint(localX, localY));
447
+ return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
448
+ }
449
+
450
+ function buildRasterClickSelectionContext(
451
+ selection: DomEditSelection,
452
+ localPointer: PreviewLocalPointer,
453
+ ): string {
454
+ return [
455
+ "The user clicked a large raster/background element in the Studio preview.",
456
+ `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
457
+ localPointer.viewport.width,
458
+ )}x${Math.round(localPointer.viewport.height)} composition.`,
459
+ `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
460
+ "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
461
+ "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
462
+ ].join("\n");
390
463
  }
391
464
 
392
465
  function domEditSelectionsTargetSame(
@@ -592,17 +665,47 @@ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number |
592
665
 
593
666
  // ── Ask Agent Modal ──
594
667
 
668
+ function clampNumber(value: number, min: number, max: number): number {
669
+ if (max < min) return min;
670
+ return Math.min(Math.max(value, min), max);
671
+ }
672
+
673
+ function getAgentModalPositionStyle(
674
+ anchorPoint: AgentModalAnchorPoint | null,
675
+ ): CSSProperties | undefined {
676
+ if (!anchorPoint || typeof window === "undefined") return undefined;
677
+
678
+ const modalWidth = 480;
679
+ const estimatedModalHeight = 270;
680
+ const margin = 16;
681
+ const left = clampNumber(
682
+ anchorPoint.x,
683
+ margin + modalWidth / 2,
684
+ window.innerWidth - margin - modalWidth / 2,
685
+ );
686
+ const top = clampNumber(
687
+ anchorPoint.y + 12,
688
+ margin,
689
+ window.innerHeight - margin - estimatedModalHeight,
690
+ );
691
+
692
+ return { left, top, transform: "translateX(-50%)" };
693
+ }
694
+
595
695
  function AskAgentModal({
596
696
  selectionLabel,
697
+ anchorPoint = null,
597
698
  onSubmit,
598
699
  onClose,
599
700
  }: {
600
701
  selectionLabel: string;
702
+ anchorPoint?: AgentModalAnchorPoint | null;
601
703
  onSubmit: (instruction: string) => void;
602
704
  onClose: () => void;
603
705
  }) {
604
706
  const [value, setValue] = useState("");
605
707
  const inputRef = useRef<HTMLTextAreaElement>(null);
708
+ const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
606
709
 
607
710
  useMountEffect(() => {
608
711
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -615,11 +718,18 @@ function AskAgentModal({
615
718
 
616
719
  return (
617
720
  <div
618
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
721
+ className={
722
+ anchorPoint
723
+ ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
724
+ : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
725
+ }
619
726
  onClick={onClose}
620
727
  >
621
728
  <div
622
- className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
729
+ className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
730
+ anchorPoint ? "fixed" : ""
731
+ }`}
732
+ style={modalPositionStyle}
623
733
  onClick={(e) => e.stopPropagation()}
624
734
  >
625
735
  <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
@@ -774,13 +884,16 @@ export function StudioApp() {
774
884
  const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
775
885
  const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
776
886
  const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
887
+ const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
888
+ string | undefined
889
+ >();
890
+ const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
891
+ null,
892
+ );
777
893
  const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
778
894
  const [agentModalOpen, setAgentModalOpen] = useState(false);
779
895
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
780
896
  const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
781
- const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState<
782
- ReadonlySet<string>
783
- >(() => new Set());
784
897
  const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
785
898
  const refreshPreviewDocumentVersion = useCallback(() => {
786
899
  setPreviewDocumentVersion((version) => version + 1);
@@ -1771,6 +1884,8 @@ export function StudioApp() {
1771
1884
  options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
1772
1885
  ) => {
1773
1886
  setAgentPromptTagSnippet(undefined);
1887
+ setAgentPromptSelectionContext(undefined);
1888
+ setAgentModalAnchorPoint(null);
1774
1889
  setCopiedAgentPrompt(false);
1775
1890
  if (!selection) {
1776
1891
  domEditSelectionRef.current = null;
@@ -2296,13 +2411,13 @@ export function StudioApp() {
2296
2411
  (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2297
2412
  const iframe = previewIframeRef.current;
2298
2413
  if (!iframe || captionEditMode) return null;
2299
- const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
2414
+ const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
2300
2415
  if (!target) return null;
2301
2416
  return buildDomSelectionFromTarget(target, {
2302
2417
  preferClipAncestor: options?.preferClipAncestor,
2303
2418
  });
2304
2419
  },
2305
- [buildDomSelectionFromTarget, captionEditMode],
2420
+ [activeCompPath, buildDomSelectionFromTarget, captionEditMode],
2306
2421
  );
2307
2422
 
2308
2423
  const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
@@ -2398,8 +2513,21 @@ export function StudioApp() {
2398
2513
 
2399
2514
  const selection = buildDomSelectionForTimelineElement(element);
2400
2515
  if (selection) applyDomSelection(selection);
2516
+
2517
+ const key = getTimelineElementKey(element);
2518
+ if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
2519
+ setInspectedTimelineElementId(key);
2520
+ setLeftCollapsed(false);
2521
+
2522
+ const iframe = previewIframeRef.current;
2523
+ if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2524
+ seekStudioPreview(iframe, element.start);
2525
+ }
2526
+ } else {
2527
+ setInspectedTimelineElementId(null);
2528
+ }
2401
2529
  },
2402
- [applyDomSelection, buildDomSelectionForTimelineElement],
2530
+ [applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
2403
2531
  );
2404
2532
 
2405
2533
  const handleTimelineElementInspect = useCallback(
@@ -2426,17 +2554,6 @@ export function StudioApp() {
2426
2554
  [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
2427
2555
  );
2428
2556
 
2429
- const handleToggleTimelineElementThumbnail = useCallback((element: TimelineElement) => {
2430
- const key = getTimelineElementKey(element);
2431
- if (!key) return;
2432
- setThumbnailedTimelineElementIds((current) => {
2433
- const next = new Set(current);
2434
- if (next.has(key)) next.delete(key);
2435
- else next.add(key);
2436
- return next;
2437
- });
2438
- }, []);
2439
-
2440
2557
  const handleTimelineLayerSelect = useCallback(
2441
2558
  (layer: DomEditLayerItem) => {
2442
2559
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
@@ -2816,15 +2933,26 @@ export function StudioApp() {
2816
2933
  buildDomEditStylePatchOperation("background-size", "contain"),
2817
2934
  );
2818
2935
  }
2819
- await persistDomEditOperations(domEditSelection, operations, {
2820
- label: "Edit layer style",
2821
- skipRefresh: true,
2822
- prepareContent: importedFont
2823
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2824
- : undefined,
2825
- });
2936
+ try {
2937
+ await persistDomEditOperations(domEditSelection, operations, {
2938
+ label: "Edit layer style",
2939
+ skipRefresh: true,
2940
+ prepareContent: importedFont
2941
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2942
+ : undefined,
2943
+ });
2944
+ } catch (err) {
2945
+ console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
2946
+ }
2947
+ refreshDomEditSelectionFromPreview(domEditSelection);
2826
2948
  },
2827
- [activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
2949
+ [
2950
+ activeCompPath,
2951
+ domEditSelection,
2952
+ persistDomEditOperations,
2953
+ refreshDomEditSelectionFromPreview,
2954
+ resolveImportedFontAsset,
2955
+ ],
2828
2956
  );
2829
2957
 
2830
2958
  const handleDomTextCommit = useCallback(
@@ -2978,51 +3106,11 @@ export function StudioApp() {
2978
3106
  [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
2979
3107
  );
2980
3108
 
2981
- const handleDomAddTextField = useCallback(
2982
- async (afterFieldKey?: string) => {
2983
- if (!domEditSelection) return null;
2984
- if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
2985
-
2986
- const insertionIndex = domEditSelection.textFields.findIndex(
2987
- (field) => field.key === afterFieldKey,
2988
- );
2989
- const baseField =
2990
- domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
2991
- domEditSelection.textFields[0];
2992
- const nextField = buildDefaultDomEditTextField(baseField);
2993
- const nextTextFields = [...domEditSelection.textFields];
2994
- nextTextFields.splice(
2995
- insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
2996
- 0,
2997
- nextField,
2998
- );
2999
-
3000
- await commitDomTextFields(domEditSelection, nextTextFields);
3001
- return nextField.key;
3002
- },
3003
- [commitDomTextFields, domEditSelection],
3004
- );
3005
-
3006
- const handleDomRemoveTextField = useCallback(
3007
- async (fieldKey: string) => {
3008
- if (!domEditSelection) return;
3009
- const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
3010
- if (!field) return;
3011
-
3012
- if (field.source === "self") {
3013
- await handleDomTextCommit("", fieldKey);
3014
- return;
3015
- }
3016
-
3017
- const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
3018
- await commitDomTextFields(domEditSelection, nextTextFields);
3019
- },
3020
- [commitDomTextFields, domEditSelection, handleDomTextCommit],
3021
- );
3022
-
3023
3109
  const handleAskAgent = useCallback(() => {
3024
3110
  if (!domEditSelection) return;
3025
3111
  setAgentPromptTagSnippet(undefined);
3112
+ setAgentPromptSelectionContext(undefined);
3113
+ setAgentModalAnchorPoint(null);
3026
3114
  void preloadAgentPromptSnippet(domEditSelection);
3027
3115
  setAgentModalOpen(true);
3028
3116
  }, [domEditSelection, preloadAgentPromptSnippet]);
@@ -3037,6 +3125,7 @@ export function StudioApp() {
3037
3125
  selection: domEditSelection,
3038
3126
  currentTime,
3039
3127
  tagSnippet,
3128
+ selectionContext: agentPromptSelectionContext,
3040
3129
  userInstruction,
3041
3130
  sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
3042
3131
  });
@@ -3048,11 +3137,21 @@ export function StudioApp() {
3048
3137
  }
3049
3138
 
3050
3139
  setAgentModalOpen(false);
3140
+ setAgentPromptSelectionContext(undefined);
3141
+ setAgentModalAnchorPoint(null);
3051
3142
  if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3052
3143
  setCopiedAgentPrompt(true);
3053
3144
  copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
3054
3145
  },
3055
- [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
3146
+ [
3147
+ activeCompPath,
3148
+ agentPromptSelectionContext,
3149
+ agentPromptTagSnippet,
3150
+ currentTime,
3151
+ domEditSelection,
3152
+ projectDir,
3153
+ showToast,
3154
+ ],
3056
3155
  );
3057
3156
 
3058
3157
  const handlePreviewIframeRef = useCallback(
@@ -3070,9 +3169,9 @@ export function StudioApp() {
3070
3169
 
3071
3170
  const handlePreviewCanvasMouseDown = useCallback(
3072
3171
  (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3073
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) return;
3172
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode) return;
3074
3173
  const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3075
- preferClipAncestor: options?.preferClipAncestor ?? true,
3174
+ preferClipAncestor: options?.preferClipAncestor ?? false,
3076
3175
  });
3077
3176
  if (!nextSelection) {
3078
3177
  if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
@@ -3080,14 +3179,34 @@ export function StudioApp() {
3080
3179
  }
3081
3180
  e.preventDefault();
3082
3181
  e.stopPropagation();
3182
+ const localPointer = previewIframeRef.current
3183
+ ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
3184
+ : null;
3083
3185
  applyDomSelection(nextSelection, { additive: e.shiftKey });
3186
+ if (
3187
+ !e.shiftKey &&
3188
+ localPointer &&
3189
+ isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
3190
+ ) {
3191
+ setAgentPromptSelectionContext(
3192
+ buildRasterClickSelectionContext(nextSelection, localPointer),
3193
+ );
3194
+ setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
3195
+ void preloadAgentPromptSnippet(nextSelection);
3196
+ setAgentModalOpen(true);
3197
+ }
3084
3198
  },
3085
- [applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
3199
+ [
3200
+ applyDomSelection,
3201
+ captionEditMode,
3202
+ preloadAgentPromptSnippet,
3203
+ resolveDomSelectionFromPreviewPoint,
3204
+ ],
3086
3205
  );
3087
3206
 
3088
3207
  const handlePreviewCanvasPointerMove = useCallback(
3089
3208
  (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3090
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) {
3209
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode) {
3091
3210
  updateDomEditHoverSelection(null);
3092
3211
  return null;
3093
3212
  }
@@ -3965,8 +4084,6 @@ export function StudioApp() {
3965
4084
  onInspectTimelineElement={handleTimelineElementInspect}
3966
4085
  inspectedTimelineElementId={inspectedTimelineElementId}
3967
4086
  timelineLayerChildCounts={timelineLayerChildCounts}
3968
- thumbnailedTimelineElementIds={thumbnailedTimelineElementIds}
3969
- onToggleTimelineElementThumbnail={handleToggleTimelineElementThumbnail}
3970
4087
  onCompIdToSrcChange={setCompIdToSrc}
3971
4088
  onCompositionChange={(compPath) => {
3972
4089
  // Sync activeCompPath when user drills down via timeline double-click
@@ -3984,7 +4101,7 @@ export function StudioApp() {
3984
4101
  iframeRef={previewIframeRef}
3985
4102
  activeCompositionPath={activeCompPath}
3986
4103
  hoverSelection={
3987
- STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !captionEditMode
4104
+ STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
3988
4105
  ? domEditHoverSelection
3989
4106
  : null
3990
4107
  }
@@ -4090,21 +4207,18 @@ export function StudioApp() {
4090
4207
  <div className="min-h-0 flex-1">
4091
4208
  {designPanelActive ? (
4092
4209
  <PropertyPanel
4093
- projectId={projectId}
4094
- assets={assets}
4095
4210
  element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4211
+ multiSelectCount={domEditGroupSelections.length}
4096
4212
  copiedAgentPrompt={copiedAgentPrompt}
4097
4213
  onClearSelection={clearDomSelection}
4098
4214
  onSetStyle={handleDomStyleCommit}
4099
4215
  onSetManualOffset={handleDomPathOffsetCommit}
4100
4216
  onSetManualSize={handleDomBoxSizeCommit}
4217
+ onSetRotation={handleDomRotationCommit}
4101
4218
  onSetText={handleDomTextCommit}
4102
4219
  onSetTextFieldStyle={handleDomTextFieldStyleCommit}
4103
- onAddTextField={handleDomAddTextField}
4104
- onRemoveTextField={handleDomRemoveTextField}
4105
4220
  onResetManualEdits={handleDomManualEditsReset}
4106
4221
  onAskAgent={handleAskAgent}
4107
- onImportAssets={handleImportFiles}
4108
4222
  fontAssets={fontAssets}
4109
4223
  onImportFonts={handleImportFonts}
4110
4224
  />
@@ -4122,9 +4236,9 @@ export function StudioApp() {
4122
4236
  projectId={projectId}
4123
4237
  onDelete={renderQueue.deleteRender}
4124
4238
  onClearCompleted={renderQueue.clearCompleted}
4125
- onStartRender={async (format, quality) => {
4239
+ onStartRender={async (format, quality, resolution, fps) => {
4126
4240
  await waitForPendingDomEditSaves();
4127
- await renderQueue.startRender(30, quality, format);
4241
+ await renderQueue.startRender({ fps, quality, format, resolution });
4128
4242
  }}
4129
4243
  isRendering={renderQueue.isRendering}
4130
4244
  />
@@ -4155,8 +4269,13 @@ export function StudioApp() {
4155
4269
  {agentModalOpen && domEditSelection && (
4156
4270
  <AskAgentModal
4157
4271
  selectionLabel={domEditSelection.label}
4272
+ anchorPoint={agentModalAnchorPoint}
4158
4273
  onSubmit={handleAgentModalSubmit}
4159
- onClose={() => setAgentModalOpen(false)}
4274
+ onClose={() => {
4275
+ setAgentModalOpen(false);
4276
+ setAgentPromptSelectionContext(undefined);
4277
+ setAgentModalAnchorPoint(null);
4278
+ }}
4160
4279
  />
4161
4280
  )}
4162
4281
 
@@ -4,8 +4,10 @@ import {
4
4
  buildStrokeWidthStyleUpdates,
5
5
  getClipPathInsetPx,
6
6
  getCssFilterFunctionPx,
7
+ getPropertyPanelVisibleSections,
7
8
  inferBoxShadowPreset,
8
9
  inferClipPathPreset,
10
+ isPropertyPanelMediaLikeSelection,
9
11
  normalizePanelPxValue,
10
12
  setCssFilterFunctionPx,
11
13
  } from "./PropertyPanel";
@@ -64,4 +66,51 @@ describe("PropertyPanel style helpers", () => {
64
66
  expect(buildStrokeStyleUpdates("none", "4px")).toEqual([["border-style", "none"]]);
65
67
  expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
66
68
  });
69
+
70
+ it("orders the simplified default inspector sections around high-confidence edits", () => {
71
+ expect(
72
+ getPropertyPanelVisibleSections({
73
+ hasSelection: true,
74
+ canEditStyles: true,
75
+ hasTextControls: true,
76
+ hasColorControls: true,
77
+ }),
78
+ ).toEqual(["Text", "Layout", "Colors", "Radius", "Shadow"]);
79
+
80
+ expect(
81
+ getPropertyPanelVisibleSections({
82
+ hasSelection: true,
83
+ canEditStyles: true,
84
+ hasTextControls: false,
85
+ hasColorControls: false,
86
+ }),
87
+ ).toEqual(["Layout", "Radius", "Shadow"]);
88
+ });
89
+
90
+ it("treats media tags and background-image layers as image-like controls", () => {
91
+ expect(
92
+ isPropertyPanelMediaLikeSelection({
93
+ tagName: "img",
94
+ styles: {},
95
+ }),
96
+ ).toBe(true);
97
+
98
+ expect(
99
+ isPropertyPanelMediaLikeSelection({
100
+ tagName: "div",
101
+ styles: {
102
+ "background-image": "url(/assets/studio.png)",
103
+ },
104
+ }),
105
+ ).toBe(true);
106
+
107
+ expect(
108
+ isPropertyPanelMediaLikeSelection({
109
+ tagName: "div",
110
+ styles: {
111
+ "background-image": "none",
112
+ },
113
+ }),
114
+ ).toBe(false);
115
+ });
67
116
  });