@hyperframes/studio 0.5.0-alpha.6 → 0.5.0-alpha.8

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/dist/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-CDSQavT7.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-C9f5eif8.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-0Zt0t13W.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.5.0-alpha.6",
3
+ "version": "0.5.0-alpha.8",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.5.0-alpha.6",
36
- "@hyperframes/player": "0.5.0-alpha.6"
35
+ "@hyperframes/core": "0.5.0-alpha.8",
36
+ "@hyperframes/player": "0.5.0-alpha.8"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.5.0-alpha.6"
50
+ "@hyperframes/producer": "0.5.0-alpha.8"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -28,6 +28,7 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
28
28
  import { useCaptionStore } from "./captions/store";
29
29
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
30
30
  import { parseCaptionComposition } from "./captions/parser";
31
+ import { copyTextToClipboard } from "./utils/clipboard";
31
32
  import {
32
33
  applyPatchByTarget,
33
34
  readAttributeByTarget,
@@ -166,6 +167,23 @@ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): stri
166
167
  return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
167
168
  }
168
169
 
170
+ function isAbsoluteFilePath(value: string): boolean {
171
+ return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
172
+ }
173
+
174
+ function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
175
+ const trimmedSource = sourceFile.trim();
176
+ if (!trimmedSource) return undefined;
177
+
178
+ const normalizedSource = trimmedSource.replace(/\\/g, "/");
179
+ if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
180
+
181
+ const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
182
+ if (!normalizedRoot) return undefined;
183
+
184
+ return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
185
+ }
186
+
169
187
  function ensureImportedFontFace(
170
188
  html: string,
171
189
  asset: ImportedFontAsset,
@@ -574,6 +592,7 @@ export function StudioApp() {
574
592
  });
575
593
 
576
594
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
595
+ const [projectDir, setProjectDir] = useState<string | null>(null);
577
596
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
578
597
  const [fileTree, setFileTree] = useState<string[]>([]);
579
598
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
@@ -589,6 +608,7 @@ export function StudioApp() {
589
608
  const [rightCollapsed, setRightCollapsed] = useState(true);
590
609
  const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
591
610
  const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
611
+ const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
592
612
  const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
593
613
  const [agentModalOpen, setAgentModalOpen] = useState(false);
594
614
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
@@ -1036,10 +1056,13 @@ export function StudioApp() {
1036
1056
  let cancelled = false;
1037
1057
  fetch(`/api/projects/${projectId}`)
1038
1058
  .then((r) => r.json())
1039
- .then((data: { files?: string[] }) => {
1059
+ .then((data: { files?: string[]; dir?: string }) => {
1040
1060
  if (!cancelled && data.files) setFileTree(data.files);
1061
+ if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
1041
1062
  })
1042
- .catch(() => {});
1063
+ .catch(() => {
1064
+ if (!cancelled) setProjectDir(null);
1065
+ });
1043
1066
  return () => {
1044
1067
  cancelled = true;
1045
1068
  };
@@ -1428,6 +1451,7 @@ export function StudioApp() {
1428
1451
  const applyDomSelection = useCallback(
1429
1452
  (selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
1430
1453
  setDomEditSelection(selection);
1454
+ setAgentPromptTagSnippet(undefined);
1431
1455
  setCopiedAgentPrompt(false);
1432
1456
  if (selection) {
1433
1457
  if (options?.revealPanel !== false) {
@@ -1490,6 +1514,34 @@ export function StudioApp() {
1490
1514
  [activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
1491
1515
  );
1492
1516
 
1517
+ const preloadAgentPromptSnippet = useCallback(
1518
+ async (selection: DomEditSelection) => {
1519
+ const pid = projectIdRef.current;
1520
+ if (!pid) return;
1521
+
1522
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
1523
+ try {
1524
+ const response = await fetch(
1525
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1526
+ );
1527
+ if (!response.ok) return;
1528
+
1529
+ const data = (await response.json()) as { content?: string };
1530
+ const html = data.content;
1531
+ const tagSnippet =
1532
+ typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
1533
+
1534
+ setAgentPromptTagSnippet((current) => {
1535
+ if (domEditSelectionRef.current !== selection) return current;
1536
+ return tagSnippet;
1537
+ });
1538
+ } catch {
1539
+ // Runtime outerHTML is still available as a synchronous copy fallback.
1540
+ }
1541
+ },
1542
+ [activeCompPath],
1543
+ );
1544
+
1493
1545
  const resolveImportedFontAsset = useCallback(
1494
1546
  (fontFamilyValue: string): ImportedFontAsset | null => {
1495
1547
  const family = primaryFontFamilyValue(fontFamilyValue);
@@ -1892,43 +1944,29 @@ export function StudioApp() {
1892
1944
 
1893
1945
  const handleAskAgent = useCallback(() => {
1894
1946
  if (!domEditSelection) return;
1947
+ setAgentPromptTagSnippet(undefined);
1948
+ void preloadAgentPromptSnippet(domEditSelection);
1895
1949
  setAgentModalOpen(true);
1896
- }, [domEditSelection]);
1950
+ }, [domEditSelection, preloadAgentPromptSnippet]);
1897
1951
 
1898
1952
  const handleAgentModalSubmit = useCallback(
1899
1953
  async (userInstruction: string) => {
1900
1954
  if (!domEditSelection) return;
1901
1955
 
1902
- const pid = projectIdRef.current;
1903
- if (!pid) return;
1904
-
1905
1956
  const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
1906
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
1907
- if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
1908
-
1909
- const data = (await response.json()) as { content?: string };
1910
- const html = data.content;
1911
- const tagSnippet =
1912
- typeof html === "string" ? readTagSnippetByTarget(html, domEditSelection) : undefined;
1957
+ const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
1913
1958
  const prompt = buildElementAgentPrompt({
1914
1959
  selection: domEditSelection,
1915
1960
  currentTime,
1916
1961
  tagSnippet,
1917
1962
  userInstruction,
1963
+ sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
1918
1964
  });
1919
1965
 
1920
- try {
1921
- await navigator.clipboard.writeText(prompt);
1922
- } catch {
1923
- const textarea = document.createElement("textarea");
1924
- textarea.value = prompt;
1925
- textarea.setAttribute("readonly", "true");
1926
- textarea.style.position = "fixed";
1927
- textarea.style.opacity = "0";
1928
- document.body.appendChild(textarea);
1929
- textarea.select();
1930
- document.execCommand("copy");
1931
- document.body.removeChild(textarea);
1966
+ const copied = await copyTextToClipboard(prompt);
1967
+ if (!copied) {
1968
+ showToast("Could not copy prompt to clipboard.", "error");
1969
+ return;
1932
1970
  }
1933
1971
 
1934
1972
  setAgentModalOpen(false);
@@ -1936,7 +1974,7 @@ export function StudioApp() {
1936
1974
  setCopiedAgentPrompt(true);
1937
1975
  copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
1938
1976
  },
1939
- [activeCompPath, currentTime, domEditSelection],
1977
+ [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
1940
1978
  );
1941
1979
 
1942
1980
  const handlePreviewIframeRef = useCallback(
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
3
+ import { copyTextToClipboard } from "../utils/clipboard";
3
4
 
4
5
  export interface LintFinding {
5
6
  severity: "error" | "warning";
@@ -30,12 +31,10 @@ export function LintModal({
30
31
  return line;
31
32
  });
32
33
  const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
33
- try {
34
- await navigator.clipboard.writeText(text);
34
+ const copiedText = await copyTextToClipboard(text);
35
+ if (copiedText) {
35
36
  setCopied(true);
36
37
  setTimeout(() => setCopied(false), 2000);
37
- } catch {
38
- // ignore
39
38
  }
40
39
  };
41
40
 
@@ -467,6 +467,43 @@ describe("patch builders and prompt builder", () => {
467
467
  expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
468
468
  });
469
469
 
470
+ it("uses an absolute source path in copied agent prompts when provided", () => {
471
+ const selection = {
472
+ element: {} as HTMLElement,
473
+ id: "editable-card",
474
+ selector: "#editable-card",
475
+ selectorIndex: undefined,
476
+ sourceFile: "index.html",
477
+ compositionPath: "index.html",
478
+ compositionSrc: undefined,
479
+ isCompositionHost: false,
480
+ label: "Drag me first",
481
+ tagName: "div",
482
+ boundingBox: { x: 108, y: 112, width: 380, height: 196 },
483
+ textContent: "Drag me first",
484
+ dataAttributes: {},
485
+ inlineStyles: {},
486
+ computedStyles: {},
487
+ textFields: [],
488
+ capabilities: {
489
+ canSelect: true,
490
+ canEditStyles: true,
491
+ canMove: true,
492
+ canResize: true,
493
+ canDetachFromLayout: false,
494
+ },
495
+ } satisfies DomEditSelection;
496
+
497
+ const prompt = buildElementAgentPrompt({
498
+ selection,
499
+ currentTime: 1.25,
500
+ sourceFilePath: "/tmp/hf-studio-project/index.html",
501
+ });
502
+
503
+ expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
504
+ expect(prompt).not.toContain("Source file: index.html");
505
+ });
506
+
470
507
  it("serializes child text fields back into HTML", () => {
471
508
  expect(
472
509
  serializeDomEditTextFields([
@@ -697,12 +697,15 @@ export function buildElementAgentPrompt({
697
697
  currentTime,
698
698
  tagSnippet,
699
699
  userInstruction,
700
+ sourceFilePath,
700
701
  }: {
701
702
  selection: DomEditSelection;
702
703
  currentTime: number;
703
704
  tagSnippet?: string;
704
705
  userInstruction?: string;
706
+ sourceFilePath?: string;
705
707
  }): string {
708
+ const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
706
709
  const lines = [
707
710
  "## HyperFrames element edit request v1",
708
711
  "Schema version: 1",
@@ -711,7 +714,7 @@ export function buildElementAgentPrompt({
711
714
  "",
712
715
  `Composition: ${selection.compositionPath}`,
713
716
  `Playback time: ${formatTime(currentTime)}`,
714
- `Source file: ${selection.sourceFile}`,
717
+ `Source file: ${displayedSourceFile}`,
715
718
  `DOM id: ${selection.id ?? "(none)"}`,
716
719
  `Selector: ${selection.selector ?? "(none)"}`,
717
720
  `Selector index: ${selection.selectorIndex ?? 0}`,
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
2
2
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
3
  import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
4
4
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
5
+ import { copyTextToClipboard } from "../../utils/clipboard";
5
6
 
6
7
  interface AssetsTabProps {
7
8
  projectId: string;
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
298
299
  );
299
300
 
300
301
  const handleCopyPath = useCallback(async (path: string) => {
301
- try {
302
- await navigator.clipboard.writeText(path);
302
+ const copied = await copyTextToClipboard(path);
303
+ if (copied) {
303
304
  setCopiedPath(path);
304
305
  setTimeout(() => setCopiedPath(null), 1500);
305
- } catch {
306
- // ignore
307
306
  }
308
307
  }, []);
309
308
 
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { usePlayerStore } from "../store/playerStore";
4
4
  import { formatTime } from "../lib/time";
5
5
  import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
6
+ import { copyTextToClipboard } from "../../utils/clipboard";
6
7
 
7
8
  interface EditPopoverProps {
8
9
  rangeStart: number;
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
62
63
  }, [start, end, elementsInRange, prompt]);
63
64
 
64
65
  const handleCopy = useCallback(async () => {
65
- try {
66
- await navigator.clipboard.writeText(buildClipboardText());
67
- } catch {
68
- const ta = document.createElement("textarea");
69
- ta.value = buildClipboardText();
70
- document.body.appendChild(ta);
71
- ta.select();
72
- document.execCommand("copy");
73
- document.body.removeChild(ta);
74
- }
66
+ const copied = await copyTextToClipboard(buildClipboardText());
67
+ if (!copied) return;
75
68
  setCopiedAgentPrompt(true);
76
69
  setTimeout(() => {
77
70
  setCopiedAgentPrompt(false);
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
82
75
  const handleCopyPrompt = useCallback(async () => {
83
76
  const promptText = buildPromptCopyText(prompt);
84
77
  if (!promptText) return;
85
- try {
86
- await navigator.clipboard.writeText(promptText);
87
- } catch {
88
- const ta = document.createElement("textarea");
89
- ta.value = promptText;
90
- document.body.appendChild(ta);
91
- ta.select();
92
- document.execCommand("copy");
93
- document.body.removeChild(ta);
94
- }
78
+ const copied = await copyTextToClipboard(promptText);
79
+ if (!copied) return;
95
80
  setCopiedPromptOnly(true);
96
81
  setTimeout(() => {
97
82
  setCopiedPromptOnly(false);
@@ -1,11 +1,14 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
+ formatTimelineTickLabel,
3
4
  generateTicks,
4
5
  getDefaultDroppedTrack,
5
6
  getTimelineCanvasHeight,
6
7
  resolveTimelineAssetDrop,
7
8
  getTimelinePlayheadLeft,
9
+ getTimelineScrollLeftForZoomAnchor,
8
10
  getTimelineScrollLeftForZoomTransition,
11
+ shouldShowTimelineShortcutHint,
9
12
  shouldHandleTimelineDeleteKey,
10
13
  shouldAutoScrollTimeline,
11
14
  } from "./Timeline";
@@ -78,6 +81,20 @@ describe("generateTicks", () => {
78
81
  expect(major[0]).toBe(0);
79
82
  }
80
83
  });
84
+
85
+ it("uses denser major labels as timeline zoom increases", () => {
86
+ const fitTicks = generateTicks(180, 10);
87
+ const zoomedTicks = generateTicks(180, 48);
88
+ expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
89
+ expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
90
+ expect(zoomedTicks.minor).toContain(1);
91
+ expect(zoomedTicks.minor).toContain(4);
92
+ });
93
+
94
+ it("keeps labels readable instead of placing one at every tiny tick", () => {
95
+ const { major } = generateTicks(180, 80);
96
+ expect(major[1] - major[0]).toBe(2);
97
+ });
81
98
  });
82
99
 
83
100
  describe("formatTime", () => {
@@ -118,6 +135,20 @@ describe("formatTime", () => {
118
135
  });
119
136
  });
120
137
 
138
+ describe("formatTimelineTickLabel", () => {
139
+ it("uses minute-second labels for normal timeline intervals", () => {
140
+ expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
141
+ });
142
+
143
+ it("uses hour labels for long timelines", () => {
144
+ expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
145
+ });
146
+
147
+ it("shows subsecond labels when the major ruler interval is below one second", () => {
148
+ expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
149
+ });
150
+ });
151
+
121
152
  describe("shouldAutoScrollTimeline", () => {
122
153
  it("never auto-scrolls in fit mode", () => {
123
154
  expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
@@ -144,6 +175,48 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
144
175
  expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
145
176
  });
146
177
  });
178
+
179
+ describe("getTimelineScrollLeftForZoomAnchor", () => {
180
+ it("preserves the time under the pointer when zooming in", () => {
181
+ expect(
182
+ getTimelineScrollLeftForZoomAnchor({
183
+ pointerX: 300,
184
+ currentScrollLeft: 200,
185
+ gutter: 32,
186
+ currentPixelsPerSecond: 10,
187
+ nextPixelsPerSecond: 20,
188
+ duration: 120,
189
+ }),
190
+ ).toBe(668);
191
+ });
192
+
193
+ it("clamps negative scroll targets", () => {
194
+ expect(
195
+ getTimelineScrollLeftForZoomAnchor({
196
+ pointerX: 300,
197
+ currentScrollLeft: 0,
198
+ gutter: 32,
199
+ currentPixelsPerSecond: 20,
200
+ nextPixelsPerSecond: 5,
201
+ duration: 120,
202
+ }),
203
+ ).toBe(0);
204
+ });
205
+
206
+ it("preserves current scroll when inputs are invalid", () => {
207
+ expect(
208
+ getTimelineScrollLeftForZoomAnchor({
209
+ pointerX: 300,
210
+ currentScrollLeft: 120,
211
+ gutter: 32,
212
+ currentPixelsPerSecond: 0,
213
+ nextPixelsPerSecond: 20,
214
+ duration: 120,
215
+ }),
216
+ ).toBe(120);
217
+ });
218
+ });
219
+
147
220
  describe("getTimelinePlayheadLeft", () => {
148
221
  it("converts time to a pixel offset from the gutter", () => {
149
222
  expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
@@ -165,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
165
238
  });
166
239
  });
167
240
 
241
+ describe("shouldShowTimelineShortcutHint", () => {
242
+ it("shows the hint when the timeline does not vertically overflow", () => {
243
+ expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
244
+ expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
245
+ });
246
+
247
+ it("hides the hint when timeline tracks need vertical scrolling", () => {
248
+ expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
249
+ });
250
+ });
251
+
168
252
  describe("shouldHandleTimelineDeleteKey", () => {
169
253
  it("handles Delete and Backspace when focus is not in an editor", () => {
170
254
  expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);