@hyperframes/studio 0.8.47 → 0.8.48

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 (95) hide show
  1. package/dist/assets/{hyperframes-player-B7fsB8C2.js → hyperframes-player-CtVX6jL_.js} +1 -1
  2. package/dist/assets/{index-ryL7q1QG.js → index-B3-orIjW.js} +1 -1
  3. package/dist/assets/index-B9LfWNyM.css +1 -0
  4. package/dist/assets/index-C9gJKpqQ.js +441 -0
  5. package/dist/assets/{index-B50xQfSG.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +66 -23
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1015 -621
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  19. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  20. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  21. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  22. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  23. package/src/components/editor/useVolumeAutomation.ts +23 -2
  24. package/src/components/panels/VariablesPanel.tsx +5 -8
  25. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  27. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  28. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  29. package/src/contexts/FileManagerContext.tsx +0 -3
  30. package/src/hooks/appHotkeysDispatch.ts +256 -0
  31. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  32. package/src/hooks/domEditDeleteMembers.ts +19 -0
  33. package/src/hooks/useActiveComposition.ts +69 -0
  34. package/src/hooks/useAppHotkeys.test.ts +23 -1
  35. package/src/hooks/useAppHotkeys.ts +6 -239
  36. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  37. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  38. package/src/hooks/useDomEditCommits.ts +13 -15
  39. package/src/hooks/useDomEditSession.test.tsx +90 -1
  40. package/src/hooks/useDomEditSession.ts +31 -34
  41. package/src/hooks/useDomSelectionTypes.ts +7 -2
  42. package/src/hooks/useElementLifecycleOps.ts +0 -8
  43. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  44. package/src/hooks/useFileManager.ts +0 -2
  45. package/src/hooks/useFileTree.test.tsx +214 -0
  46. package/src/hooks/useFileTree.ts +66 -24
  47. package/src/hooks/useGroupCommits.test.ts +98 -0
  48. package/src/hooks/useGroupCommits.ts +66 -9
  49. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  50. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  51. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  52. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  53. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  54. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  55. package/src/hooks/useTimelineEditing.ts +1 -0
  56. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  57. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  58. package/src/index.ts +10 -0
  59. package/src/player/components/Timeline.test.ts +0 -4
  60. package/src/player/components/Timeline.tsx +0 -1
  61. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  62. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  63. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  64. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  65. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  66. package/src/player/components/automationLaneData.test.ts +11 -3
  67. package/src/player/components/automationLaneData.ts +5 -4
  68. package/src/player/components/automationLaneGeometry.ts +15 -0
  69. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  70. package/src/player/components/timelineDragDrop.ts +9 -25
  71. package/src/player/components/timelineGapCommit.test.ts +96 -0
  72. package/src/player/components/timelineGapCommit.ts +56 -11
  73. package/src/player/components/timelineGaps.ts +2 -2
  74. package/src/player/components/timelineLayout.test.ts +0 -1
  75. package/src/player/components/timelineLayout.ts +1 -6
  76. package/src/player/components/timelineZones.test.ts +20 -1
  77. package/src/player/components/timelineZones.ts +10 -3
  78. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  79. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  80. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  81. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  82. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  83. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  84. package/src/player/store/playerStore.ts +16 -21
  85. package/src/utils/razorSplitTransaction.test.ts +10 -0
  86. package/src/utils/razorSplitTransaction.ts +5 -0
  87. package/src/utils/sdkCutover.test.ts +51 -0
  88. package/src/utils/studioHelpers.test.ts +18 -0
  89. package/src/utils/studioHelpers.ts +41 -5
  90. package/src/utils/studioUiPreferences.test.ts +14 -0
  91. package/src/utils/studioUiPreferences.ts +6 -0
  92. package/src/utils/studioUrlState.ts +15 -7
  93. package/src/utils/timelineInspector.ts +10 -0
  94. package/dist/assets/index-DnRfAiK2.css +0 -1
  95. package/dist/assets/index-j-4hCrZU.js +0 -441
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.47",
3
+ "version": "0.8.48",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.47",
52
- "@hyperframes/sdk": "0.8.47",
53
- "@hyperframes/parsers": "0.8.47",
54
- "@hyperframes/studio-server": "0.8.47",
55
- "@hyperframes/player": "0.8.47"
51
+ "@hyperframes/core": "0.8.48",
52
+ "@hyperframes/parsers": "0.8.48",
53
+ "@hyperframes/player": "0.8.48",
54
+ "@hyperframes/studio-server": "0.8.48",
55
+ "@hyperframes/sdk": "0.8.48"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^4.1.11",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.47"
72
+ "@hyperframes/producer": "0.8.48"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
package/src/App.tsx CHANGED
@@ -36,7 +36,6 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
36
36
  import { useLintModal } from "./hooks/useLintModal";
37
37
  import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
38
38
  import { useToast } from "./hooks/useToast";
39
- import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
40
39
  import { useStudioUrlState } from "./hooks/useStudioUrlState";
41
40
  import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
42
41
  import {
@@ -63,7 +62,7 @@ import { useServerConnection } from "./hooks/useServerConnection";
63
62
  import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
64
63
  import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
65
64
  import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
66
- import { useHydrateActiveCompPathFromUrl } from "./hooks/useHydrateActiveCompPathFromUrl";
65
+ import { useActiveComposition } from "./hooks/useActiveComposition";
67
66
  const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
68
67
  // fallow-ignore-next-line complexity
69
68
  export function StudioApp() {
@@ -71,10 +70,6 @@ export function StudioApp() {
71
70
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
72
71
  const viewModeValue = useViewModeState();
73
72
  useStudioSessionStart(projectId, resolving, waitingForServer);
74
- const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
75
- const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
76
- () => initialUrlStateRef.current.activeCompPath == null,
77
- );
78
73
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
79
74
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
80
75
  const [compositionLoading, setCompositionLoading] = useState(true);
@@ -82,10 +77,7 @@ export function StudioApp() {
82
77
  const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
83
78
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
84
79
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
85
- const activeCompPathRef = useRef(activeCompPath);
86
- activeCompPathRef.current = activeCompPath;
87
80
  const leftSidebarRef = useRef<LeftSidebarHandle>(null);
88
- const renderQueue = useRenderQueue(projectId, activeCompPathRef);
89
81
  const captionEditMode = useCaptionStore((s) => s.isEditMode);
90
82
  const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
91
83
  const captionSync = useCaptionSync(projectId);
@@ -115,22 +107,27 @@ export function StudioApp() {
115
107
  setRefreshKey,
116
108
  });
117
109
  const masterCompPath = useMemo(
118
- () => resolveMasterCompositionPath(fileManager.fileTree),
119
- [fileManager.fileTree],
110
+ () => resolveMasterCompositionPath(fileManager.compositions),
111
+ [fileManager.compositions],
120
112
  );
113
+ const { activeCompPath, activeCompPathHydrated, setActiveCompPath, handleSelectComposition } =
114
+ useActiveComposition({
115
+ projectId,
116
+ initialUrlStateRef,
117
+ fileTree: fileManager.fileTree,
118
+ fileTreeLoaded: fileManager.fileTreeLoaded,
119
+ masterCompPath,
120
+ setEditingFile: fileManager.setEditingFile,
121
+ showToast,
122
+ });
121
123
  const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
122
124
  projectId,
123
125
  activeCompPath,
124
126
  masterCompPath,
125
127
  );
126
- useHydrateActiveCompPathFromUrl({
127
- hydrated: activeCompPathHydrated,
128
- fileTreeLoaded: fileManager.fileTreeLoaded,
129
- fileTree: fileManager.fileTree,
130
- initialUrlStateRef,
131
- setActiveCompPath,
132
- setHydrated: setActiveCompPathHydrated,
133
- });
128
+ const activeCompPathRef = useRef(activeCompPath);
129
+ activeCompPathRef.current = activeCompPath;
130
+ const renderQueue = useRenderQueue(projectId, activeCompPathRef);
134
131
  const previewPersistence = usePreviewPersistence({
135
132
  showToast,
136
133
  readOptionalProjectFile: fileManager.readOptionalProjectFile,
@@ -295,6 +292,7 @@ export function StudioApp() {
295
292
  sdkSession: editFlowSdkSession,
296
293
  publishSdkSession: sdkHandle.publish,
297
294
  forceReloadSdkSession: sdkHandle.forceReload,
295
+ handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
298
296
  });
299
297
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
300
298
  handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
@@ -372,13 +370,6 @@ export function StudioApp() {
372
370
  },
373
371
  [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
374
372
  );
375
- const { setEditingFile } = fileManager;
376
- const handleSelectComposition = useCompositionContentLoader({
377
- projectId,
378
- setEditingFile,
379
- setActiveCompPath,
380
- showToast,
381
- });
382
373
  const {
383
374
  designPanelActive,
384
375
  inspectorPanelActive,
@@ -25,19 +25,21 @@ function getAgentModalPositionStyle(
25
25
  return { left, top, transform: "translateX(-50%)" };
26
26
  }
27
27
 
28
+ export interface AskAgentModalProps {
29
+ selectionLabel: string;
30
+ contextPreview?: string;
31
+ anchorPoint?: AgentModalAnchorPoint | null;
32
+ onSubmit: (instruction: string) => void;
33
+ onClose: () => void;
34
+ }
35
+
28
36
  export function AskAgentModal({
29
37
  selectionLabel,
30
38
  contextPreview,
31
39
  anchorPoint = null,
32
40
  onSubmit,
33
41
  onClose,
34
- }: {
35
- selectionLabel: string;
36
- contextPreview?: string;
37
- anchorPoint?: AgentModalAnchorPoint | null;
38
- onSubmit: (instruction: string) => void;
39
- onClose: () => void;
40
- }) {
42
+ }: AskAgentModalProps) {
41
43
  const [value, setValue] = useState("");
42
44
  const inputRef = useRef<HTMLTextAreaElement>(null);
43
45
  const containerRef = useRef<HTMLDivElement>(null);
@@ -1,5 +1,11 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
2
+ import {
3
+ Image,
4
+ Magnet,
5
+ MagnifyingGlassMinus,
6
+ MagnifyingGlassPlus,
7
+ Waves,
8
+ } from "@phosphor-icons/react";
3
9
  import {
4
10
  useEnableKeyframes,
5
11
  isPlayheadWithinTween,
@@ -126,6 +132,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
126
132
  const setActiveTool = usePlayerStore((s) => s.setActiveTool);
127
133
  const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
128
134
  const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
135
+ const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled);
136
+ const setRippleEditEnabled = usePlayerStore((s) => s.setRippleEditEnabled);
129
137
  const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
130
138
  const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
131
139
  const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
@@ -228,6 +236,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
228
236
  <Magnet size={16} weight="bold" aria-hidden="true" />
229
237
  </button>
230
238
  </Tooltip>
239
+ <Tooltip
240
+ label={
241
+ rippleEditEnabled
242
+ ? "Ripple on — keeps the main track gapless"
243
+ : "Ripple off — deleting a main-track clip leaves a gap"
244
+ }
245
+ >
246
+ <button
247
+ type="button"
248
+ onClick={() => setRippleEditEnabled(!rippleEditEnabled)}
249
+ aria-label="Toggle ripple edit"
250
+ aria-pressed={rippleEditEnabled}
251
+ className={rippleEditEnabled ? flatActive : flatIdle}
252
+ >
253
+ <Waves size={16} weight="bold" aria-hidden="true" />
254
+ </button>
255
+ </Tooltip>
231
256
  {/* Always rendered (CapCut-style): with no keyframeable selection the
232
257
  button fades to a disabled state instead of unmounting, so the
233
258
  toolbar layout never shifts. */}
@@ -285,17 +285,17 @@ export function PropertyPanelFlat({
285
285
  }
286
286
  : null;
287
287
  const audioSelection = isAudioDomElement(element.element);
288
- // Handlers being wired is necessary but not sufficient: App.tsx always passes
289
- // them, so this alone showed the tween editor for every selection — including
290
- // an `<audio>` clip and an `<hf-audio-group>` bus, neither of which has a
291
- // transform, an opacity or a box for a tween to move. Gated on the TAG, not on
292
- // `sections.animation` (`animationCount > 0`): a div with no tweens yet must
293
- // still offer "+ Add", so "has none" and "can have none" are different
294
- // questions and only the second one belongs here.
288
+ // Gated on the tag, not `sections.animation` (`animationCount > 0`): an audio
289
+ // clip/bus has no tween to move, but a fresh div with no tweens yet must
290
+ // still offer "+ Add" — "has none" and "can have none" differ.
295
291
  const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
296
292
  const showMotionGroup = showMotionTiming || showMotionEffects;
297
293
 
298
- const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
294
+ const volumeAutomation = useVolumeAutomation(
295
+ element,
296
+ currentTime,
297
+ onSetAttributeQuiet ?? onSetAttributeLive,
298
+ );
299
299
 
300
300
  // The group this clip belongs to, if any — the Audio FX summary reads
301
301
  // "in Voiceover" for a member (see `audioFxSummary`). Membership lives on the
@@ -45,4 +45,10 @@ export {
45
45
  } from "./domEditingLayers";
46
46
 
47
47
  // Agent prompt
48
- export { buildElementAgentPrompt } from "./domEditingAgentPrompt";
48
+ export {
49
+ buildElementAgentPrompt,
50
+ buildAgentContextPreview,
51
+ buildPickerAgentPrompt,
52
+ buildPickerAgentContextPreview,
53
+ } from "./domEditingAgentPrompt";
54
+ export type { AgentPromptElementInfo } from "./domEditingAgentPrompt";
@@ -0,0 +1,199 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { HyperframePickerElementInfo } from "@hyperframes/core";
5
+ import {
6
+ buildAgentContextPreview,
7
+ buildElementAgentPrompt,
8
+ buildPickerAgentContextPreview,
9
+ buildPickerAgentPrompt,
10
+ } from "./domEditingAgentPrompt";
11
+ import type { DomEditSelection } from "./domEditingTypes";
12
+
13
+ function makeSelection(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
14
+ return {
15
+ element: document.createElement("div"),
16
+ label: "Headline",
17
+ tagName: "div",
18
+ sourceFile: "hero.html",
19
+ compositionPath: "hero.html",
20
+ isCompositionHost: false,
21
+ isInsideLockedComposition: false,
22
+ id: "headline-1",
23
+ selector: "#headline-1",
24
+ selectorIndex: 0,
25
+ boundingBox: { x: 10, y: 20, width: 300, height: 40 },
26
+ textContent: "Hello world",
27
+ dataAttributes: {},
28
+ inlineStyles: {},
29
+ computedStyles: {},
30
+ textFields: [],
31
+ capabilities: {
32
+ canSelect: true,
33
+ canEditStyles: true,
34
+ canCrop: false,
35
+ canMove: true,
36
+ canResize: true,
37
+ canApplyManualOffset: false,
38
+ canApplyManualSize: false,
39
+ canApplyManualRotation: false,
40
+ },
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ const PICKER_SELECTION: HyperframePickerElementInfo = {
46
+ id: "headline-1",
47
+ tagName: "div",
48
+ selector: "#headline-1",
49
+ label: "Headline",
50
+ boundingBox: { x: 10, y: 20, width: 300, height: 40 },
51
+ textContent: "Hello world",
52
+ src: null,
53
+ dataAttributes: {},
54
+ };
55
+
56
+ describe("buildElementAgentPrompt", () => {
57
+ it("produces the documented v1 schema", () => {
58
+ const prompt = buildElementAgentPrompt({
59
+ selection: makeSelection(),
60
+ currentTime: 1.5,
61
+ userInstruction: "Make this bigger",
62
+ });
63
+ expect(prompt).toBe(
64
+ [
65
+ "## HyperFrames element edit request v1",
66
+ "Schema version: 1",
67
+ "",
68
+ "Make this bigger",
69
+ "",
70
+ "Composition: hero.html",
71
+ "Playback time: 00:01",
72
+ "Source file: hero.html",
73
+ "DOM id: headline-1",
74
+ "Selector: #headline-1",
75
+ "Selector index: 0",
76
+ "Tag: <div>",
77
+ "Bounds: x=10, y=20, width=300, height=40",
78
+ "Text: Hello world",
79
+ "",
80
+ "Guardrails:",
81
+ "- Make a targeted change to this element only.",
82
+ "- Preserve the rest of the composition and its timing.",
83
+ "- Do not modify other elements' data-* attributes or positioning.",
84
+ "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
85
+ ].join("\n"),
86
+ );
87
+ });
88
+
89
+ it("includes text fields, inline styles, computed styles and a target snippet when present", () => {
90
+ const prompt = buildElementAgentPrompt({
91
+ selection: makeSelection({
92
+ inlineStyles: { color: "red" },
93
+ computedStyles: { "font-size": "16px" },
94
+ textFields: [
95
+ {
96
+ key: "title",
97
+ label: "Title",
98
+ value: "Hello",
99
+ tagName: "h1",
100
+ attributes: [],
101
+ inlineStyles: {},
102
+ computedStyles: {},
103
+ source: "self",
104
+ },
105
+ ],
106
+ }),
107
+ currentTime: 0,
108
+ tagSnippet: "<div id='headline-1'>Hello world</div>",
109
+ selectionContext: "Nested inside the hero card",
110
+ });
111
+ expect(prompt).toContain("Selection context:\nNested inside the hero card");
112
+ expect(prompt).toContain('Text fields:\n- key=title; tag=<h1>; source=self; text="Hello"');
113
+ expect(prompt).toContain("Inline styles:\ncolor: red");
114
+ expect(prompt).toContain("Computed styles (browser-resolved):\nfont-size: 16px");
115
+ expect(prompt).toContain("Target HTML:\n<div id='headline-1'>Hello world</div>");
116
+ });
117
+ });
118
+
119
+ describe("buildAgentContextPreview", () => {
120
+ it("summarizes composition, source, selector, tag and text", () => {
121
+ expect(buildAgentContextPreview(makeSelection(), "hero.html")).toBe(
122
+ [
123
+ "Composition: hero.html",
124
+ "Source: hero.html",
125
+ "Selector: #headline-1 Tag: <div>",
126
+ "Text: Hello world",
127
+ ].join("\n"),
128
+ );
129
+ });
130
+ });
131
+
132
+ describe("buildPickerAgentPrompt", () => {
133
+ it("produces a plain comment prompt when nothing is selected", () => {
134
+ const prompt = buildPickerAgentPrompt({ selection: null, userInstruction: "Add a border" });
135
+ expect(prompt).toBe(
136
+ ["## HyperFrames element edit request v1", "Schema version: 1", "", "Add a border"].join(
137
+ "\n",
138
+ ),
139
+ );
140
+ });
141
+
142
+ it("produces identical shared-field text to buildElementAgentPrompt for equivalent input", () => {
143
+ const domPrompt = buildElementAgentPrompt({
144
+ selection: makeSelection({ compositionPath: "", sourceFile: "" }),
145
+ currentTime: 0,
146
+ });
147
+ const pickerPrompt = buildPickerAgentPrompt({ selection: PICKER_SELECTION });
148
+
149
+ const sharedFields = [
150
+ "DOM id: headline-1",
151
+ "Selector: #headline-1",
152
+ "Tag: <div>",
153
+ "Bounds: x=10, y=20, width=300, height=40",
154
+ "Text: Hello world",
155
+ ];
156
+ for (const line of sharedFields) {
157
+ expect(domPrompt).toContain(line);
158
+ expect(pickerPrompt).toContain(line);
159
+ }
160
+ // Both end on the identical guardrails block, byte for byte.
161
+ const guardrails = [
162
+ "Guardrails:",
163
+ "- Make a targeted change to this element only.",
164
+ "- Preserve the rest of the composition and its timing.",
165
+ "- Do not modify other elements' data-* attributes or positioning.",
166
+ "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
167
+ ].join("\n");
168
+ expect(domPrompt.endsWith(guardrails)).toBe(true);
169
+ expect(pickerPrompt.endsWith(guardrails)).toBe(true);
170
+ });
171
+
172
+ it("surfaces the picker's label and media src — a host app has no DOM to read them from otherwise", () => {
173
+ const prompt = buildPickerAgentPrompt({
174
+ selection: { ...PICKER_SELECTION, tagName: "img", src: "assets/hero.png" },
175
+ });
176
+ expect(prompt).toContain("Label: Headline");
177
+ expect(prompt).toContain("Source (media): assets/hero.png");
178
+ });
179
+
180
+ it("omits the label and source lines when the picker didn't report them", () => {
181
+ const prompt = buildPickerAgentPrompt({
182
+ selection: { ...PICKER_SELECTION, label: "", src: null },
183
+ });
184
+ expect(prompt).not.toContain("Label:");
185
+ expect(prompt).not.toContain("Source (media):");
186
+ });
187
+ });
188
+
189
+ describe("buildPickerAgentContextPreview", () => {
190
+ it("returns an empty string when nothing is selected", () => {
191
+ expect(buildPickerAgentContextPreview(null)).toBe("");
192
+ });
193
+
194
+ it("matches buildAgentContextPreview's selector/tag/text line format", () => {
195
+ expect(buildPickerAgentContextPreview(PICKER_SELECTION)).toBe(
196
+ ["Selector: #headline-1 Tag: <div>", "Text: Hello world"].join("\n"),
197
+ );
198
+ });
199
+ });
@@ -1,10 +1,40 @@
1
1
  /**
2
2
  * Agent prompt builder for HyperFrames element edit requests.
3
3
  */
4
+ import type { HyperframePickerElementInfo } from "@hyperframes/core";
4
5
  import { formatTime } from "../../player/lib/time";
5
6
  import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
6
7
 
7
- function formatBoundingBox(bounds: DomEditSelection["boundingBox"]): string {
8
+ /**
9
+ * The subset of an element selection shared by the Studio DOM editor (`DomEditSelection`) and the
10
+ * runtime picker (`HyperframePickerElementInfo`); a field absent from a caller's type is simply omitted.
11
+ */
12
+ export interface AgentPromptElementInfo {
13
+ id: string | null;
14
+ selector?: string | null;
15
+ /** Disambiguates duplicate selectors in `DomEditSelection`. The picker
16
+ * payload has no such concept (one picked node, not a candidate list), so
17
+ * a picker-built prompt always prints index 0 — expected, not a gap. */
18
+ selectorIndex?: number;
19
+ tagName: string;
20
+ label?: string;
21
+ boundingBox: { x: number; y: number; width: number; height: number };
22
+ textContent: string | null;
23
+ src?: string | null;
24
+ textFields?: DomEditTextField[];
25
+ inlineStyles?: Record<string, string>;
26
+ computedStyles?: Record<string, string>;
27
+ }
28
+
29
+ const GUARDRAIL_LINES = [
30
+ "Guardrails:",
31
+ "- Make a targeted change to this element only.",
32
+ "- Preserve the rest of the composition and its timing.",
33
+ "- Do not modify other elements' data-* attributes or positioning.",
34
+ "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
35
+ ];
36
+
37
+ function formatBoundingBox(bounds: AgentPromptElementInfo["boundingBox"]): string {
8
38
  return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`;
9
39
  }
10
40
 
@@ -24,6 +54,41 @@ function formatTextFields(fields: DomEditTextField[]): string {
24
54
  .join("\n");
25
55
  }
26
56
 
57
+ function formatSelectorTagLine(info: Pick<AgentPromptElementInfo, "selector" | "tagName">): string {
58
+ return `Selector: ${info.selector ?? "(none)"} Tag: <${info.tagName}>`;
59
+ }
60
+
61
+ function formatTextLine(textContent: string | null): string {
62
+ return textContent ? `Text: ${textContent}` : "";
63
+ }
64
+
65
+ /** Core identity fields, plus the text line when the element has text content. */
66
+ function buildElementInfoLines(info: AgentPromptElementInfo): string[] {
67
+ const lines = [
68
+ `DOM id: ${info.id ?? "(none)"}`,
69
+ `Selector: ${info.selector ?? "(none)"}`,
70
+ `Selector index: ${info.selectorIndex ?? 0}`,
71
+ `Tag: <${info.tagName}>`,
72
+ ];
73
+ if (info.label) lines.push(`Label: ${info.label}`);
74
+ lines.push(`Bounds: ${formatBoundingBox(info.boundingBox)}`);
75
+ if (info.src) lines.push(`Source (media): ${info.src}`);
76
+ if (info.textContent) lines.push(`Text: ${info.textContent}`);
77
+ return lines;
78
+ }
79
+
80
+ /** Text fields, inline styles, computed styles — present when the caller supplies them. */
81
+ function buildElementDetailLines(info: AgentPromptElementInfo): string[] {
82
+ const lines: string[] = [];
83
+ const textFieldsBlock = info.textFields ? formatTextFields(info.textFields) : "";
84
+ if (textFieldsBlock) lines.push("", "Text fields:", textFieldsBlock);
85
+ const inlineStyleBlock = info.inlineStyles ? formatStyleBlock(info.inlineStyles) : "";
86
+ if (inlineStyleBlock) lines.push("", "Inline styles:", inlineStyleBlock);
87
+ const computedStyleBlock = info.computedStyles ? formatStyleBlock(info.computedStyles) : "";
88
+ if (computedStyleBlock) lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
89
+ return lines;
90
+ }
91
+
27
92
  export function buildElementAgentPrompt({
28
93
  selection,
29
94
  currentTime,
@@ -40,6 +105,18 @@ export function buildElementAgentPrompt({
40
105
  sourceFilePath?: string;
41
106
  }): string {
42
107
  const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
108
+ const info: AgentPromptElementInfo = {
109
+ id: selection.id ?? null,
110
+ selector: selection.selector,
111
+ selectorIndex: selection.selectorIndex,
112
+ tagName: selection.tagName,
113
+ boundingBox: selection.boundingBox,
114
+ textContent: selection.textContent,
115
+ textFields: selection.textFields,
116
+ inlineStyles: selection.inlineStyles,
117
+ computedStyles: selection.computedStyles,
118
+ };
119
+
43
120
  const lines = [
44
121
  "## HyperFrames element edit request v1",
45
122
  "Schema version: 1",
@@ -49,53 +126,31 @@ export function buildElementAgentPrompt({
49
126
  `Composition: ${selection.compositionPath}`,
50
127
  `Playback time: ${formatTime(currentTime)}`,
51
128
  `Source file: ${displayedSourceFile}`,
52
- `DOM id: ${selection.id ?? "(none)"}`,
53
- `Selector: ${selection.selector ?? "(none)"}`,
54
- `Selector index: ${selection.selectorIndex ?? 0}`,
55
- `Tag: <${selection.tagName}>`,
56
- `Bounds: ${formatBoundingBox(selection.boundingBox)}`,
129
+ ...buildElementInfoLines(info),
57
130
  ];
58
131
 
59
- if (selection.textContent) {
60
- lines.push(`Text: ${selection.textContent}`);
61
- }
62
-
63
132
  const trimmedSelectionContext = selectionContext?.trim();
64
133
  if (trimmedSelectionContext) {
65
134
  lines.push("", "Selection context:", trimmedSelectionContext);
66
135
  }
67
136
 
68
- const textFieldsBlock = formatTextFields(selection.textFields);
69
- if (textFieldsBlock) {
70
- lines.push("", "Text fields:", textFieldsBlock);
71
- }
72
-
73
- const inlineStyleBlock = formatStyleBlock(selection.inlineStyles);
74
- if (inlineStyleBlock) {
75
- lines.push("", "Inline styles:", inlineStyleBlock);
76
- }
77
-
78
- const computedStyleBlock = formatStyleBlock(selection.computedStyles);
79
- if (computedStyleBlock) {
80
- lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
81
- }
137
+ lines.push(...buildElementDetailLines(info));
82
138
 
83
139
  if (tagSnippet) {
84
140
  lines.push("", "Target HTML:", tagSnippet);
85
141
  }
86
142
 
87
- lines.push(
88
- "",
89
- "Guardrails:",
90
- "- Make a targeted change to this element only.",
91
- "- Preserve the rest of the composition and its timing.",
92
- "- Do not modify other elements' data-* attributes or positioning.",
93
- "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
94
- );
143
+ lines.push("", ...GUARDRAIL_LINES);
95
144
 
96
145
  return lines.join("\n");
97
146
  }
98
147
 
148
+ function buildSelectorAndTextLines(
149
+ info: Pick<AgentPromptElementInfo, "selector" | "tagName" | "textContent">,
150
+ ): string[] {
151
+ return [formatSelectorTagLine(info), formatTextLine(info.textContent)];
152
+ }
153
+
99
154
  export function buildAgentContextPreview(
100
155
  selection: DomEditSelection,
101
156
  activeCompPath: string | null,
@@ -103,9 +158,56 @@ export function buildAgentContextPreview(
103
158
  return [
104
159
  `Composition: ${selection.compositionPath}`,
105
160
  `Source: ${selection.sourceFile || activeCompPath || "index.html"}`,
106
- `Selector: ${selection.selector ?? "(none)"} Tag: <${selection.tagName}>`,
107
- selection.textContent ? `Text: ${selection.textContent}` : "",
161
+ ...buildSelectorAndTextLines(selection),
108
162
  ]
109
163
  .filter(Boolean)
110
164
  .join("\n");
111
165
  }
166
+
167
+ /**
168
+ * Same convention as `buildElementAgentPrompt`, for a host app embedding only the player: built from
169
+ * the runtime picker's own payload, not `DomEditSelection`. Null selection yields a bare prompt, no guardrails.
170
+ */
171
+ export function buildPickerAgentPrompt({
172
+ selection,
173
+ userInstruction,
174
+ }: {
175
+ selection: HyperframePickerElementInfo | null;
176
+ userInstruction?: string;
177
+ }): string {
178
+ const lines = [
179
+ "## HyperFrames element edit request v1",
180
+ "Schema version: 1",
181
+ "",
182
+ userInstruction?.trim() || "Edit this selected HyperFrames element.",
183
+ ];
184
+
185
+ if (!selection) return lines.join("\n");
186
+
187
+ const info: AgentPromptElementInfo = {
188
+ id: selection.id,
189
+ selector: selection.selector,
190
+ tagName: selection.tagName,
191
+ label: selection.label,
192
+ boundingBox: selection.boundingBox,
193
+ textContent: selection.textContent,
194
+ src: selection.src,
195
+ };
196
+
197
+ lines.push(
198
+ "",
199
+ ...buildElementInfoLines(info),
200
+ ...buildElementDetailLines(info),
201
+ "",
202
+ ...GUARDRAIL_LINES,
203
+ );
204
+
205
+ return lines.join("\n");
206
+ }
207
+
208
+ export function buildPickerAgentContextPreview(
209
+ selection: HyperframePickerElementInfo | null,
210
+ ): string {
211
+ if (!selection) return "";
212
+ return buildSelectorAndTextLines(selection).filter(Boolean).join("\n");
213
+ }