@hyperframes/studio 0.4.38 → 0.5.0-alpha.10

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 (51) hide show
  1. package/dist/assets/index-DKaNgV2Z.css +1 -0
  2. package/dist/assets/index-peNJzL-4.js +105 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +1431 -196
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.tsx +445 -0
  8. package/src/components/editor/PropertyPanel.tsx +2466 -206
  9. package/src/components/editor/colorValue.test.ts +82 -0
  10. package/src/components/editor/colorValue.ts +175 -0
  11. package/src/components/editor/domEditing.test.ts +537 -0
  12. package/src/components/editor/domEditing.ts +762 -0
  13. package/src/components/editor/floatingPanel.test.ts +34 -0
  14. package/src/components/editor/floatingPanel.ts +54 -0
  15. package/src/components/editor/fontAssets.ts +32 -0
  16. package/src/components/editor/fontCatalog.ts +126 -0
  17. package/src/components/editor/gradientValue.test.ts +89 -0
  18. package/src/components/editor/gradientValue.ts +445 -0
  19. package/src/components/nle/NLELayout.tsx +17 -47
  20. package/src/components/nle/NLEPreview.tsx +50 -5
  21. package/src/components/sidebar/AssetsTab.tsx +3 -4
  22. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  23. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  24. package/src/components/sidebar/LeftSidebar.tsx +34 -55
  25. package/src/icons/SystemIcons.tsx +0 -2
  26. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  27. package/src/player/components/CompositionThumbnail.tsx +42 -10
  28. package/src/player/components/EditModal.tsx +5 -20
  29. package/src/player/components/Player.tsx +18 -70
  30. package/src/player/components/PlayerControls.tsx +44 -3
  31. package/src/player/components/Timeline.test.ts +12 -0
  32. package/src/player/components/Timeline.tsx +51 -20
  33. package/src/player/components/TimelineClip.tsx +20 -7
  34. package/src/player/components/timelineEditing.test.ts +2 -4
  35. package/src/player/components/timelineEditing.ts +1 -3
  36. package/src/player/components/timelineTheme.ts +3 -3
  37. package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
  38. package/src/player/hooks/useTimelinePlayer.ts +74 -32
  39. package/src/player/lib/time.test.ts +1 -11
  40. package/src/player/lib/time.ts +0 -6
  41. package/src/utils/clipboard.test.ts +88 -0
  42. package/src/utils/clipboard.ts +57 -0
  43. package/src/utils/mediaTypes.ts +1 -1
  44. package/src/utils/sourcePatcher.test.ts +128 -1
  45. package/src/utils/sourcePatcher.ts +130 -18
  46. package/src/utils/timelineAssetDrop.test.ts +31 -11
  47. package/src/utils/timelineAssetDrop.ts +22 -2
  48. package/dist/assets/index-18P_dZeo.js +0 -93
  49. package/dist/assets/index-BLrgRQSu.css +0 -1
  50. package/src/utils/frameCapture.test.ts +0 -26
  51. package/src/utils/frameCapture.ts +0 -38
package/src/App.tsx CHANGED
@@ -1,31 +1,24 @@
1
- import {
2
- useState,
3
- useCallback,
4
- useRef,
5
- useEffect,
6
- useMemo,
7
- type MouseEvent,
8
- type ReactNode,
9
- } from "react";
1
+ import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
10
2
  import { useMountEffect } from "./hooks/useMountEffect";
11
3
  import { NLELayout } from "./components/nle/NLELayout";
12
4
  import { SourceEditor } from "./components/editor/SourceEditor";
13
5
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
14
6
  import { RenderQueue } from "./components/renders/RenderQueue";
15
7
  import { useRenderQueue } from "./components/renders/useRenderQueue";
16
- import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
8
+ import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
17
9
  import { AudioWaveform } from "./player/components/AudioWaveform";
18
10
  import type { TimelineElement } from "./player";
19
11
  import { LintModal } from "./components/LintModal";
20
12
  import type { LintFinding } from "./components/LintModal";
21
13
  import { MediaPreview } from "./components/MediaPreview";
22
- import { isMediaFile } from "./utils/mediaTypes";
14
+ import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
23
15
  import {
24
16
  buildTimelineAssetId,
25
17
  buildTimelineAssetInsertHtml,
26
18
  buildTimelineFileDropPlacements,
27
19
  getTimelineAssetKind,
28
20
  insertTimelineAssetIntoSource,
21
+ resolveTimelineAssetInitialGeometry,
29
22
  resolveTimelineAssetSrc,
30
23
  type TimelineAssetKind,
31
24
  } from "./utils/timelineAssetDrop";
@@ -35,7 +28,13 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
35
28
  import { useCaptionStore } from "./captions/store";
36
29
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
37
30
  import { parseCaptionComposition } from "./captions/parser";
38
- import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
31
+ import { copyTextToClipboard } from "./utils/clipboard";
32
+ import {
33
+ applyPatchByTarget,
34
+ readAttributeByTarget,
35
+ readTagSnippetByTarget,
36
+ type PatchOperation,
37
+ } from "./utils/sourcePatcher";
39
38
  import {
40
39
  buildTrackZIndexMap,
41
40
  formatTimelineAttributeNumber,
@@ -45,11 +44,36 @@ import {
45
44
  getTimelineZoomPercent,
46
45
  } from "./player/components/timelineZoom";
47
46
  import {
47
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
48
+ getTimelineEditorHintDismissed,
48
49
  getTimelineToggleTitle,
50
+ setTimelineEditorHintDismissed,
49
51
  shouldHandleTimelineToggleHotkey,
50
52
  } from "./utils/timelineDiscovery";
51
- import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
52
- import { Camera } from "./icons/SystemIcons";
53
+ import { PropertyPanel } from "./components/editor/PropertyPanel";
54
+ import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
55
+ import {
56
+ fontFamilyFromAssetPath,
57
+ importedFontFaceCss,
58
+ type ImportedFontAsset,
59
+ } from "./components/editor/fontAssets";
60
+ import { DomEditOverlay } from "./components/editor/DomEditOverlay";
61
+ import {
62
+ buildDefaultDomEditTextField,
63
+ buildDomEditDetachPatchOperations,
64
+ buildDomEditMovePatchOperations,
65
+ buildDomEditResizePatchOperations,
66
+ buildDomEditStylePatchOperation,
67
+ buildDomEditTextPatchOperation,
68
+ buildElementAgentPrompt,
69
+ findElementForSelection,
70
+ isTextEditableSelection,
71
+ serializeDomEditTextFields,
72
+ resolveDomEditCapabilities,
73
+ resolveDomEditSelection,
74
+ type DomEditTextField,
75
+ type DomEditSelection,
76
+ } from "./components/editor/domEditing";
53
77
 
54
78
  interface EditingFile {
55
79
  path: string;
@@ -61,6 +85,435 @@ interface AppToast {
61
85
  tone: "error" | "info";
62
86
  }
63
87
 
88
+ type RightPanelTab = "design" | "renders";
89
+
90
+ const GENERIC_FONT_FAMILIES = new Set([
91
+ "inherit",
92
+ "initial",
93
+ "revert",
94
+ "revert-layer",
95
+ "serif",
96
+ "sans-serif",
97
+ "monospace",
98
+ "cursive",
99
+ "fantasy",
100
+ "system-ui",
101
+ "ui-sans-serif",
102
+ "ui-serif",
103
+ "ui-monospace",
104
+ "ui-rounded",
105
+ "emoji",
106
+ "math",
107
+ "fangsong",
108
+ ]);
109
+
110
+ function primaryFontFamilyFromCss(value: string): string {
111
+ const first = value.split(",")[0] ?? "";
112
+ return first.trim().replace(/^["']|["']$/g, "");
113
+ }
114
+
115
+ function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
116
+ const family = primaryFontFamilyFromCss(fontFamilyValue);
117
+ if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
118
+
119
+ const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
120
+ if (doc.getElementById(id)) return;
121
+
122
+ const link = doc.createElement("link");
123
+ link.id = id;
124
+ link.rel = "stylesheet";
125
+ link.href = googleFontStylesheetUrl(family);
126
+ doc.head.appendChild(link);
127
+ }
128
+
129
+ function primaryFontFamilyValue(value: string): string {
130
+ return (
131
+ value
132
+ .split(",")[0]
133
+ ?.trim()
134
+ .replace(/^["']|["']$/g, "")
135
+ .trim() ?? ""
136
+ );
137
+ }
138
+
139
+ function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
140
+ const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
141
+ if (doc.getElementById(id)) return;
142
+ const style = doc.createElement("style");
143
+ style.id = id;
144
+ style.textContent = importedFontFaceCss(asset);
145
+ doc.head.appendChild(style);
146
+ }
147
+
148
+ function normalizeProjectAssetPath(value: string): string {
149
+ const trimmed = value.trim();
150
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
151
+ return decodeURIComponent(maybeUrl)
152
+ .replace(/\\/g, "/")
153
+ .replace(/^\.?\//, "");
154
+ }
155
+
156
+ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
157
+ const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
158
+ const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
159
+
160
+ fromParts.pop();
161
+
162
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
163
+ fromParts.shift();
164
+ targetParts.shift();
165
+ }
166
+
167
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
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
+
187
+ function ensureImportedFontFace(
188
+ html: string,
189
+ asset: ImportedFontAsset,
190
+ sourceFile: string,
191
+ ): string {
192
+ const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
193
+ if (html.includes(css)) return html;
194
+
195
+ const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
196
+ const styleMatch = styleRe.exec(html);
197
+ if (styleMatch) {
198
+ const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
199
+ return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
200
+ }
201
+
202
+ const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
203
+ if (/<\/head>/i.test(html)) {
204
+ return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
205
+ }
206
+ return `${styleTag}\n${html}`;
207
+ }
208
+ function normalizeDomEditStyleValue(property: string, value: string): string {
209
+ const trimmed = value.trim();
210
+ if (!trimmed) return trimmed;
211
+
212
+ if (
213
+ ["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
214
+ /^-?\d+(\.\d+)?$/.test(trimmed)
215
+ ) {
216
+ return `${trimmed}px`;
217
+ }
218
+
219
+ return trimmed;
220
+ }
221
+
222
+ function isImageBackgroundValue(value: string): boolean {
223
+ return /^url\(/i.test(value.trim());
224
+ }
225
+
226
+ function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
227
+ return Boolean(
228
+ selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
229
+ );
230
+ }
231
+
232
+ function buildOppositeEdgePatchOperations(
233
+ selection: DomEditSelection,
234
+ dimension: "width" | "height" | "both",
235
+ ): PatchOperation[] {
236
+ if (!shouldDetachOppositeEdges(selection)) return [];
237
+ const operations: PatchOperation[] = [];
238
+ if (dimension === "width" || dimension === "both") {
239
+ operations.push({ type: "inline-style", property: "right", value: "auto" });
240
+ }
241
+ if (dimension === "height" || dimension === "both") {
242
+ operations.push({ type: "inline-style", property: "bottom", value: "auto" });
243
+ }
244
+ return operations;
245
+ }
246
+
247
+ function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
248
+ if (!target || typeof target !== "object") return null;
249
+ const maybeNode = target as {
250
+ nodeType?: number;
251
+ parentElement?: Element | null;
252
+ };
253
+ if (maybeNode.nodeType === 1) return target as HTMLElement;
254
+ if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
255
+ return maybeNode.parentElement as HTMLElement;
256
+ }
257
+ return null;
258
+ }
259
+
260
+ function findMatchingTimelineElementId(
261
+ selection: Pick<
262
+ DomEditSelection,
263
+ "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
264
+ >,
265
+ elements: TimelineElement[],
266
+ ): string | null {
267
+ for (const element of elements) {
268
+ if (selection.id && element.domId === selection.id) {
269
+ return element.key ?? element.id;
270
+ }
271
+ if (
272
+ selection.isCompositionHost &&
273
+ selection.compositionSrc &&
274
+ element.compositionSrc === selection.compositionSrc
275
+ ) {
276
+ return element.key ?? element.id;
277
+ }
278
+ if (
279
+ selection.selector &&
280
+ element.selector === selection.selector &&
281
+ (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
282
+ (element.sourceFile ?? "index.html") === selection.sourceFile
283
+ ) {
284
+ return element.key ?? element.id;
285
+ }
286
+ }
287
+
288
+ return null;
289
+ }
290
+
291
+ function findMappedCompositionHost(
292
+ target: HTMLElement,
293
+ timelineElements: TimelineElement[],
294
+ compIdToSrc: Map<string, string>,
295
+ fileTree: string[],
296
+ ): { host: HTMLElement; compositionSrc: string } | null {
297
+ const rootCompositionId =
298
+ target.ownerDocument
299
+ .querySelector("[data-composition-id]")
300
+ ?.getAttribute("data-composition-id") ?? null;
301
+
302
+ let nestedCurrent: HTMLElement | null = target;
303
+ while (nestedCurrent) {
304
+ const nestedCompId = nestedCurrent.getAttribute("data-composition-id");
305
+ if (nestedCompId && nestedCompId !== rootCompositionId) {
306
+ const hostCandidate = nestedCurrent.parentElement?.closest(".clip");
307
+ if (hostCandidate instanceof HTMLElement) {
308
+ const hostCompId = hostCandidate.getAttribute("data-composition-id");
309
+ const compositionSrc =
310
+ hostCandidate.getAttribute("data-composition-src") ??
311
+ hostCandidate.getAttribute("data-composition-file") ??
312
+ (hostCompId ? compIdToSrc.get(hostCompId) : undefined) ??
313
+ compIdToSrc.get(nestedCompId) ??
314
+ fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ??
315
+ undefined;
316
+ if (compositionSrc) {
317
+ return { host: hostCandidate, compositionSrc };
318
+ }
319
+ }
320
+ }
321
+ nestedCurrent = nestedCurrent.parentElement;
322
+ }
323
+
324
+ let current: HTMLElement | null = target;
325
+ while (current) {
326
+ const compId = current.getAttribute("data-composition-id");
327
+ const directSrc =
328
+ current.getAttribute("data-composition-src") ??
329
+ current.getAttribute("data-composition-file") ??
330
+ undefined;
331
+ const timelineMatch =
332
+ timelineElements.find(
333
+ (element) =>
334
+ Boolean(element.compositionSrc) &&
335
+ (element.domId === current?.id ||
336
+ (current?.id && element.id === current.id) ||
337
+ (compId && element.id === compId)),
338
+ ) ?? null;
339
+ const compositionSrc =
340
+ directSrc ??
341
+ timelineMatch?.compositionSrc ??
342
+ (compId ? compIdToSrc.get(compId) : undefined) ??
343
+ (compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined);
344
+ if (compositionSrc) {
345
+ return { host: current, compositionSrc };
346
+ }
347
+ current = current.parentElement;
348
+ }
349
+
350
+ return null;
351
+ }
352
+
353
+ function isMoveStyleProperty(property: string): boolean {
354
+ return property === "left" || property === "top";
355
+ }
356
+
357
+ function isResizeStyleProperty(property: string): boolean {
358
+ return property === "width" || property === "height";
359
+ }
360
+
361
+ function getDomDetachCoordinateRoot(element: HTMLElement): HTMLElement {
362
+ const offsetParent = element.offsetParent;
363
+ if (offsetParent instanceof HTMLElement) return offsetParent;
364
+
365
+ let current = element.parentElement;
366
+ while (current) {
367
+ if (current.hasAttribute("data-composition-id")) return current;
368
+ current = current.parentElement;
369
+ }
370
+
371
+ return element.ownerDocument.body;
372
+ }
373
+
374
+ function measureDomDetachRect(element: HTMLElement): {
375
+ left: number;
376
+ top: number;
377
+ width: number;
378
+ height: number;
379
+ } {
380
+ const root = getDomDetachCoordinateRoot(element);
381
+ const rect = element.getBoundingClientRect();
382
+ const rootRect = root.getBoundingClientRect();
383
+
384
+ return {
385
+ left: rect.left - rootRect.left + root.scrollLeft,
386
+ top: rect.top - rootRect.top + root.scrollTop,
387
+ width: rect.width,
388
+ height: rect.height,
389
+ };
390
+ }
391
+
392
+ function getDomSelectionClickKey(
393
+ selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex">,
394
+ ): string {
395
+ if (selection.id) return `id:${selection.id}`;
396
+ return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`;
397
+ }
398
+
399
+ function getPreviewTargetFromPointer(
400
+ iframe: HTMLIFrameElement,
401
+ clientX: number,
402
+ clientY: number,
403
+ ): HTMLElement | null {
404
+ let doc: Document | null = null;
405
+ let win: Window | null = null;
406
+ try {
407
+ doc = iframe.contentDocument;
408
+ win = iframe.contentWindow;
409
+ } catch {
410
+ return null;
411
+ }
412
+ if (!doc || !win) return null;
413
+
414
+ const iframeRect = iframe.getBoundingClientRect();
415
+ const root =
416
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
417
+ const rootRect = root?.getBoundingClientRect();
418
+ const rootWidth = rootRect?.width || win.innerWidth;
419
+ const rootHeight = rootRect?.height || win.innerHeight;
420
+ if (!rootWidth || !rootHeight) return null;
421
+
422
+ const scaleX = iframeRect.width / rootWidth;
423
+ const scaleY = iframeRect.height / rootHeight;
424
+ const localX = (clientX - iframeRect.left) / scaleX;
425
+ const localY = (clientY - iframeRect.top) / scaleY;
426
+
427
+ return getEventTargetElement(doc.elementFromPoint(localX, localY));
428
+ }
429
+
430
+ // ── Ask Agent Modal ──
431
+
432
+ function AskAgentModal({
433
+ selectionLabel,
434
+ onSubmit,
435
+ onClose,
436
+ }: {
437
+ selectionLabel: string;
438
+ onSubmit: (instruction: string) => void;
439
+ onClose: () => void;
440
+ }) {
441
+ const [value, setValue] = useState("");
442
+ const inputRef = useRef<HTMLTextAreaElement>(null);
443
+
444
+ useMountEffect(() => {
445
+ requestAnimationFrame(() => inputRef.current?.focus());
446
+ });
447
+
448
+ const handleSubmit = () => {
449
+ if (!value.trim()) return;
450
+ onSubmit(value.trim());
451
+ };
452
+
453
+ return (
454
+ <div
455
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
456
+ onClick={onClose}
457
+ >
458
+ <div
459
+ className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
460
+ onClick={(e) => e.stopPropagation()}
461
+ >
462
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
463
+ <div>
464
+ <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
465
+ <p className="text-xs text-neutral-500 mt-0.5">
466
+ {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
467
+ </p>
468
+ </div>
469
+ <button
470
+ className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
471
+ onClick={onClose}
472
+ >
473
+ <svg
474
+ width="14"
475
+ height="14"
476
+ viewBox="0 0 24 24"
477
+ fill="none"
478
+ stroke="currentColor"
479
+ strokeWidth="2"
480
+ strokeLinecap="round"
481
+ >
482
+ <line x1="18" y1="6" x2="6" y2="18" />
483
+ <line x1="6" y1="6" x2="18" y2="18" />
484
+ </svg>
485
+ </button>
486
+ </div>
487
+ <div className="px-5 py-4">
488
+ <textarea
489
+ ref={inputRef}
490
+ className="w-full h-24 px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900/60 text-sm text-neutral-200 placeholder-neutral-600 resize-none focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
491
+ placeholder="Describe what you want to change…"
492
+ value={value}
493
+ onChange={(e) => setValue(e.target.value)}
494
+ onKeyDown={(e) => {
495
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
496
+ if (e.key === "Escape") onClose();
497
+ }}
498
+ />
499
+ </div>
500
+ <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
501
+ <span className="text-[11px] text-neutral-600">
502
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
503
+ </span>
504
+ <button
505
+ className="px-4 py-1.5 rounded-lg bg-studio-accent/90 text-xs font-medium text-neutral-950 hover:bg-studio-accent disabled:opacity-40 disabled:cursor-not-allowed"
506
+ disabled={!value.trim()}
507
+ onClick={handleSubmit}
508
+ >
509
+ Copy prompt
510
+ </button>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ );
515
+ }
516
+
64
517
  const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
65
518
  image: 3,
66
519
  video: 5,
@@ -139,6 +592,7 @@ export function StudioApp() {
139
592
  });
140
593
 
141
594
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
595
+ const [projectDir, setProjectDir] = useState<string | null>(null);
142
596
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
143
597
  const [fileTree, setFileTree] = useState<string[]>([]);
144
598
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
@@ -152,6 +606,12 @@ export function StudioApp() {
152
606
  const [rightWidth, setRightWidth] = useState(400);
153
607
  const [leftCollapsed, setLeftCollapsed] = useState(false);
154
608
  const [rightCollapsed, setRightCollapsed] = useState(true);
609
+ const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
610
+ const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
611
+ const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
612
+ const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
613
+ const [agentModalOpen, setAgentModalOpen] = useState(false);
614
+ const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
155
615
  // Auto-enter caption edit mode when the iframe contains .caption-group elements.
156
616
  // This is a subscription to external events (postMessage from runtime) — useEffect
157
617
  // is appropriate here. The runtime fires "state"/"timeline" messages after all
@@ -274,10 +734,14 @@ export function StudioApp() {
274
734
  const [globalDragOver, setGlobalDragOver] = useState(false);
275
735
  const [appToast, setAppToast] = useState<AppToast | null>(null);
276
736
  const [timelineVisible, setTimelineVisible] = useState(true);
277
- const [captureFrameTime, setCaptureFrameTime] = useState(0);
737
+ const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
738
+ getTimelineEditorHintDismissed,
739
+ );
278
740
  const dragCounterRef = useRef(0);
279
741
  const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
280
742
  const lastBlockedTimelineToastAtRef = useRef(0);
743
+ const lastBlockedDomMoveToastAtRef = useRef(0);
744
+ const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
281
745
  const previewHotkeyWindowRef = useRef<Window | null>(null);
282
746
  const panelDragRef = useRef<{
283
747
  side: "left" | "right";
@@ -289,11 +753,14 @@ export function StudioApp() {
289
753
  const activePreviewUrl = activeCompPath
290
754
  ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
291
755
  : null;
756
+ const isMasterView = !activeCompPath || activeCompPath === "index.html";
292
757
  const zoomMode = usePlayerStore((s) => s.zoomMode);
293
758
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
294
759
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
295
760
  const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
761
+ const currentTime = usePlayerStore((s) => s.currentTime);
296
762
  const timelineElements = usePlayerStore((s) => s.elements);
763
+ const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
297
764
  const timelineDuration = usePlayerStore((s) => s.duration);
298
765
  const effectiveTimelineDuration = useMemo(() => {
299
766
  const maxEnd =
@@ -309,29 +776,13 @@ export function StudioApp() {
309
776
  const toggleTimelineVisibility = useCallback(() => {
310
777
  setTimelineVisible((visible) => !visible);
311
778
  }, []);
312
- const toggleLeftSidebar = useCallback(() => {
313
- setLeftCollapsed((collapsed) => !collapsed);
314
- }, []);
315
- const refreshCaptureFrameTime = useCallback(() => {
316
- setCaptureFrameTime(usePlayerStore.getState().currentTime);
317
- }, []);
318
-
319
- useMountEffect(() => {
320
- setCaptureFrameTime(usePlayerStore.getState().currentTime);
321
- return liveTime.subscribe(setCaptureFrameTime);
322
- });
323
-
324
- const captureFrameHref = projectId
325
- ? buildFrameCaptureUrl({
326
- projectId,
327
- compositionPath: activeCompPath,
328
- currentTime: captureFrameTime,
329
- })
330
- : "#";
331
- const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
332
779
  useMountEffect(() => () => {
333
780
  if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
334
781
  });
782
+ const dismissTimelineEditorHint = useCallback(() => {
783
+ setTimelineEditorHintState(true);
784
+ setTimelineEditorHintDismissed(true);
785
+ }, []);
335
786
  const handleTimelineToggleHotkey = useCallback(
336
787
  (event: KeyboardEvent) => {
337
788
  if (!shouldHandleTimelineToggleHotkey(event)) return;
@@ -395,7 +846,6 @@ export function StudioApp() {
395
846
  label={el.id || el.tag}
396
847
  labelColor={style.label}
397
848
  accentColor={style.clip}
398
- selector={el.selector}
399
849
  seekTime={0}
400
850
  duration={el.duration}
401
851
  />
@@ -412,6 +862,7 @@ export function StudioApp() {
412
862
  labelColor={style.label}
413
863
  accentColor={style.clip}
414
864
  selector={el.selector}
865
+ selectorIndex={el.selectorIndex}
415
866
  seekTime={el.start}
416
867
  duration={el.duration}
417
868
  />
@@ -473,6 +924,7 @@ export function StudioApp() {
473
924
  labelColor={style.label}
474
925
  accentColor={style.clip}
475
926
  selector={el.selector}
927
+ selectorIndex={el.selectorIndex}
476
928
  seekTime={el.start}
477
929
  duration={el.duration}
478
930
  />
@@ -485,6 +937,31 @@ export function StudioApp() {
485
937
  );
486
938
  const timelineToolbar = (
487
939
  <div className="border-b border-neutral-800/40 bg-neutral-950/96">
940
+ {timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
941
+ <div className="px-3 pt-3">
942
+ <div className="flex items-start justify-between gap-3 rounded-xl border border-studio-accent/20 bg-studio-accent/[0.07] px-3 py-3">
943
+ <div className="min-w-0">
944
+ <div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
945
+ <p className="mt-1 text-[11px] leading-5 text-neutral-300">
946
+ Drag clips to move timing, and drag clip edges to resize them when handles are
947
+ available. Hide the panel anytime and bring it back with{" "}
948
+ <span className="font-mono text-[10px] text-studio-accent">
949
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
950
+ </span>
951
+ .
952
+ </p>
953
+ </div>
954
+ <button
955
+ type="button"
956
+ onClick={dismissTimelineEditorHint}
957
+ className="flex-shrink-0 rounded-md border border-neutral-700 px-2 py-1 text-[10px] font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
958
+ >
959
+ Dismiss
960
+ </button>
961
+ </div>
962
+ </div>
963
+ )}
964
+
488
965
  <div className="flex items-center justify-between px-3 py-2">
489
966
  <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
490
967
  Timeline
@@ -527,28 +1004,6 @@ export function StudioApp() {
527
1004
  >
528
1005
  +
529
1006
  </button>
530
- <button
531
- type="button"
532
- onClick={toggleTimelineVisibility}
533
- className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
534
- title={getTimelineToggleTitle(true)}
535
- aria-label="Hide timeline editor"
536
- >
537
- <svg
538
- width="14"
539
- height="14"
540
- viewBox="0 0 24 24"
541
- fill="none"
542
- stroke="currentColor"
543
- strokeWidth="1.8"
544
- strokeLinecap="round"
545
- strokeLinejoin="round"
546
- aria-hidden="true"
547
- >
548
- <path d="M5 7h14" />
549
- <path d="m8 11 4 4 4-4" />
550
- </svg>
551
- </button>
552
1007
  </div>
553
1008
  </div>
554
1009
  </div>
@@ -562,11 +1017,20 @@ export function StudioApp() {
562
1017
  const projectIdRef = useRef(projectId);
563
1018
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
564
1019
  const consoleErrorsRef = useRef<LintFinding[]>([]);
1020
+ const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1021
+ const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
1022
+ const lastPreviewClickRef = useRef<{ key: string; at: number } | null>(null);
1023
+ const domEditSaveTimestampRef = useRef(0);
1024
+ const domTextCommitVersionRef = useRef(0);
565
1025
 
566
1026
  // Listen for external file changes (user editing HTML outside the editor).
567
1027
  // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
1028
+ // Suppress file-change events that echo back from a recent DOM edit save —
1029
+ // those changes are already applied to the iframe DOM and a full reload
1030
+ // would flash the preview.
568
1031
  useMountEffect(() => {
569
1032
  const handler = () => {
1033
+ if (Date.now() - domEditSaveTimestampRef.current < 1200) return;
570
1034
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
571
1035
  refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
572
1036
  };
@@ -580,6 +1044,7 @@ export function StudioApp() {
580
1044
  return () => es.close();
581
1045
  });
582
1046
  projectIdRef.current = projectId;
1047
+ domEditSelectionRef.current = domEditSelection;
583
1048
 
584
1049
  // Load file tree when projectId changes.
585
1050
  // Note: This is one of the few places where useEffect with deps is acceptable —
@@ -591,10 +1056,13 @@ export function StudioApp() {
591
1056
  let cancelled = false;
592
1057
  fetch(`/api/projects/${projectId}`)
593
1058
  .then((r) => r.json())
594
- .then((data: { files?: string[] }) => {
1059
+ .then((data: { files?: string[]; dir?: string }) => {
595
1060
  if (!cancelled && data.files) setFileTree(data.files);
1061
+ if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
596
1062
  })
597
- .catch(() => {});
1063
+ .catch(() => {
1064
+ if (!cancelled) setProjectDir(null);
1065
+ });
598
1066
  return () => {
599
1067
  cancelled = true;
600
1068
  };
@@ -840,42 +1308,6 @@ export function StudioApp() {
840
1308
  toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
841
1309
  }, []);
842
1310
 
843
- const handleCaptureFrameClick = useCallback(
844
- async (event: MouseEvent<HTMLAnchorElement>) => {
845
- if (!projectId) return;
846
- event.preventDefault();
847
-
848
- const currentTime = usePlayerStore.getState().currentTime;
849
- setCaptureFrameTime(currentTime);
850
- const href = buildFrameCaptureUrl({
851
- projectId,
852
- compositionPath: activeCompPath,
853
- currentTime,
854
- });
855
- const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
856
-
857
- try {
858
- const response = await fetch(href, { cache: "no-store" });
859
- if (!response.ok) {
860
- throw new Error(`Capture failed (${response.status})`);
861
- }
862
- const blob = await response.blob();
863
- const blobUrl = URL.createObjectURL(blob);
864
- const link = document.createElement("a");
865
- link.href = blobUrl;
866
- link.download = filename;
867
- document.body.appendChild(link);
868
- link.click();
869
- link.remove();
870
- setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
871
- } catch (err) {
872
- const message = err instanceof Error ? err.message : "Capture failed";
873
- showToast(message);
874
- }
875
- },
876
- [activeCompPath, projectId, showToast],
877
- );
878
-
879
1311
  const handleTimelineElementDelete = useCallback(
880
1312
  async (element: TimelineElement) => {
881
1313
  const pid = projectIdRef.current;
@@ -1000,6 +1432,722 @@ export function StudioApp() {
1000
1432
  [showToast],
1001
1433
  );
1002
1434
 
1435
+ const handleBlockedDomMove = useCallback(
1436
+ (selection: DomEditSelection) => {
1437
+ const now = Date.now();
1438
+ if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
1439
+ lastBlockedDomMoveToastAtRef.current = now;
1440
+ showToast(
1441
+ selection.capabilities.canDetachFromLayout
1442
+ ? "This layer is controlled by layout. Use Make movable in the panel to detach it."
1443
+ : (selection.capabilities.reasonIfDisabled ??
1444
+ "This element can’t be moved directly from the preview."),
1445
+ "info",
1446
+ );
1447
+ },
1448
+ [showToast],
1449
+ );
1450
+
1451
+ const applyDomSelection = useCallback(
1452
+ (selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
1453
+ setDomEditSelection(selection);
1454
+ setAgentPromptTagSnippet(undefined);
1455
+ setCopiedAgentPrompt(false);
1456
+ if (selection) {
1457
+ if (options?.revealPanel !== false) {
1458
+ setRightCollapsed(false);
1459
+ setRightPanelTab("design");
1460
+ }
1461
+ const nextSelectedTimelineId = findMatchingTimelineElementId(selection, timelineElements);
1462
+ setSelectedTimelineElementId(nextSelectedTimelineId);
1463
+ return;
1464
+ }
1465
+
1466
+ setSelectedTimelineElementId(null);
1467
+ },
1468
+ [setSelectedTimelineElementId, timelineElements],
1469
+ );
1470
+
1471
+ const clearDomSelection = useCallback(() => {
1472
+ applyDomSelection(null, { revealPanel: false });
1473
+ }, [applyDomSelection]);
1474
+
1475
+ const buildDomSelectionFromTarget = useCallback(
1476
+ (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
1477
+ if (isMasterView) {
1478
+ const mappedHost = findMappedCompositionHost(
1479
+ target,
1480
+ timelineElements,
1481
+ compIdToSrc,
1482
+ fileTree,
1483
+ );
1484
+ if (mappedHost) {
1485
+ const hostSelection = resolveDomEditSelection(mappedHost.host, {
1486
+ activeCompositionPath: activeCompPath,
1487
+ isMasterView,
1488
+ preferClipAncestor: options?.preferClipAncestor,
1489
+ });
1490
+ if (!hostSelection) return null;
1491
+ return {
1492
+ ...hostSelection,
1493
+ compositionSrc: mappedHost.compositionSrc,
1494
+ isCompositionHost: true,
1495
+ capabilities: resolveDomEditCapabilities({
1496
+ selector: hostSelection.selector,
1497
+ tagName: hostSelection.tagName,
1498
+ className: hostSelection.element.className,
1499
+ inlineStyles: hostSelection.inlineStyles,
1500
+ computedStyles: hostSelection.computedStyles,
1501
+ isCompositionHost: true,
1502
+ isMasterView: true,
1503
+ }),
1504
+ } satisfies DomEditSelection;
1505
+ }
1506
+ }
1507
+
1508
+ return resolveDomEditSelection(target, {
1509
+ activeCompositionPath: activeCompPath,
1510
+ isMasterView,
1511
+ preferClipAncestor: options?.preferClipAncestor,
1512
+ });
1513
+ },
1514
+ [activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
1515
+ );
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
+
1545
+ const resolveImportedFontAsset = useCallback(
1546
+ (fontFamilyValue: string): ImportedFontAsset | null => {
1547
+ const family = primaryFontFamilyValue(fontFamilyValue);
1548
+ if (!family) return null;
1549
+ const imported = importedFontAssetsRef.current.find(
1550
+ (font) => font.family.toLowerCase() === family.toLowerCase(),
1551
+ );
1552
+ if (imported) return imported;
1553
+ const asset = fileTree.find(
1554
+ (path) =>
1555
+ FONT_EXT.test(path) &&
1556
+ fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
1557
+ );
1558
+ if (!asset) return null;
1559
+ return {
1560
+ family: fontFamilyFromAssetPath(asset),
1561
+ path: asset,
1562
+ url: `/api/projects/${projectId}/preview/${asset}`,
1563
+ };
1564
+ },
1565
+ [fileTree, projectId],
1566
+ );
1567
+
1568
+ const persistDomEditOperations = useCallback(
1569
+ async (
1570
+ selection: DomEditSelection,
1571
+ operations: Parameters<typeof applyPatchByTarget>[2][],
1572
+ options?: {
1573
+ skipRefresh?: boolean;
1574
+ prepareContent?: (html: string, sourceFile: string) => string;
1575
+ shouldSave?: () => boolean;
1576
+ },
1577
+ ) => {
1578
+ const pid = projectIdRef.current;
1579
+ if (!pid) throw new Error("No active project");
1580
+ if (options?.shouldSave && !options.shouldSave()) return;
1581
+
1582
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
1583
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
1584
+ if (!response.ok) {
1585
+ throw new Error(`Failed to read ${targetPath}`);
1586
+ }
1587
+
1588
+ const data = (await response.json()) as { content?: string };
1589
+ const originalContent = data.content;
1590
+ if (typeof originalContent !== "string") {
1591
+ throw new Error(`Missing file contents for ${targetPath}`);
1592
+ }
1593
+
1594
+ let patchedContent = originalContent;
1595
+ for (const operation of operations) {
1596
+ patchedContent = applyPatchByTarget(patchedContent, selection, operation);
1597
+ }
1598
+ if (options?.prepareContent) {
1599
+ patchedContent = options.prepareContent(patchedContent, targetPath);
1600
+ }
1601
+ if (options?.shouldSave && !options.shouldSave()) return;
1602
+
1603
+ if (patchedContent === originalContent) {
1604
+ throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
1605
+ }
1606
+
1607
+ const saveResponse = await fetch(
1608
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1609
+ {
1610
+ method: "PUT",
1611
+ headers: { "Content-Type": "text/plain" },
1612
+ body: patchedContent,
1613
+ },
1614
+ );
1615
+ if (!saveResponse.ok) {
1616
+ throw new Error(`Failed to save ${targetPath}`);
1617
+ }
1618
+
1619
+ if (editingPathRef.current === targetPath) {
1620
+ setEditingFile({ path: targetPath, content: patchedContent });
1621
+ }
1622
+
1623
+ if (options?.skipRefresh) {
1624
+ domEditSaveTimestampRef.current = Date.now();
1625
+ } else {
1626
+ setRefreshKey((k) => k + 1);
1627
+ }
1628
+ },
1629
+ [activeCompPath],
1630
+ );
1631
+
1632
+ const handleDomMoveCommit = useCallback(
1633
+ async (selection: DomEditSelection, next: { left: number; top: number }) => {
1634
+ await persistDomEditOperations(
1635
+ selection,
1636
+ [
1637
+ ...buildDomEditMovePatchOperations(next.left, next.top),
1638
+ ...buildOppositeEdgePatchOperations(selection, "both"),
1639
+ ],
1640
+ { skipRefresh: true },
1641
+ );
1642
+ },
1643
+ [persistDomEditOperations],
1644
+ );
1645
+
1646
+ const handleDomResizeCommit = useCallback(
1647
+ async (selection: DomEditSelection, next: { width: number; height: number }) => {
1648
+ if (shouldDetachOppositeEdges(selection)) {
1649
+ selection.element.style.right = "auto";
1650
+ selection.element.style.bottom = "auto";
1651
+ }
1652
+ await persistDomEditOperations(
1653
+ selection,
1654
+ [
1655
+ ...buildDomEditResizePatchOperations(next.width, next.height),
1656
+ ...buildOppositeEdgePatchOperations(selection, "both"),
1657
+ ],
1658
+ { skipRefresh: true },
1659
+ );
1660
+ },
1661
+ [persistDomEditOperations],
1662
+ );
1663
+
1664
+ const handleDomDetachFromLayout = useCallback(async () => {
1665
+ const selection = domEditSelection;
1666
+ if (!selection?.capabilities.canDetachFromLayout) return;
1667
+
1668
+ const doc = previewIframeRef.current?.contentDocument;
1669
+ const element = doc
1670
+ ? findElementForSelection(doc, selection, selection.sourceFile)
1671
+ : selection.element;
1672
+ if (!element) {
1673
+ showToast("Could not find the selected layer in the preview.", "info");
1674
+ return;
1675
+ }
1676
+
1677
+ const rect = measureDomDetachRect(element);
1678
+ const operations = buildDomEditDetachPatchOperations(rect);
1679
+
1680
+ for (const operation of operations) {
1681
+ element.style.setProperty(operation.property, operation.value);
1682
+ }
1683
+
1684
+ await persistDomEditOperations(selection, operations, { skipRefresh: true });
1685
+
1686
+ const refreshed = doc ? findElementForSelection(doc, selection, selection.sourceFile) : element;
1687
+ if (refreshed) {
1688
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
1689
+ if (nextSelection) {
1690
+ applyDomSelection(nextSelection, { revealPanel: false });
1691
+ }
1692
+ }
1693
+ showToast("Layer detached from layout. You can move it now.", "info");
1694
+ }, [
1695
+ applyDomSelection,
1696
+ buildDomSelectionFromTarget,
1697
+ domEditSelection,
1698
+ persistDomEditOperations,
1699
+ showToast,
1700
+ ]);
1701
+
1702
+ const handleDomStyleCommit = useCallback(
1703
+ async (property: string, value: string) => {
1704
+ if (!domEditSelection) return;
1705
+ const isMoveStyle = isMoveStyleProperty(property);
1706
+ const isResizeStyle = isResizeStyleProperty(property);
1707
+ if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
1708
+ if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
1709
+ if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
1710
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
1711
+ const iframe = previewIframeRef.current;
1712
+ const doc = iframe?.contentDocument;
1713
+ if (doc) {
1714
+ const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
1715
+ if (el) {
1716
+ el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
1717
+ if (property === "font-family") {
1718
+ injectPreviewGoogleFont(doc, value);
1719
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
1720
+ }
1721
+ if (shouldDetachOppositeEdges(domEditSelection)) {
1722
+ if (property === "width") el.style.right = "auto";
1723
+ if (property === "height") el.style.bottom = "auto";
1724
+ }
1725
+ if (property === "background-image" && isImageBackgroundValue(value)) {
1726
+ el.style.setProperty("background-position", "center");
1727
+ el.style.setProperty("background-repeat", "no-repeat");
1728
+ el.style.setProperty("background-size", "contain");
1729
+ }
1730
+ }
1731
+ }
1732
+ const operations: PatchOperation[] = [
1733
+ buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
1734
+ ];
1735
+ if (property === "width") {
1736
+ operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
1737
+ } else if (property === "height") {
1738
+ operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
1739
+ } else if (property === "background-image" && isImageBackgroundValue(value)) {
1740
+ operations.push(
1741
+ buildDomEditStylePatchOperation("background-position", "center"),
1742
+ buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
1743
+ buildDomEditStylePatchOperation("background-size", "contain"),
1744
+ );
1745
+ }
1746
+ await persistDomEditOperations(domEditSelection, operations, {
1747
+ skipRefresh: true,
1748
+ prepareContent: importedFont
1749
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
1750
+ : undefined,
1751
+ });
1752
+ },
1753
+ [domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
1754
+ );
1755
+
1756
+ const handleDomTextCommit = useCallback(
1757
+ async (value: string, fieldKey?: string) => {
1758
+ if (!domEditSelection) return;
1759
+ if (!isTextEditableSelection(domEditSelection)) return;
1760
+ const commitVersion = domTextCommitVersionRef.current + 1;
1761
+ domTextCommitVersionRef.current = commitVersion;
1762
+ const nextTextFields =
1763
+ domEditSelection.textFields.length > 0
1764
+ ? domEditSelection.textFields.map((field) =>
1765
+ field.key === fieldKey ? { ...field, value } : field,
1766
+ )
1767
+ : [];
1768
+ const nextContent =
1769
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
1770
+ ? serializeDomEditTextFields(nextTextFields)
1771
+ : value;
1772
+ const iframe = previewIframeRef.current;
1773
+ const doc = iframe?.contentDocument;
1774
+ if (doc) {
1775
+ const el = findElementForSelection(doc, domEditSelection, domEditSelection.sourceFile);
1776
+ if (el) {
1777
+ if (
1778
+ nextTextFields.length > 1 ||
1779
+ nextTextFields.some((field) => field.source === "child")
1780
+ ) {
1781
+ el.innerHTML = nextContent;
1782
+ } else {
1783
+ el.textContent = value;
1784
+ }
1785
+ }
1786
+ }
1787
+ await persistDomEditOperations(
1788
+ domEditSelection,
1789
+ [buildDomEditTextPatchOperation(nextContent)],
1790
+ {
1791
+ skipRefresh: true,
1792
+ shouldSave: () => domTextCommitVersionRef.current === commitVersion,
1793
+ },
1794
+ );
1795
+ if (domTextCommitVersionRef.current !== commitVersion) return;
1796
+
1797
+ if (doc) {
1798
+ const refreshed = findElementForSelection(
1799
+ doc,
1800
+ domEditSelection,
1801
+ domEditSelection.sourceFile,
1802
+ );
1803
+ if (refreshed) {
1804
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
1805
+ if (nextSelection) {
1806
+ applyDomSelection(nextSelection, { revealPanel: false });
1807
+ }
1808
+ }
1809
+ }
1810
+ },
1811
+ [applyDomSelection, buildDomSelectionFromTarget, domEditSelection, persistDomEditOperations],
1812
+ );
1813
+
1814
+ const commitDomTextFields = useCallback(
1815
+ async (
1816
+ selection: DomEditSelection,
1817
+ nextTextFields: DomEditTextField[],
1818
+ options?: { importedFont?: ImportedFontAsset | null },
1819
+ ) => {
1820
+ const nextContent =
1821
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
1822
+ ? serializeDomEditTextFields(nextTextFields)
1823
+ : (nextTextFields[0]?.value ?? "");
1824
+
1825
+ const iframe = previewIframeRef.current;
1826
+ const doc = iframe?.contentDocument;
1827
+ if (doc) {
1828
+ const el = findElementForSelection(doc, selection, selection.sourceFile);
1829
+ if (el) {
1830
+ if (
1831
+ nextTextFields.length > 1 ||
1832
+ nextTextFields.some((field) => field.source === "child")
1833
+ ) {
1834
+ el.innerHTML = nextContent;
1835
+ } else {
1836
+ el.textContent = nextContent;
1837
+ }
1838
+ }
1839
+ }
1840
+
1841
+ const importedFont = options?.importedFont ?? null;
1842
+ await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
1843
+ skipRefresh: true,
1844
+ prepareContent: importedFont
1845
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
1846
+ : undefined,
1847
+ });
1848
+
1849
+ if (doc) {
1850
+ const refreshed = findElementForSelection(doc, selection, selection.sourceFile);
1851
+ if (refreshed) {
1852
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
1853
+ if (nextSelection) {
1854
+ applyDomSelection(nextSelection, { revealPanel: false });
1855
+ }
1856
+ }
1857
+ }
1858
+ },
1859
+ [applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
1860
+ );
1861
+
1862
+ const handleDomTextFieldStyleCommit = useCallback(
1863
+ async (fieldKey: string, property: string, value: string) => {
1864
+ if (!domEditSelection) return;
1865
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
1866
+ if (!field) return;
1867
+
1868
+ if (field.source === "self") {
1869
+ await handleDomStyleCommit(property, value);
1870
+ return;
1871
+ }
1872
+
1873
+ const normalizedValue = normalizeDomEditStyleValue(property, value);
1874
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
1875
+ if (property === "font-family") {
1876
+ const doc = previewIframeRef.current?.contentDocument;
1877
+ if (doc) {
1878
+ injectPreviewGoogleFont(doc, normalizedValue);
1879
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
1880
+ }
1881
+ }
1882
+ const nextTextFields = domEditSelection.textFields.map((entry) =>
1883
+ entry.key === fieldKey
1884
+ ? {
1885
+ ...entry,
1886
+ inlineStyles: {
1887
+ ...entry.inlineStyles,
1888
+ [property]: normalizedValue,
1889
+ },
1890
+ computedStyles: {
1891
+ ...entry.computedStyles,
1892
+ [property]: normalizedValue,
1893
+ },
1894
+ }
1895
+ : entry,
1896
+ );
1897
+
1898
+ await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
1899
+ },
1900
+ [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
1901
+ );
1902
+
1903
+ const handleDomAddTextField = useCallback(
1904
+ async (afterFieldKey?: string) => {
1905
+ if (!domEditSelection) return null;
1906
+ if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
1907
+
1908
+ const insertionIndex = domEditSelection.textFields.findIndex(
1909
+ (field) => field.key === afterFieldKey,
1910
+ );
1911
+ const baseField =
1912
+ domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
1913
+ domEditSelection.textFields[0];
1914
+ const nextField = buildDefaultDomEditTextField(baseField);
1915
+ const nextTextFields = [...domEditSelection.textFields];
1916
+ nextTextFields.splice(
1917
+ insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
1918
+ 0,
1919
+ nextField,
1920
+ );
1921
+
1922
+ await commitDomTextFields(domEditSelection, nextTextFields);
1923
+ return nextField.key;
1924
+ },
1925
+ [commitDomTextFields, domEditSelection],
1926
+ );
1927
+
1928
+ const handleDomRemoveTextField = useCallback(
1929
+ async (fieldKey: string) => {
1930
+ if (!domEditSelection) return;
1931
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
1932
+ if (!field) return;
1933
+
1934
+ if (field.source === "self") {
1935
+ await handleDomTextCommit("", fieldKey);
1936
+ return;
1937
+ }
1938
+
1939
+ const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
1940
+ await commitDomTextFields(domEditSelection, nextTextFields);
1941
+ },
1942
+ [commitDomTextFields, domEditSelection, handleDomTextCommit],
1943
+ );
1944
+
1945
+ const handleAskAgent = useCallback(() => {
1946
+ if (!domEditSelection) return;
1947
+ setAgentPromptTagSnippet(undefined);
1948
+ void preloadAgentPromptSnippet(domEditSelection);
1949
+ setAgentModalOpen(true);
1950
+ }, [domEditSelection, preloadAgentPromptSnippet]);
1951
+
1952
+ const handleAgentModalSubmit = useCallback(
1953
+ async (userInstruction: string) => {
1954
+ if (!domEditSelection) return;
1955
+
1956
+ const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
1957
+ const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
1958
+ const prompt = buildElementAgentPrompt({
1959
+ selection: domEditSelection,
1960
+ currentTime,
1961
+ tagSnippet,
1962
+ userInstruction,
1963
+ sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
1964
+ });
1965
+
1966
+ const copied = await copyTextToClipboard(prompt);
1967
+ if (!copied) {
1968
+ showToast("Could not copy prompt to clipboard.", "error");
1969
+ return;
1970
+ }
1971
+
1972
+ setAgentModalOpen(false);
1973
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
1974
+ setCopiedAgentPrompt(true);
1975
+ copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
1976
+ },
1977
+ [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
1978
+ );
1979
+
1980
+ const handlePreviewIframeRef = useCallback(
1981
+ (iframe: HTMLIFrameElement | null) => {
1982
+ previewIframeRef.current = iframe;
1983
+ setPreviewIframe(iframe);
1984
+ syncPreviewTimelineHotkey(iframe);
1985
+ consoleErrorsRef.current = [];
1986
+ setConsoleErrors(null);
1987
+ },
1988
+ [syncPreviewTimelineHotkey],
1989
+ );
1990
+
1991
+ const handlePreviewCanvasMouseDown = useCallback(
1992
+ (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
1993
+ const iframe = previewIframeRef.current;
1994
+ if (!iframe || captionEditMode) return;
1995
+ const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
1996
+ if (!target) {
1997
+ lastPreviewClickRef.current = null;
1998
+ applyDomSelection(null, { revealPanel: false });
1999
+ return;
2000
+ }
2001
+ e.preventDefault();
2002
+ e.stopPropagation();
2003
+ const nextSelection = buildDomSelectionFromTarget(target, {
2004
+ preferClipAncestor: options?.preferClipAncestor ?? true,
2005
+ });
2006
+ if (!nextSelection) {
2007
+ lastPreviewClickRef.current = null;
2008
+ applyDomSelection(null, { revealPanel: false });
2009
+ return;
2010
+ }
2011
+ if (nextSelection.isCompositionHost && isMasterView && nextSelection.compositionSrc) {
2012
+ const key = getDomSelectionClickKey(nextSelection);
2013
+ const last = lastPreviewClickRef.current;
2014
+ const now = Date.now();
2015
+ if (last && last.key === key && now - last.at < 350) {
2016
+ lastPreviewClickRef.current = null;
2017
+ applyDomSelection(null, { revealPanel: false });
2018
+ setActiveCompPath(nextSelection.compositionSrc);
2019
+ return;
2020
+ }
2021
+ lastPreviewClickRef.current = { key, at: now };
2022
+ } else {
2023
+ lastPreviewClickRef.current = null;
2024
+ }
2025
+ applyDomSelection(nextSelection);
2026
+ },
2027
+ [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
2028
+ );
2029
+
2030
+ const handlePreviewCanvasDoubleClick = useCallback(
2031
+ (e: React.MouseEvent<HTMLDivElement>) => {
2032
+ const iframe = previewIframeRef.current;
2033
+ if (!iframe || captionEditMode) return;
2034
+ const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
2035
+ if (!target) return;
2036
+ const nextSelection = buildDomSelectionFromTarget(target, {
2037
+ preferClipAncestor: false,
2038
+ });
2039
+ if (!nextSelection?.isCompositionHost || !isMasterView || !nextSelection.compositionSrc) {
2040
+ return;
2041
+ }
2042
+ e.preventDefault();
2043
+ e.stopPropagation();
2044
+ lastPreviewClickRef.current = null;
2045
+ applyDomSelection(null, { revealPanel: false });
2046
+ setActiveCompPath(nextSelection.compositionSrc);
2047
+ },
2048
+ [applyDomSelection, buildDomSelectionFromTarget, captionEditMode, isMasterView],
2049
+ );
2050
+
2051
+ const handleSelectedOverlayDoubleClick = useCallback(() => {
2052
+ const selection = domEditSelectionRef.current;
2053
+ if (!selection?.isCompositionHost || !selection.compositionSrc) return;
2054
+ applyDomSelection(null, { revealPanel: false });
2055
+ setActiveCompPath(selection.compositionSrc);
2056
+ }, [applyDomSelection]);
2057
+
2058
+ // eslint-disable-next-line no-restricted-syntax
2059
+ useEffect(() => {
2060
+ if (!previewIframe || captionEditMode) return;
2061
+
2062
+ const syncSelectionFromDocument = () => {
2063
+ const currentSelection = domEditSelectionRef.current;
2064
+ if (!currentSelection) return;
2065
+ let doc: Document | null = null;
2066
+ try {
2067
+ doc = previewIframe.contentDocument;
2068
+ } catch {
2069
+ return;
2070
+ }
2071
+ if (!doc) return;
2072
+
2073
+ const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
2074
+ if (!nextElement) {
2075
+ applyDomSelection(null, { revealPanel: false });
2076
+ return;
2077
+ }
2078
+
2079
+ const nextSelection = buildDomSelectionFromTarget(nextElement);
2080
+ if (nextSelection) {
2081
+ applyDomSelection(nextSelection, { revealPanel: false });
2082
+ }
2083
+ };
2084
+
2085
+ const attachErrorCapture = () => {
2086
+ try {
2087
+ const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
2088
+ if (!win) return;
2089
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
2090
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
2091
+ const origError = win.console.error.bind(win.console);
2092
+ win.console.error = function (...args: unknown[]) {
2093
+ origError(...args);
2094
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
2095
+ if (text.includes("favicon")) return;
2096
+ consoleErrorsRef.current = [
2097
+ ...consoleErrorsRef.current,
2098
+ { severity: "error", message: text },
2099
+ ];
2100
+ setConsoleErrors([...consoleErrorsRef.current]);
2101
+ };
2102
+ win.addEventListener("error", (e: ErrorEvent) => {
2103
+ const text = e.message || String(e);
2104
+ consoleErrorsRef.current = [
2105
+ ...consoleErrorsRef.current,
2106
+ { severity: "error", message: text },
2107
+ ];
2108
+ setConsoleErrors([...consoleErrorsRef.current]);
2109
+ });
2110
+ } catch {
2111
+ // same-origin only
2112
+ }
2113
+ };
2114
+
2115
+ attachErrorCapture();
2116
+ syncSelectionFromDocument();
2117
+
2118
+ const handleLoad = () => {
2119
+ consoleErrorsRef.current = [];
2120
+ setConsoleErrors(null);
2121
+ attachErrorCapture();
2122
+ syncSelectionFromDocument();
2123
+ };
2124
+
2125
+ previewIframe.addEventListener("load", handleLoad);
2126
+ return () => {
2127
+ previewIframe.removeEventListener("load", handleLoad);
2128
+ };
2129
+ }, [
2130
+ activeCompPath,
2131
+ applyDomSelection,
2132
+ buildDomSelectionFromTarget,
2133
+ captionEditMode,
2134
+ previewIframe,
2135
+ ]);
2136
+
2137
+ // eslint-disable-next-line no-restricted-syntax
2138
+ useEffect(() => {
2139
+ if (!captionEditMode) return;
2140
+ applyDomSelection(null, { revealPanel: false });
2141
+ }, [applyDomSelection, captionEditMode]);
2142
+
2143
+ // eslint-disable-next-line no-restricted-syntax
2144
+ useEffect(
2145
+ () => () => {
2146
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
2147
+ },
2148
+ [],
2149
+ );
2150
+
1003
2151
  const refreshFileTree = useCallback(async () => {
1004
2152
  const pid = projectIdRef.current;
1005
2153
  if (!pid) return;
@@ -1133,6 +2281,7 @@ export function StudioApp() {
1133
2281
  duration: normalizedDuration,
1134
2282
  track: placement.track,
1135
2283
  zIndex: trackZIndices.get(placement.track) ?? 1,
2284
+ geometry: resolveTimelineAssetInitialGeometry(originalContent),
1136
2285
  }),
1137
2286
  );
1138
2287
 
@@ -1317,7 +2466,33 @@ export function StudioApp() {
1317
2466
 
1318
2467
  const handleImportFiles = useCallback(
1319
2468
  async (files: FileList | File[], dir?: string) => {
1320
- void uploadProjectFiles(Array.from(files), dir);
2469
+ return uploadProjectFiles(Array.from(files), dir);
2470
+ },
2471
+ [uploadProjectFiles],
2472
+ );
2473
+
2474
+ const handleImportFonts = useCallback(
2475
+ async (files: FileList | File[]) => {
2476
+ const uploaded = await uploadProjectFiles(
2477
+ Array.from(files).filter((file) => FONT_EXT.test(file.name)),
2478
+ "assets/fonts",
2479
+ );
2480
+ const pid = projectIdRef.current;
2481
+ const imported = uploaded
2482
+ .filter((asset) => FONT_EXT.test(asset))
2483
+ .map((asset) => ({
2484
+ family: fontFamilyFromAssetPath(asset),
2485
+ path: asset,
2486
+ url: `/api/projects/${pid}/preview/${asset}`,
2487
+ }));
2488
+ importedFontAssetsRef.current = [
2489
+ ...imported,
2490
+ ...importedFontAssetsRef.current.filter(
2491
+ (existing) =>
2492
+ !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
2493
+ ),
2494
+ ];
2495
+ return imported;
1321
2496
  },
1322
2497
  [uploadProjectFiles],
1323
2498
  );
@@ -1389,6 +2564,17 @@ export function StudioApp() {
1389
2564
  fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
1390
2565
  [fileTree],
1391
2566
  );
2567
+ const fontAssets = useMemo<ImportedFontAsset[]>(
2568
+ () =>
2569
+ assets
2570
+ .filter((asset) => FONT_EXT.test(asset))
2571
+ .map((asset) => ({
2572
+ family: fontFamilyFromAssetPath(asset),
2573
+ path: asset,
2574
+ url: `/api/projects/${projectId}/preview/${asset}`,
2575
+ })),
2576
+ [assets, projectId],
2577
+ );
1392
2578
 
1393
2579
  if (resolving || !projectId) {
1394
2580
  return (
@@ -1434,21 +2620,65 @@ export function StudioApp() {
1434
2620
  </div>
1435
2621
  {/* Right: toolbar buttons */}
1436
2622
  <div className="flex items-center gap-1.5">
1437
- <a
1438
- href={captureFrameHref}
1439
- download={captureFrameFilename}
1440
- onClick={handleCaptureFrameClick}
1441
- onFocus={refreshCaptureFrameTime}
1442
- onPointerDown={refreshCaptureFrameTime}
1443
- className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
1444
- title="Capture current frame"
1445
- aria-label="Capture current frame"
2623
+ <button
2624
+ onClick={() => setLeftCollapsed((v) => !v)}
2625
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
2626
+ !leftCollapsed
2627
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
2628
+ : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
2629
+ }`}
2630
+ title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
2631
+ >
2632
+ <svg
2633
+ width="14"
2634
+ height="14"
2635
+ viewBox="0 0 24 24"
2636
+ fill="none"
2637
+ stroke="currentColor"
2638
+ strokeWidth="1.5"
2639
+ strokeLinecap="round"
2640
+ strokeLinejoin="round"
2641
+ >
2642
+ <rect x="3" y="3" width="18" height="18" rx="2" />
2643
+ <path d="M9 3v18" />
2644
+ </svg>
2645
+ </button>
2646
+ <button
2647
+ type="button"
2648
+ onClick={toggleTimelineVisibility}
2649
+ className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
2650
+ timelineVisible
2651
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
2652
+ : "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
2653
+ }`}
2654
+ title={getTimelineToggleTitle(timelineVisible)}
2655
+ aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
1446
2656
  >
1447
- <Camera size={14} />
1448
- <span>Capture</span>
1449
- </a>
2657
+ <svg
2658
+ width="14"
2659
+ height="14"
2660
+ viewBox="0 0 24 24"
2661
+ fill="none"
2662
+ stroke="currentColor"
2663
+ strokeWidth="1.5"
2664
+ strokeLinecap="round"
2665
+ >
2666
+ <rect x="3" y="13" width="18" height="8" rx="1" />
2667
+ <line x1="3" y1="9" x2="21" y2="9" />
2668
+ <line x1="3" y1="5" x2="21" y2="5" />
2669
+ </svg>
2670
+ <span>Timeline</span>
2671
+ </button>
1450
2672
  <button
1451
- onClick={() => setRightCollapsed((v) => !v)}
2673
+ onClick={() => {
2674
+ if (rightCollapsed || rightPanelTab !== "design") {
2675
+ setRightPanelTab("design");
2676
+ setRightCollapsed(false);
2677
+ return;
2678
+ }
2679
+ clearDomSelection();
2680
+ setRightCollapsed(true);
2681
+ }}
1452
2682
  className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
1453
2683
  !rightCollapsed
1454
2684
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
@@ -1466,8 +2696,7 @@ export function StudioApp() {
1466
2696
  <circle cx="12" cy="12" r="10" />
1467
2697
  <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
1468
2698
  </svg>
1469
- Renders
1470
- {renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
2699
+ Inspector
1471
2700
  </button>
1472
2701
  </div>
1473
2702
  </div>
@@ -1475,32 +2704,7 @@ export function StudioApp() {
1475
2704
  {/* Main content: sidebar + preview + right panel */}
1476
2705
  <div className="flex flex-1 min-h-0">
1477
2706
  {/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
1478
- {leftCollapsed ? (
1479
- <div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
1480
- <button
1481
- type="button"
1482
- onClick={toggleLeftSidebar}
1483
- className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
1484
- title="Show sidebar"
1485
- aria-label="Show sidebar"
1486
- >
1487
- <svg
1488
- width="14"
1489
- height="14"
1490
- viewBox="0 0 24 24"
1491
- fill="none"
1492
- stroke="currentColor"
1493
- strokeWidth="1.5"
1494
- strokeLinecap="round"
1495
- strokeLinejoin="round"
1496
- aria-hidden="true"
1497
- >
1498
- <path d="M5 4v16" />
1499
- <path d="m10 7 5 5-5 5" />
1500
- </svg>
1501
- </button>
1502
- </div>
1503
- ) : (
2707
+ {!leftCollapsed && (
1504
2708
  <LeftSidebar
1505
2709
  width={leftWidth}
1506
2710
  projectId={projectId}
@@ -1547,7 +2751,6 @@ export function StudioApp() {
1547
2751
  }
1548
2752
  onLint={handleLint}
1549
2753
  linting={linting}
1550
- onToggleCollapse={toggleLeftSidebar}
1551
2754
  />
1552
2755
  )}
1553
2756
 
@@ -1584,56 +2787,25 @@ export function StudioApp() {
1584
2787
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
1585
2788
  setActiveCompPath(compPath);
1586
2789
  }}
1587
- onIframeRef={(iframe) => {
1588
- previewIframeRef.current = iframe;
1589
- syncPreviewTimelineHotkey(iframe);
1590
- consoleErrorsRef.current = [];
1591
- setConsoleErrors(null);
1592
- if (!iframe) return;
1593
-
1594
- // Attach error capture after each iframe load (content resets on navigation)
1595
- const attachErrorCapture = () => {
1596
- try {
1597
- const win = iframe.contentWindow as (Window & typeof globalThis) | null;
1598
- if (!win) return;
1599
- // Guard against double-patching
1600
- if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
1601
- (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
1602
- const origError = win.console.error.bind(win.console);
1603
- win.console.error = function (...args: unknown[]) {
1604
- origError(...args);
1605
- const text = args
1606
- .map((a) => (a instanceof Error ? a.message : String(a)))
1607
- .join(" ");
1608
- if (text.includes("favicon")) return;
1609
- consoleErrorsRef.current = [
1610
- ...consoleErrorsRef.current,
1611
- { severity: "error", message: text },
1612
- ];
1613
- setConsoleErrors([...consoleErrorsRef.current]);
1614
- };
1615
- win.addEventListener("error", (e: ErrorEvent) => {
1616
- const text = e.message || String(e);
1617
- consoleErrorsRef.current = [
1618
- ...consoleErrorsRef.current,
1619
- { severity: "error", message: text },
1620
- ];
1621
- setConsoleErrors([...consoleErrorsRef.current]);
1622
- });
1623
- } catch {
1624
- // cross-origin — can't attach
1625
- }
1626
- };
1627
- // Attach now (iframe may already be loaded) and on future loads
1628
- attachErrorCapture();
1629
- iframe.addEventListener("load", () => {
1630
- consoleErrorsRef.current = [];
1631
- setConsoleErrors(null);
1632
- attachErrorCapture();
1633
- });
1634
- }}
2790
+ onIframeRef={handlePreviewIframeRef}
1635
2791
  previewOverlay={
1636
- captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
2792
+ captionEditMode ? (
2793
+ <CaptionOverlay iframeRef={previewIframeRef} />
2794
+ ) : (
2795
+ <DomEditOverlay
2796
+ iframeRef={previewIframeRef}
2797
+ selection={
2798
+ !rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
2799
+ }
2800
+ allowCanvasMovement={false}
2801
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
2802
+ onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
2803
+ onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
2804
+ onBlockedMove={handleBlockedDomMove}
2805
+ onMoveCommit={handleDomMoveCommit}
2806
+ onResizeCommit={handleDomResizeCommit}
2807
+ />
2808
+ )
1637
2809
  }
1638
2810
  timelineFooter={
1639
2811
  captionEditMode ? (
@@ -1674,14 +2846,68 @@ export function StudioApp() {
1674
2846
  {captionEditMode ? (
1675
2847
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
1676
2848
  ) : (
1677
- <RenderQueue
1678
- jobs={renderQueue.jobs}
1679
- projectId={projectId}
1680
- onDelete={renderQueue.deleteRender}
1681
- onClearCompleted={renderQueue.clearCompleted}
1682
- onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
1683
- isRendering={renderQueue.isRendering}
1684
- />
2849
+ <>
2850
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
2851
+ <button
2852
+ type="button"
2853
+ onClick={() => setRightPanelTab("design")}
2854
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2855
+ rightPanelTab === "design"
2856
+ ? "bg-neutral-800 text-white"
2857
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2858
+ }`}
2859
+ >
2860
+ Design
2861
+ </button>
2862
+ <button
2863
+ type="button"
2864
+ onClick={() => setRightPanelTab("renders")}
2865
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
2866
+ rightPanelTab === "renders"
2867
+ ? "bg-neutral-800 text-white"
2868
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
2869
+ }`}
2870
+ >
2871
+ {renderQueue.jobs.length > 0
2872
+ ? `Renders (${renderQueue.jobs.length})`
2873
+ : "Renders"}
2874
+ </button>
2875
+ </div>
2876
+ <div className="min-h-0 flex-1">
2877
+ {rightPanelTab === "design" ? (
2878
+ <PropertyPanel
2879
+ projectId={projectId}
2880
+ assets={assets}
2881
+ element={domEditSelection}
2882
+ copiedAgentPrompt={copiedAgentPrompt}
2883
+ onClearSelection={clearDomSelection}
2884
+ onSetStyle={handleDomStyleCommit}
2885
+ onSetText={handleDomTextCommit}
2886
+ onSetTextFieldStyle={handleDomTextFieldStyleCommit}
2887
+ onAddTextField={handleDomAddTextField}
2888
+ onRemoveTextField={handleDomRemoveTextField}
2889
+ onDetachFromLayout={handleDomDetachFromLayout}
2890
+ onAskAgent={handleAskAgent}
2891
+ onCopyAgentInstruction={handleAgentModalSubmit}
2892
+ onImportAssets={handleImportFiles}
2893
+ fontAssets={fontAssets}
2894
+ onImportFonts={handleImportFonts}
2895
+ allowLayoutDetach={false}
2896
+ />
2897
+ ) : (
2898
+ <RenderQueue
2899
+ jobs={renderQueue.jobs}
2900
+ projectId={projectId}
2901
+ onDelete={renderQueue.deleteRender}
2902
+ onClearCompleted={renderQueue.clearCompleted}
2903
+ onStartRender={(format, quality) =>
2904
+ renderQueue.startRender(30, quality, format)
2905
+ }
2906
+ isRendering={renderQueue.isRendering}
2907
+ />
2908
+ )}
2909
+ </div>
2910
+ </>
1685
2911
  )}
1686
2912
  </div>
1687
2913
  </>
@@ -1702,6 +2928,15 @@ export function StudioApp() {
1702
2928
  />
1703
2929
  )}
1704
2930
 
2931
+ {/* Ask agent modal */}
2932
+ {agentModalOpen && domEditSelection && (
2933
+ <AskAgentModal
2934
+ selectionLabel={domEditSelection.label}
2935
+ onSubmit={handleAgentModalSubmit}
2936
+ onClose={() => setAgentModalOpen(false)}
2937
+ />
2938
+ )}
2939
+
1705
2940
  {/* Global drag-drop overlay */}
1706
2941
  {globalDragOver && (
1707
2942
  <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">