@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.11

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 (53) hide show
  1. package/dist/assets/index-Bl4Deziq.js +105 -0
  2. package/dist/assets/index-KioPDrX6.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +494 -185
  6. package/src/captions/components/CaptionOverlay.tsx +2 -1
  7. package/src/captions/keyboard.test.ts +38 -0
  8. package/src/captions/keyboard.ts +8 -0
  9. package/src/components/LintModal.tsx +3 -4
  10. package/src/components/editor/DomEditOverlay.tsx +41 -6
  11. package/src/components/editor/PropertyPanel.tsx +7 -3
  12. package/src/components/editor/domEditing.test.ts +110 -0
  13. package/src/components/editor/domEditing.ts +33 -4
  14. package/src/components/nle/NLELayout.tsx +43 -8
  15. package/src/components/nle/NLEPreview.tsx +5 -1
  16. package/src/components/sidebar/AssetsTab.tsx +3 -4
  17. package/src/components/sidebar/LeftSidebar.tsx +64 -36
  18. package/src/hooks/usePersistentEditHistory.test.ts +255 -0
  19. package/src/hooks/usePersistentEditHistory.ts +336 -0
  20. package/src/icons/SystemIcons.tsx +4 -0
  21. package/src/player/components/AudioWaveform.tsx +44 -29
  22. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  23. package/src/player/components/CompositionThumbnail.tsx +42 -10
  24. package/src/player/components/EditModal.tsx +5 -20
  25. package/src/player/components/PlayerControls.tsx +117 -49
  26. package/src/player/components/Timeline.test.ts +84 -0
  27. package/src/player/components/Timeline.tsx +198 -27
  28. package/src/player/components/timelineEditing.test.ts +2 -2
  29. package/src/player/components/timelineEditing.ts +1 -1
  30. package/src/player/components/timelineTheme.ts +3 -3
  31. package/src/player/components/timelineZoom.test.ts +21 -0
  32. package/src/player/components/timelineZoom.ts +11 -0
  33. package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
  34. package/src/player/hooks/useTimelinePlayer.ts +354 -43
  35. package/src/player/lib/time.test.ts +29 -1
  36. package/src/player/lib/time.ts +26 -0
  37. package/src/player/store/playerStore.test.ts +11 -1
  38. package/src/player/store/playerStore.ts +5 -1
  39. package/src/styles/studio.css +9 -0
  40. package/src/utils/clipboard.test.ts +88 -0
  41. package/src/utils/clipboard.ts +57 -0
  42. package/src/utils/editHistory.test.ts +244 -0
  43. package/src/utils/editHistory.ts +218 -0
  44. package/src/utils/editHistoryStorage.test.ts +37 -0
  45. package/src/utils/editHistoryStorage.ts +99 -0
  46. package/src/utils/frameCapture.test.ts +26 -0
  47. package/src/utils/frameCapture.ts +38 -0
  48. package/src/utils/studioFileHistory.test.ts +156 -0
  49. package/src/utils/studioFileHistory.ts +61 -0
  50. package/src/utils/timelineAssetDrop.test.ts +64 -4
  51. package/src/utils/timelineAssetDrop.ts +27 -5
  52. package/dist/assets/index-Bi30tos-.js +0 -105
  53. package/dist/assets/index-Dm9VsShj.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import { memo, useState, useCallback, useRef } from "react";
2
2
  import { useCaptionStore } from "../store";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { shouldHandleCaptionNudgeKey } from "../keyboard";
4
5
 
5
6
  interface CaptionOverlayProps {
6
7
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -329,7 +330,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
329
330
  const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
330
331
  if (sel.size === 0 || !m) return;
331
332
  const arrow = e.key;
332
- if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return;
333
+ if (!shouldHandleCaptionNudgeKey(e)) return;
333
334
 
334
335
  e.preventDefault();
335
336
  const step = e.shiftKey ? 10 : 1;
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldHandleCaptionNudgeKey } from "./keyboard";
3
+
4
+ function mockKeyboardEvent(
5
+ key: string,
6
+ overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey">> = {},
7
+ ): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key"> {
8
+ return {
9
+ altKey: false,
10
+ ctrlKey: false,
11
+ metaKey: false,
12
+ key,
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe("shouldHandleCaptionNudgeKey", () => {
18
+ it("handles plain and Shift-modified arrow keys for caption nudging", () => {
19
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft"))).toBe(true);
20
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"))).toBe(true);
21
+ });
22
+
23
+ it("ignores browser and app shortcut chords", () => {
24
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBe(
25
+ false,
26
+ );
27
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { ctrlKey: true }))).toBe(
28
+ false,
29
+ );
30
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { metaKey: true }))).toBe(
31
+ false,
32
+ );
33
+ });
34
+
35
+ it("ignores non-arrow keys", () => {
36
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,8 @@
1
+ const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
2
+
3
+ type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
4
+
5
+ export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
6
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
7
+ return CAPTION_NUDGE_KEYS.has(event.key);
8
+ }
@@ -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
 
@@ -14,7 +14,11 @@ interface OverlayRect {
14
14
  interface DomEditOverlayProps {
15
15
  iframeRef: RefObject<HTMLIFrameElement | null>;
16
16
  selection: DomEditSelection | null;
17
- onCanvasMouseDown: (event: React.MouseEvent<HTMLDivElement>) => void;
17
+ allowCanvasMovement?: boolean;
18
+ onCanvasMouseDown: (
19
+ event: React.MouseEvent<HTMLDivElement>,
20
+ options?: { preferClipAncestor?: boolean },
21
+ ) => void;
18
22
  onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
19
23
  onSelectedDoubleClick: () => void;
20
24
  onBlockedMove: (selection: DomEditSelection) => void;
@@ -85,10 +89,21 @@ function selectionCacheKey(
85
89
  ].join("|");
86
90
  }
87
91
 
92
+ function restoreInlineStyle(
93
+ element: HTMLElement,
94
+ property: "left" | "top" | "width" | "height",
95
+ value: string,
96
+ ) {
97
+ if (value) element.style.setProperty(property, value);
98
+ else element.style.removeProperty(property);
99
+ }
100
+
88
101
  interface GestureState {
89
102
  kind: GestureKind;
90
103
  startX: number;
91
104
  startY: number;
105
+ initialStyleLeft: string;
106
+ initialStyleTop: string;
92
107
  originLeft: number;
93
108
  originTop: number;
94
109
  originWidth: number;
@@ -111,6 +126,7 @@ interface BlockedMoveState {
111
126
  export const DomEditOverlay = memo(function DomEditOverlay({
112
127
  iframeRef,
113
128
  selection,
129
+ allowCanvasMovement = true,
114
130
  onCanvasMouseDown,
115
131
  onCanvasDoubleClick,
116
132
  onSelectedDoubleClick,
@@ -226,6 +242,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
226
242
  kind,
227
243
  startX: e.clientX,
228
244
  startY: e.clientY,
245
+ initialStyleLeft: sel.element.style.left,
246
+ initialStyleTop: sel.element.style.top,
229
247
  originLeft: rect.left,
230
248
  originTop: rect.top,
231
249
  originWidth: rect.width,
@@ -277,9 +295,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
277
295
  }
278
296
  };
279
297
 
280
- const onPointerUp = () => {
298
+ const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
281
299
  const g = gestureRef.current;
282
300
  const sel = selectionRef.current;
301
+ const box = boxRef.current;
283
302
  blockedMoveRef.current = null;
284
303
  if (!g || !sel) {
285
304
  gestureRef.current = null;
@@ -290,6 +309,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
290
309
  gestureRef.current = null;
291
310
  rafPausedRef.current = false;
292
311
 
312
+ const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
313
+ if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
314
+ restoreInlineStyle(sel.element, "left", g.initialStyleLeft);
315
+ restoreInlineStyle(sel.element, "top", g.initialStyleTop);
316
+ if (box) {
317
+ box.style.left = `${g.originLeft}px`;
318
+ box.style.top = `${g.originTop}px`;
319
+ }
320
+ suppressNextBoxClickRef.current = true;
321
+ onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
322
+ preferClipAncestor: false,
323
+ });
324
+ return;
325
+ }
326
+
293
327
  if (g.kind === "drag") {
294
328
  const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
295
329
  const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
@@ -320,7 +354,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
320
354
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
321
355
  const target = event.target as HTMLElement | null;
322
356
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
323
- onCanvasMouseDown(event);
357
+ onCanvasMouseDown(event, { preferClipAncestor: false });
324
358
  };
325
359
 
326
360
  const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -339,7 +373,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
339
373
  event.stopPropagation();
340
374
  return;
341
375
  }
342
- onCanvasMouseDown(event);
376
+ onCanvasMouseDown(event, { preferClipAncestor: false });
343
377
  };
344
378
 
345
379
  const clearPointerState = () => {
@@ -371,9 +405,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
371
405
  top: overlayRect.top,
372
406
  width: overlayRect.width,
373
407
  height: overlayRect.height,
374
- cursor: selection.capabilities.canMove ? "move" : "default",
408
+ cursor: allowCanvasMovement && selection.capabilities.canMove ? "move" : "default",
375
409
  }}
376
410
  onPointerDown={(e) => {
411
+ if (!allowCanvasMovement) return;
377
412
  if (selection.capabilities.canMove) {
378
413
  startGesture("drag", e);
379
414
  return;
@@ -392,7 +427,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
392
427
  onDoubleClick={onSelectedDoubleClick}
393
428
  >
394
429
  {/* Resize handle — bottom-right corner */}
395
- {selection.capabilities.canResize && (
430
+ {allowCanvasMovement && selection.capabilities.canResize && (
396
431
  <div
397
432
  className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
398
433
  style={{ cursor: "se-resize", touchAction: "none" }}
@@ -64,6 +64,7 @@ interface PropertyPanelProps {
64
64
  onImportAssets?: (files: FileList) => Promise<string[]>;
65
65
  fontAssets?: ImportedFontAsset[];
66
66
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
67
+ allowLayoutDetach?: boolean;
67
68
  }
68
69
 
69
70
  const FIELD =
@@ -1984,6 +1985,7 @@ export const PropertyPanel = memo(function PropertyPanel({
1984
1985
  onImportAssets,
1985
1986
  fontAssets = [],
1986
1987
  onImportFonts,
1988
+ allowLayoutDetach = true,
1987
1989
  }: PropertyPanelProps) {
1988
1990
  const styles = element?.computedStyles ?? EMPTY_STYLES;
1989
1991
  const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
@@ -2020,7 +2022,7 @@ export const PropertyPanel = memo(function PropertyPanel({
2020
2022
  <p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
2021
2023
  <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
2022
2024
  The inspector is tuned for direct DOM edits with safer geometry controls, color picking,
2023
- and cleaner Paper-style grouping.
2025
+ and cleaner grouped layer controls.
2024
2026
  </p>
2025
2027
  </div>
2026
2028
  );
@@ -2036,7 +2038,9 @@ export const PropertyPanel = memo(function PropertyPanel({
2036
2038
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
2037
2039
  const showEditableSections = element.capabilities.canEditStyles;
2038
2040
  const disabledMoveReason =
2039
- element.capabilities.reasonIfDisabled && !element.capabilities.canDetachFromLayout
2041
+ allowLayoutDetach &&
2042
+ element.capabilities.reasonIfDisabled &&
2043
+ !element.capabilities.canDetachFromLayout
2040
2044
  ? element.capabilities.reasonIfDisabled
2041
2045
  : null;
2042
2046
 
@@ -2131,7 +2135,7 @@ export const PropertyPanel = memo(function PropertyPanel({
2131
2135
  </button>
2132
2136
  </div>
2133
2137
  )}
2134
- {element.capabilities.canDetachFromLayout && (
2138
+ {allowLayoutDetach && element.capabilities.canDetachFromLayout && (
2135
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">
2136
2140
  <div className="min-w-0 text-[11px] leading-5 text-neutral-400">
2137
2141
  <div className="font-medium text-neutral-200">
@@ -108,6 +108,61 @@ describe("resolveDomEditCapabilities", () => {
108
108
  });
109
109
  });
110
110
 
111
+ it("treats identity transforms left behind by animation libraries as movable", () => {
112
+ expect(
113
+ resolveDomEditCapabilities({
114
+ selector: "#card",
115
+ inlineStyles: {
116
+ left: "120px",
117
+ top: "80px",
118
+ width: "240px",
119
+ height: "140px",
120
+ },
121
+ computedStyles: {
122
+ position: "absolute",
123
+ left: "120px",
124
+ top: "80px",
125
+ width: "240px",
126
+ height: "140px",
127
+ transform: "matrix(1, 0, 0, 1, 0, 0)",
128
+ },
129
+ isCompositionHost: false,
130
+ isMasterView: false,
131
+ }),
132
+ ).toMatchObject({
133
+ canMove: true,
134
+ canResize: true,
135
+ canDetachFromLayout: false,
136
+ });
137
+ });
138
+
139
+ it("treats identity matrix3d transforms as movable", () => {
140
+ expect(
141
+ resolveDomEditCapabilities({
142
+ selector: "#card",
143
+ inlineStyles: {
144
+ left: "120px",
145
+ top: "80px",
146
+ width: "240px",
147
+ height: "140px",
148
+ },
149
+ computedStyles: {
150
+ position: "absolute",
151
+ left: "120px",
152
+ top: "80px",
153
+ width: "240px",
154
+ height: "140px",
155
+ transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
156
+ },
157
+ isCompositionHost: false,
158
+ isMasterView: false,
159
+ }),
160
+ ).toMatchObject({
161
+ canMove: true,
162
+ canResize: true,
163
+ });
164
+ });
165
+
111
166
  it("allows imported absolute media to resize from computed px geometry", () => {
112
167
  expect(
113
168
  resolveDomEditCapabilities({
@@ -228,6 +283,24 @@ describe("resolveDomEditSelection", () => {
228
283
  expect(selection?.selector).toBe("#card");
229
284
  });
230
285
 
286
+ it("can resolve the exact child when clip-ancestor preference is disabled", () => {
287
+ const document = createDocument(`
288
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
289
+ <p id="copy">Hello</p>
290
+ </section>
291
+ `);
292
+
293
+ const child = document.getElementById("copy") as HTMLElement;
294
+ const selection = resolveDomEditSelection(child, {
295
+ activeCompositionPath: null,
296
+ isMasterView: false,
297
+ preferClipAncestor: false,
298
+ });
299
+
300
+ expect(selection?.id).toBe("copy");
301
+ expect(selection?.selector).toBe("#copy");
302
+ });
303
+
231
304
  it("collects simple child text blocks as separate editable fields", () => {
232
305
  const document = createDocument(`
233
306
  <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
@@ -394,6 +467,43 @@ describe("patch builders and prompt builder", () => {
394
467
  expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
395
468
  });
396
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
+
397
507
  it("serializes child text fields back into HTML", () => {
398
508
  expect(
399
509
  serializeDomEditTextFields([
@@ -93,6 +93,32 @@ function parsePx(value: string | undefined): number | null {
93
93
  return Number.isFinite(parsed) ? parsed : null;
94
94
  }
95
95
 
96
+ function isIdentityTransform(value: string | undefined): boolean {
97
+ const transform = (value ?? "none").trim();
98
+ if (!transform || transform === "none") return true;
99
+
100
+ const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
101
+ if (matrix) {
102
+ const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
103
+ if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
104
+ return (
105
+ Math.abs(values[0] - 1) < 0.0001 &&
106
+ Math.abs(values[1]) < 0.0001 &&
107
+ Math.abs(values[2]) < 0.0001 &&
108
+ Math.abs(values[3] - 1) < 0.0001 &&
109
+ Math.abs(values[4]) < 0.0001 &&
110
+ Math.abs(values[5]) < 0.0001
111
+ );
112
+ }
113
+
114
+ const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
115
+ if (!matrix3d) return false;
116
+ const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
117
+ if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
118
+ const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
119
+ return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
120
+ }
121
+
96
122
  function isClipClassName(className: string | undefined): boolean {
97
123
  return Boolean(className?.split(/\s+/).includes("clip"));
98
124
  }
@@ -426,13 +452,13 @@ export function resolveDomEditCapabilities(args: {
426
452
  const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
427
453
  const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
428
454
  const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
429
- const transform = (args.computedStyles.transform ?? "none").trim();
455
+ const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
430
456
 
431
457
  const canMove =
432
458
  (position === "absolute" || position === "fixed") &&
433
459
  left != null &&
434
460
  top != null &&
435
- transform === "none";
461
+ !hasTransformDrivenGeometry;
436
462
 
437
463
  const canResize = canMove && (width != null || height != null);
438
464
  const isBlockishLayer =
@@ -442,7 +468,7 @@ export function resolveDomEditCapabilities(args: {
442
468
  isBlockishDisplay(args.computedStyles.display);
443
469
  const canDetachFromLayout =
444
470
  !canMove &&
445
- transform === "none" &&
471
+ !hasTransformDrivenGeometry &&
446
472
  isBlockishLayer &&
447
473
  (!isInlineTextTag(args.tagName) || isClipClassName(args.className));
448
474
  const reasonIfDisabled = !canMove
@@ -671,12 +697,15 @@ export function buildElementAgentPrompt({
671
697
  currentTime,
672
698
  tagSnippet,
673
699
  userInstruction,
700
+ sourceFilePath,
674
701
  }: {
675
702
  selection: DomEditSelection;
676
703
  currentTime: number;
677
704
  tagSnippet?: string;
678
705
  userInstruction?: string;
706
+ sourceFilePath?: string;
679
707
  }): string {
708
+ const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
680
709
  const lines = [
681
710
  "## HyperFrames element edit request v1",
682
711
  "Schema version: 1",
@@ -685,7 +714,7 @@ export function buildElementAgentPrompt({
685
714
  "",
686
715
  `Composition: ${selection.compositionPath}`,
687
716
  `Playback time: ${formatTime(currentTime)}`,
688
- `Source file: ${selection.sourceFile}`,
717
+ `Source file: ${displayedSourceFile}`,
689
718
  `DOM id: ${selection.id ?? "(none)"}`,
690
719
  `Selector: ${selection.selector ?? "(none)"}`,
691
720
  `Selector index: ${selection.selectorIndex ?? 0}`,
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
5
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
6
6
  import { NLEPreview } from "./NLEPreview";
7
7
  import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
8
+ import {
9
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
10
+ getTimelineToggleTitle,
11
+ } from "../../utils/timelineDiscovery";
8
12
 
9
13
  interface NLELayoutProps {
10
14
  projectId: string;
@@ -198,6 +202,7 @@ export const NLELayout = memo(function NLELayout({
198
202
 
199
203
  // Resizable timeline height
200
204
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
205
+ const isTimelineVisible = timelineVisible ?? true;
201
206
  const isDragging = useRef(false);
202
207
  const containerRef = useRef<HTMLDivElement>(null);
203
208
 
@@ -371,16 +376,11 @@ export const NLELayout = memo(function NLELayout({
371
376
  onNavigate={handleNavigateComposition}
372
377
  />
373
378
  )}
374
- <PlayerControls
375
- onTogglePlay={togglePlay}
376
- onSeek={seek}
377
- timelineVisible={timelineVisible ?? true}
378
- onToggleTimeline={onToggleTimeline}
379
- />
379
+ <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
380
380
  </div>
381
381
  </div>
382
382
 
383
- {(timelineVisible ?? true) && (
383
+ {isTimelineVisible ? (
384
384
  <>
385
385
  {/* Resize divider */}
386
386
  <div
@@ -422,7 +422,42 @@ export const NLELayout = memo(function NLELayout({
422
422
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
423
423
  </div>
424
424
  </>
425
- )}
425
+ ) : onToggleTimeline ? (
426
+ <div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
427
+ <div className="flex h-10 items-center justify-between px-3">
428
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
429
+ Timeline
430
+ </div>
431
+ <button
432
+ type="button"
433
+ onClick={onToggleTimeline}
434
+ className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
435
+ title={getTimelineToggleTitle(false)}
436
+ aria-label="Show timeline editor"
437
+ >
438
+ <svg
439
+ width="13"
440
+ height="13"
441
+ viewBox="0 0 24 24"
442
+ fill="none"
443
+ stroke="currentColor"
444
+ strokeWidth="1.7"
445
+ strokeLinecap="round"
446
+ strokeLinejoin="round"
447
+ aria-hidden="true"
448
+ >
449
+ <rect x="3" y="13" width="18" height="8" rx="1" />
450
+ <path d="M7 9h10" />
451
+ <path d="M8 5h8" />
452
+ </svg>
453
+ <span>Show</span>
454
+ <span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
455
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
456
+ </span>
457
+ </button>
458
+ </div>
459
+ </div>
460
+ ) : null}
426
461
  </div>
427
462
  );
428
463
  });
@@ -67,7 +67,11 @@ export const NLEPreview = memo(function NLEPreview({
67
67
 
68
68
  return (
69
69
  <div className="flex flex-col h-full min-h-0">
70
- <div className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0">
70
+ <div
71
+ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
72
+ tabIndex={0}
73
+ aria-label="Composition preview"
74
+ >
71
75
  {retiringKey && (
72
76
  <Player
73
77
  key={retiringKey}
@@ -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