@hyperframes/studio 0.1.13 → 0.1.15

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 (31) hide show
  1. package/dist/assets/index-CLmYRLY-.css +1 -0
  2. package/dist/assets/index-CRvFpc0E.js +84 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/App.tsx +139 -657
  6. package/src/components/LintModal.tsx +149 -0
  7. package/src/components/MediaPreview.tsx +79 -0
  8. package/src/components/editor/FileTree.tsx +50 -40
  9. package/src/components/editor/PropertyPanel.tsx +3 -3
  10. package/src/components/nle/NLELayout.tsx +59 -43
  11. package/src/components/renders/RenderQueue.tsx +19 -16
  12. package/src/components/renders/RenderQueueItem.tsx +13 -8
  13. package/src/components/sidebar/AssetsTab.tsx +34 -144
  14. package/src/components/sidebar/CompositionsTab.tsx +47 -161
  15. package/src/components/sidebar/LeftSidebar.tsx +79 -8
  16. package/src/components/ui/VideoFrameThumbnail.tsx +1 -5
  17. package/src/index.ts +0 -3
  18. package/src/player/components/CompositionThumbnail.tsx +20 -94
  19. package/src/player/components/EditModal.tsx +5 -5
  20. package/src/player/components/PlayerControls.tsx +56 -3
  21. package/src/player/components/Timeline.tsx +13 -17
  22. package/src/player/components/TimelineClip.tsx +0 -1
  23. package/src/player/index.ts +0 -1
  24. package/src/player/store/playerStore.ts +3 -28
  25. package/src/utils/mediaTypes.ts +9 -0
  26. package/dist/assets/index-2uBPlHR_.css +0 -1
  27. package/dist/assets/index-uQ8cgxb3.js +0 -92
  28. package/src/components/ui/ExpandOnHover.tsx +0 -194
  29. package/src/components/ui/ExpandedVideoPreview.tsx +0 -37
  30. package/src/hooks/useCodeEditor.ts +0 -88
  31. package/src/player/components/PreviewPanel.tsx +0 -181
package/src/App.tsx CHANGED
@@ -1,532 +1,52 @@
1
- import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
1
+ import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
2
2
  import { useMountEffect } from "./hooks/useMountEffect";
3
3
  import { NLELayout } from "./components/nle/NLELayout";
4
4
  import { SourceEditor } from "./components/editor/SourceEditor";
5
- import { FileTree } from "./components/editor/FileTree";
6
5
  import { LeftSidebar } from "./components/sidebar/LeftSidebar";
7
6
  import { RenderQueue } from "./components/renders/RenderQueue";
8
7
  import { useRenderQueue } from "./components/renders/useRenderQueue";
9
8
  import { CompositionThumbnail, VideoThumbnail } from "./player";
10
9
  import { AudioWaveform } from "./player/components/AudioWaveform";
11
10
  import type { TimelineElement } from "./player";
12
- import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
11
+ import { LintModal } from "./components/LintModal";
12
+ import type { LintFinding } from "./components/LintModal";
13
+ import { MediaPreview } from "./components/MediaPreview";
14
+ import { isMediaFile } from "./utils/mediaTypes";
13
15
 
14
16
  interface EditingFile {
15
17
  path: string;
16
18
  content: string | null;
17
19
  }
18
20
 
19
- interface ProjectEntry {
20
- id: string;
21
- title?: string;
22
- sessionId?: string;
23
- }
24
-
25
- interface LintFinding {
26
- severity: "error" | "warning";
27
- message: string;
28
- file?: string;
29
- fixHint?: string;
30
- }
31
-
32
- import { ExpandOnHover } from "./components/ui/ExpandOnHover";
33
-
34
- // ── Media file detection and preview ──
35
-
36
- const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
37
- const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
38
- const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
39
- const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
40
-
41
- function isMediaFile(path: string): boolean {
42
- return (
43
- IMAGE_EXT.test(path) || VIDEO_EXT.test(path) || AUDIO_EXT.test(path) || FONT_EXT.test(path)
44
- );
45
- }
46
-
47
- function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
48
- const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
49
- const name = filePath.split("/").pop() ?? filePath;
50
-
51
- if (IMAGE_EXT.test(filePath)) {
52
- return (
53
- <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
54
- <img
55
- src={serveUrl}
56
- alt={name}
57
- className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
58
- />
59
- <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
60
- </div>
61
- );
62
- }
63
-
64
- if (VIDEO_EXT.test(filePath)) {
65
- return (
66
- <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
67
- <video
68
- src={serveUrl}
69
- controls
70
- className="max-w-full max-h-[70%] rounded border border-neutral-800"
71
- />
72
- <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
73
- </div>
74
- );
75
- }
76
-
77
- if (AUDIO_EXT.test(filePath)) {
78
- return (
79
- <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-3">
80
- <svg
81
- width="48"
82
- height="48"
83
- viewBox="0 0 24 24"
84
- fill="none"
85
- stroke="currentColor"
86
- strokeWidth="1.5"
87
- className="text-neutral-600"
88
- >
89
- <path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
90
- <circle cx="6" cy="18" r="3" />
91
- <circle cx="18" cy="16" r="3" />
92
- </svg>
93
- <audio src={serveUrl} controls className="w-full max-w-[280px]" />
94
- <span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
95
- </div>
96
- );
97
- }
98
-
99
- // Fonts and other binary — show info instead of binary dump
100
- return (
101
- <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
102
- <svg
103
- width="40"
104
- height="40"
105
- viewBox="0 0 24 24"
106
- fill="none"
107
- stroke="currentColor"
108
- strokeWidth="1.5"
109
- className="text-neutral-600"
110
- >
111
- <path
112
- d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
113
- strokeLinecap="round"
114
- strokeLinejoin="round"
115
- />
116
- <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
117
- </svg>
118
- <span className="text-sm text-neutral-400 font-medium">{name}</span>
119
- <span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
120
- <span className="text-[10px] text-neutral-600">Binary file — preview not available</span>
121
- </div>
122
- );
123
- }
124
-
125
- // ── Project Card with hover-to-preview ──
126
-
127
- function ExpandedPreviewIframe({ src }: { src: string }) {
128
- const containerRef = useRef<HTMLDivElement>(null);
129
- const iframeRef = useRef<HTMLIFrameElement>(null);
130
- const [dims, setDims] = useState({ w: 1920, h: 1080 });
131
- const [scale, setScale] = useState(1);
132
-
133
- // Recalculate scale when container resizes or dims change.
134
- // Note: useEffect with [dims] dep — syncs with ResizeObserver (external system).
135
- // eslint-disable-next-line no-restricted-syntax
136
- useEffect(() => {
137
- const el = containerRef.current;
138
- if (!el) return;
139
- const update = () => {
140
- const cw = el.clientWidth;
141
- const ch = el.clientHeight;
142
- // Fit the composition inside the container (contain, not cover)
143
- const s = Math.min(cw / dims.w, ch / dims.h);
144
- setScale(s);
145
- };
146
- update();
147
- const ro = new ResizeObserver(update);
148
- ro.observe(el);
149
- return () => ro.disconnect();
150
- }, [dims]);
151
-
152
- // After iframe loads: detect composition dimensions, seek, and play
153
- const handleLoad = useCallback(() => {
154
- const iframe = iframeRef.current;
155
- if (!iframe) return;
156
- let attempts = 0;
157
- const interval = setInterval(() => {
158
- try {
159
- const doc = iframe.contentDocument;
160
- if (doc) {
161
- const comp = doc.querySelector("[data-composition-id]") as HTMLElement | null;
162
- if (comp) {
163
- const w = parseInt(comp.getAttribute("data-width") ?? "0", 10);
164
- const h = parseInt(comp.getAttribute("data-height") ?? "0", 10);
165
- if (w > 0 && h > 0) setDims({ w, h });
166
- }
167
- }
168
- const win = iframe.contentWindow as Window & {
169
- __player?: { seek: (t: number) => void; play: () => void };
170
- };
171
- if (win?.__player) {
172
- win.__player.seek(2);
173
- win.__player.play();
174
- clearInterval(interval);
175
- }
176
- } catch {
177
- /* cross-origin */
178
- }
179
- if (++attempts > 25) clearInterval(interval);
180
- }, 200);
181
- }, []);
182
-
183
- // Center the scaled iframe
184
- const offsetX = containerRef.current
185
- ? (containerRef.current.clientWidth - dims.w * scale) / 2
186
- : 0;
187
- const offsetY = containerRef.current
188
- ? (containerRef.current.clientHeight - dims.h * scale) / 2
189
- : 0;
190
-
191
- return (
192
- <div ref={containerRef} className="w-full h-full relative overflow-hidden bg-black">
193
- <iframe
194
- ref={iframeRef}
195
- src={src}
196
- sandbox="allow-scripts allow-same-origin"
197
- onLoad={handleLoad}
198
- className="absolute border-none"
199
- style={{
200
- left: Math.max(0, offsetX),
201
- top: Math.max(0, offsetY),
202
- width: dims.w,
203
- height: dims.h,
204
- transformOrigin: "0 0",
205
- transform: `scale(${scale})`,
206
- }}
207
- />
208
- </div>
209
- );
210
- }
211
-
212
- function ProjectCard({ project: p, onSelect }: { project: ProjectEntry; onSelect: () => void }) {
213
- const thumbnailUrl = `/api/projects/${p.id}/thumbnail/index.html?t=0.5`;
214
- const previewUrl = `/api/projects/${p.id}/preview`;
215
-
216
- const card = (
217
- <div className="rounded-xl overflow-hidden bg-neutral-900 border border-neutral-800/60 hover:border-[#3CE6AC]/30 hover:shadow-lg hover:shadow-[#3CE6AC]/5 transition-all duration-200 cursor-pointer">
218
- <div className="aspect-video bg-neutral-950 relative overflow-hidden flex items-center justify-center">
219
- <img
220
- src={thumbnailUrl}
221
- alt={p.title ?? p.id}
222
- loading="lazy"
223
- className="max-w-full max-h-full object-contain"
224
- onError={(e) => {
225
- (e.target as HTMLImageElement).style.display = "none";
226
- }}
227
- />
228
- </div>
229
- <div className="px-3.5 py-3">
230
- <div className="text-sm font-medium text-neutral-200 truncate">{p.title ?? p.id}</div>
231
- <div className="text-[10px] text-neutral-600 font-mono truncate mt-0.5">{p.id}</div>
232
- </div>
233
- </div>
234
- );
235
-
236
- return (
237
- <ExpandOnHover
238
- expandedContent={(closeExpand) => (
239
- <div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
240
- <div className="flex-1 min-h-0">
241
- <ExpandedPreviewIframe src={previewUrl} />
242
- </div>
243
- <div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
244
- <div>
245
- <div className="text-sm font-medium text-neutral-200">{p.title ?? p.id}</div>
246
- <div className="text-[10px] text-neutral-600 font-mono mt-0.5">{p.id}</div>
247
- </div>
248
- <button
249
- onClick={(e) => {
250
- e.stopPropagation();
251
- closeExpand();
252
- onSelect();
253
- }}
254
- className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
255
- >
256
- Open
257
- </button>
258
- </div>
259
- </div>
260
- )}
261
- onClick={onSelect}
262
- expandScale={0.6}
263
- delay={400}
264
- >
265
- {card}
266
- </ExpandOnHover>
267
- );
268
- }
269
-
270
- // ── Project Picker ──
271
-
272
- function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
273
- const [projects, setProjects] = useState<ProjectEntry[]>([]);
274
- const [loading, setLoading] = useState(true);
275
-
276
- useMountEffect(() => {
277
- fetch("/api/projects")
278
- .then((r) => r.json())
279
- .then((data: { projects?: ProjectEntry[] }) => {
280
- setProjects(data.projects ?? []);
281
- setLoading(false);
282
- })
283
- .catch(() => setLoading(false));
284
- });
285
-
286
- return (
287
- <div className="h-screen w-screen bg-neutral-950 overflow-y-auto">
288
- {/* Header */}
289
- <div className="max-w-4xl mx-auto px-6 pt-16 pb-8">
290
- <div className="flex items-center gap-3 mb-2">
291
- <svg width="32" height="32" viewBox="0 0 512 512" className="flex-shrink-0">
292
- <rect width="512" height="512" rx="115" fill="#1A1913" />
293
- <g strokeLinecap="round" strokeLinejoin="round">
294
- <polyline
295
- points="156,176 76,256 156,336"
296
- fill="none"
297
- stroke="#7B7568"
298
- strokeWidth="32"
299
- />
300
- <line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
301
- <polygon
302
- points="336,176 436,256 336,336"
303
- fill="#3CE6AC"
304
- stroke="#3CE6AC"
305
- strokeWidth="32"
306
- />
307
- </g>
308
- </svg>
309
- <h1 className="text-2xl font-bold text-neutral-100 tracking-tight">HyperFrames Studio</h1>
310
- </div>
311
- <p className="text-sm text-neutral-500 ml-11">Your projects</p>
312
- </div>
313
-
314
- {/* Project grid */}
315
- <div className="max-w-4xl mx-auto px-6 pb-16">
316
- {loading ? (
317
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
318
- {[1, 2, 3].map((i) => (
319
- <div key={i} className="aspect-video rounded-xl bg-neutral-900 animate-pulse" />
320
- ))}
321
- </div>
322
- ) : projects.length === 0 ? (
323
- <div className="flex flex-col items-center justify-center py-24 gap-4">
324
- <svg width="48" height="48" viewBox="0 0 512 512" className="opacity-20">
325
- <rect width="512" height="512" rx="115" fill="#1A1913" />
326
- <g strokeLinecap="round" strokeLinejoin="round">
327
- <polyline
328
- points="156,176 76,256 156,336"
329
- fill="none"
330
- stroke="#7B7568"
331
- strokeWidth="32"
332
- />
333
- <line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
334
- <polygon
335
- points="336,176 436,256 336,336"
336
- fill="#3CE6AC"
337
- stroke="#3CE6AC"
338
- strokeWidth="32"
339
- />
340
- </g>
341
- </svg>
342
- <div className="text-center">
343
- <p className="text-sm text-neutral-400 font-medium">No projects yet</p>
344
- <p className="text-xs text-neutral-600 mt-1">
345
- Run{" "}
346
- <code className="px-1.5 py-0.5 rounded bg-neutral-800 text-[#3CE6AC] text-[11px]">
347
- hyperframes init
348
- </code>{" "}
349
- to create one
350
- </p>
351
- </div>
352
- </div>
353
- ) : (
354
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
355
- {projects.map((p) => (
356
- <ProjectCard key={p.id} project={p} onSelect={() => onSelect(p.id)} />
357
- ))}
358
- </div>
359
- )}
360
- </div>
361
- </div>
362
- );
363
- }
364
-
365
- // ── Lint Modal ──
366
-
367
- function LintModal({
368
- findings,
369
- projectId,
370
- onClose,
371
- }: {
372
- findings: LintFinding[];
373
- projectId: string;
374
- onClose: () => void;
375
- }) {
376
- const errors = findings.filter((f) => f.severity === "error");
377
- const warnings = findings.filter((f) => f.severity === "warning");
378
- const hasIssues = findings.length > 0;
379
- const [copied, setCopied] = useState(false);
380
-
381
- const handleCopyToAgent = async () => {
382
- const lines = findings.map((f) => {
383
- let line = `[${f.severity}] ${f.message}`;
384
- if (f.file) line += `\n File: ${f.file}`;
385
- if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
386
- return line;
387
- });
388
- const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
389
- try {
390
- await navigator.clipboard.writeText(text);
391
- setCopied(true);
392
- setTimeout(() => setCopied(false), 2000);
393
- } catch {
394
- // ignore
395
- }
396
- };
397
-
398
- return (
399
- <div
400
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
401
- onClick={onClose}
402
- >
403
- <div
404
- className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
405
- onClick={(e) => e.stopPropagation()}
406
- >
407
- {/* Header */}
408
- <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
409
- <div className="flex items-center gap-3">
410
- {hasIssues ? (
411
- <div className="w-8 h-8 rounded-full bg-red-500/10 flex items-center justify-center">
412
- <WarningIcon size={18} className="text-red-400" weight="fill" />
413
- </div>
414
- ) : (
415
- <div className="w-8 h-8 rounded-full bg-[#3CE6AC]/10 flex items-center justify-center">
416
- <CheckCircleIcon size={18} className="text-[#3CE6AC]" weight="fill" />
417
- </div>
418
- )}
419
- <div>
420
- <h2 className="text-sm font-semibold text-neutral-200">
421
- {hasIssues
422
- ? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
423
- : "All checks passed"}
424
- </h2>
425
- <p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
426
- </div>
427
- </div>
428
- <button
429
- onClick={onClose}
430
- className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
431
- >
432
- <XIcon size={16} />
433
- </button>
434
- </div>
435
-
436
- {/* Copy to agent + findings */}
437
- {hasIssues && (
438
- <div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
439
- <button
440
- onClick={handleCopyToAgent}
441
- className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
442
- copied ? "bg-green-600 text-white" : "bg-[#3CE6AC] hover:bg-[#3CE6AC]/80 text-white"
443
- }`}
444
- >
445
- {copied ? "Copied!" : "Copy to Agent"}
446
- </button>
447
- </div>
448
- )}
449
- <div className="flex-1 overflow-y-auto px-5 py-3">
450
- {!hasIssues && (
451
- <div className="py-8 text-center text-neutral-500 text-sm">
452
- No errors or warnings found. Your composition looks good!
453
- </div>
454
- )}
455
- {errors.map((f, i) => (
456
- <div key={`e-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
457
- <div className="flex items-start gap-2">
458
- <WarningIcon
459
- size={14}
460
- className="text-red-400 flex-shrink-0 mt-0.5"
461
- weight="fill"
462
- />
463
- <div className="min-w-0">
464
- <p className="text-sm text-neutral-200">{f.message}</p>
465
- {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
466
- {f.fixHint && (
467
- <div className="flex items-start gap-1 mt-1.5">
468
- <CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
469
- <p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
470
- </div>
471
- )}
472
- </div>
473
- </div>
474
- </div>
475
- ))}
476
- {warnings.map((f, i) => (
477
- <div key={`w-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
478
- <div className="flex items-start gap-2">
479
- <WarningIcon size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
480
- <div className="min-w-0">
481
- <p className="text-sm text-neutral-300">{f.message}</p>
482
- {f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
483
- {f.fixHint && (
484
- <div className="flex items-start gap-1 mt-1.5">
485
- <CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
486
- <p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
487
- </div>
488
- )}
489
- </div>
490
- </div>
491
- </div>
492
- ))}
493
- </div>
494
- </div>
495
- </div>
496
- );
497
- }
498
-
499
21
  // ── Main App ──
500
22
 
501
23
  export function StudioApp() {
502
24
  const [projectId, setProjectId] = useState<string | null>(null);
503
25
  const [resolving, setResolving] = useState(true);
504
26
 
505
- useMountEffect(() => {
506
- const hash = window.location.hash;
507
- const projectMatch = hash.match(/project\/([^/]+)/);
508
- const sessionMatch = hash.match(/session\/([^/]+)/);
509
- if (projectMatch) {
510
- setProjectId(projectMatch[1]);
511
- setResolving(false);
512
- } else if (sessionMatch) {
513
- fetch(`/api/resolve-session/${sessionMatch[1]}`)
514
- .then((r) => r.json())
515
- .then((data: { projectId?: string }) => {
516
- if (data.projectId) {
517
- window.location.hash = `#project/${data.projectId}`;
518
- setProjectId(data.projectId);
519
- }
520
- setResolving(false);
521
- })
522
- .catch(() => setResolving(false));
523
- } else {
27
+ // eslint-disable-next-line no-restricted-syntax
28
+ useEffect(() => {
29
+ const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
30
+ if (hashMatch) {
31
+ setProjectId(hashMatch[1]);
524
32
  setResolving(false);
33
+ return;
525
34
  }
526
- });
35
+ // No hash — auto-select first available project
36
+ fetch("/api/projects")
37
+ .then((r) => r.json())
38
+ .then((data) => {
39
+ const first = (data.projects ?? [])[0];
40
+ if (first) {
41
+ setProjectId(first.id);
42
+ window.location.hash = `#project/${first.id}`;
43
+ }
44
+ })
45
+ .catch(() => {})
46
+ .finally(() => setResolving(false));
47
+ }, []);
527
48
 
528
49
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
529
- const [rightTab, setRightTab] = useState<"code" | "renders">("code");
530
50
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
531
51
  const [fileTree, setFileTree] = useState<string[]>([]);
532
52
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
@@ -536,10 +56,13 @@ export function StudioApp() {
536
56
  const [leftWidth, setLeftWidth] = useState(240);
537
57
  const [rightWidth, setRightWidth] = useState(400);
538
58
  const [leftCollapsed, setLeftCollapsed] = useState(false);
539
- const [rightCollapsed, setRightCollapsed] = useState(false);
540
- const panelDragRef = useRef<{ side: "left" | "right"; startX: number; startW: number } | null>(
541
- null,
542
- );
59
+ const [rightCollapsed, setRightCollapsed] = useState(true);
60
+ const [timelineVisible, setTimelineVisible] = useState(false);
61
+ const panelDragRef = useRef<{
62
+ side: "left" | "right";
63
+ startX: number;
64
+ startW: number;
65
+ } | null>(null);
543
66
 
544
67
  // Derive active preview URL from composition path (for drilled-down thumbnails)
545
68
  const activePreviewUrl = activeCompPath
@@ -637,6 +160,7 @@ export function StudioApp() {
637
160
  const [linting, setLinting] = useState(false);
638
161
  const [refreshKey, setRefreshKey] = useState(0);
639
162
  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
163
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
640
164
  const projectIdRef = useRef(projectId);
641
165
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
642
166
 
@@ -677,18 +201,11 @@ export function StudioApp() {
677
201
  };
678
202
  }, [projectId]);
679
203
 
680
- const handleSelectProject = useCallback((id: string) => {
681
- window.location.hash = `#project/${id}`;
682
- setProjectId(id);
683
- setActiveCompPath(null);
684
- setEditingFile(null);
685
- setCompIdToSrc(new Map());
686
- setFileTree([]);
687
- }, []);
688
-
689
204
  const handleFileSelect = useCallback((path: string) => {
690
205
  const pid = projectIdRef.current;
691
206
  if (!pid) return;
207
+ // Expand left panel to 50vw when opening a file in Code tab
208
+ setLeftWidth((prev) => Math.max(prev, Math.floor(window.innerWidth * 0.5)));
692
209
  // Skip fetching binary content for media files — just set the path for preview
693
210
  if (isMediaFile(path)) {
694
211
  setEditingFile({ path, content: null });
@@ -709,20 +226,24 @@ export function StudioApp() {
709
226
 
710
227
  const handleContentChange = useCallback((content: string) => {
711
228
  const pid = projectIdRef.current;
229
+ if (!pid) return;
712
230
  const path = editingPathRef.current;
713
- if (!pid || !path) return;
714
- // Don't update editingFile state — the editor manages its own content.
715
- // Only save to disk and refresh the preview.
716
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
717
- method: "PUT",
718
- headers: { "Content-Type": "text/plain" },
719
- body: content,
720
- })
721
- .then(() => {
722
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
723
- refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
231
+ if (!path) return;
232
+
233
+ // Debounce the server write (600ms)
234
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
235
+ saveTimerRef.current = setTimeout(() => {
236
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
237
+ method: "PUT",
238
+ headers: { "Content-Type": "text/plain" },
239
+ body: content,
724
240
  })
725
- .catch(() => {});
241
+ .then(() => {
242
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
243
+ refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
244
+ })
245
+ .catch(() => {});
246
+ }, 600);
726
247
  }, []);
727
248
 
728
249
  const handleLint = useCallback(async () => {
@@ -767,9 +288,13 @@ export function StudioApp() {
767
288
  const drag = panelDragRef.current;
768
289
  if (!drag) return;
769
290
  const delta = e.clientX - drag.startX;
291
+ const maxLeft = Math.floor(window.innerWidth * 0.5);
770
292
  const newW = Math.max(
771
293
  160,
772
- Math.min(600, drag.startW + (drag.side === "left" ? delta : -delta)),
294
+ Math.min(
295
+ drag.side === "left" ? maxLeft : 600,
296
+ drag.startW + (drag.side === "left" ? delta : -delta),
297
+ ),
773
298
  );
774
299
  if (drag.side === "left") setLeftWidth(newW);
775
300
  else setRightWidth(newW);
@@ -779,60 +304,41 @@ export function StudioApp() {
779
304
  panelDragRef.current = null;
780
305
  }, []);
781
306
 
782
- if (resolving) {
307
+ const compositions = useMemo(
308
+ () => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
309
+ [fileTree],
310
+ );
311
+ const assets = useMemo(
312
+ () =>
313
+ fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
314
+ [fileTree],
315
+ );
316
+
317
+ if (resolving || !projectId) {
783
318
  return (
784
319
  <div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
785
- <div className="text-sm text-neutral-500">Loading...</div>
320
+ <div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
786
321
  </div>
787
322
  );
788
323
  }
789
324
 
790
- if (!projectId) {
791
- return <ProjectPicker onSelect={handleSelectProject} />;
792
- }
793
-
794
- const compositions = fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/"));
795
- const assets = fileTree.filter(
796
- (f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json"),
797
- );
325
+ // At this point projectId is guaranteed non-null (narrowed by the guard above)
798
326
 
799
327
  return (
800
328
  <div className="flex flex-col h-screen w-screen bg-neutral-950">
801
329
  {/* Header bar */}
802
330
  <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
803
- {/* Left: back button + project name */}
331
+ {/* Left: project name */}
804
332
  <div className="flex items-center gap-2">
805
- <button
806
- onClick={() => {
807
- window.location.hash = "";
808
- setProjectId(null);
809
- }}
810
- className="flex items-center gap-1.5 px-2 py-1 rounded-md text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
811
- >
812
- <svg
813
- width="14"
814
- height="14"
815
- viewBox="0 0 24 24"
816
- fill="none"
817
- stroke="currentColor"
818
- strokeWidth="2"
819
- strokeLinecap="round"
820
- strokeLinejoin="round"
821
- >
822
- <polyline points="15 18 9 12 15 6" />
823
- </svg>
824
- <span className="text-xs">Projects</span>
825
- </button>
826
- <span className="text-[11px] text-neutral-600">/</span>
827
- <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
333
+ <span className="text-[11px] font-medium text-neutral-400">{projectId}</span>
828
334
  </div>
829
335
  {/* Right: toolbar buttons */}
830
336
  <div className="flex items-center gap-1.5">
831
337
  <button
832
338
  onClick={() => setLeftCollapsed((v) => !v)}
833
339
  className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
834
- leftCollapsed
835
- ? "bg-neutral-800 border-neutral-700 text-neutral-300"
340
+ !leftCollapsed
341
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
836
342
  : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
837
343
  }`}
838
344
  title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
@@ -852,20 +358,13 @@ export function StudioApp() {
852
358
  </svg>
853
359
  </button>
854
360
  <button
855
- onClick={handleLint}
856
- disabled={linting}
857
- className="h-7 px-2.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
858
- >
859
- {linting ? "Linting..." : "Lint"}
860
- </button>
861
- <button
862
- onClick={() => setRightCollapsed((v) => !v)}
361
+ onClick={() => setTimelineVisible((v) => !v)}
863
362
  className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
864
- rightCollapsed
865
- ? "bg-neutral-800 border-neutral-700 text-neutral-300"
363
+ timelineVisible
364
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
866
365
  : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
867
366
  }`}
868
- title={rightCollapsed ? "Show code panel" : "Hide code panel"}
367
+ title={timelineVisible ? "Hide timeline" : "Show timeline"}
869
368
  >
870
369
  <svg
871
370
  width="14"
@@ -875,11 +374,33 @@ export function StudioApp() {
875
374
  stroke="currentColor"
876
375
  strokeWidth="1.5"
877
376
  strokeLinecap="round"
878
- strokeLinejoin="round"
879
377
  >
880
- <rect x="3" y="3" width="18" height="18" rx="2" />
881
- <path d="M15 3v18" />
378
+ <rect x="3" y="13" width="18" height="8" rx="1" />
379
+ <line x1="3" y1="9" x2="21" y2="9" />
380
+ <line x1="3" y1="5" x2="21" y2="5" />
381
+ </svg>
382
+ </button>
383
+ <button
384
+ onClick={() => setRightCollapsed((v) => !v)}
385
+ className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
386
+ !rightCollapsed
387
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
388
+ : "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
389
+ }`}
390
+ >
391
+ <svg
392
+ width="12"
393
+ height="12"
394
+ viewBox="0 0 24 24"
395
+ fill="none"
396
+ stroke="currentColor"
397
+ strokeWidth="2"
398
+ >
399
+ <circle cx="12" cy="12" r="10" />
400
+ <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
882
401
  </svg>
402
+ Renders
403
+ {renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
883
404
  </button>
884
405
  </div>
885
406
  </div>
@@ -909,13 +430,31 @@ export function StudioApp() {
909
430
  .then((data) => setEditingFile({ path: comp, content: data.content }))
910
431
  .catch(() => {});
911
432
  }}
433
+ fileTree={fileTree}
434
+ editingFile={editingFile}
435
+ onSelectFile={handleFileSelect}
436
+ codeChildren={
437
+ editingFile ? (
438
+ isMediaFile(editingFile.path) ? (
439
+ <MediaPreview projectId={projectId ?? ""} filePath={editingFile.path} />
440
+ ) : (
441
+ <SourceEditor
442
+ content={editingFile.content ?? ""}
443
+ filePath={editingFile.path}
444
+ onChange={handleContentChange}
445
+ />
446
+ )
447
+ ) : undefined
448
+ }
449
+ onLint={handleLint}
450
+ linting={linting}
912
451
  />
913
452
  )}
914
453
 
915
454
  {/* Left resize handle */}
916
455
  {!leftCollapsed && (
917
456
  <div
918
- className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-col-resize transition-colors active:bg-blue-400"
457
+ className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
919
458
  style={{ touchAction: "none" }}
920
459
  onPointerDown={(e) => handlePanelResizeStart("left", e)}
921
460
  onPointerMove={handlePanelResizeMove}
@@ -939,97 +478,40 @@ export function StudioApp() {
939
478
  onIframeRef={(iframe) => {
940
479
  previewIframeRef.current = iframe;
941
480
  }}
481
+ timelineVisible={timelineVisible}
482
+ onToggleTimeline={() => setTimelineVisible((v) => !v)}
942
483
  />
943
484
  </div>
944
485
 
945
- {/* Right resize handle */}
946
- {!rightCollapsed && (
947
- <div
948
- className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-col-resize transition-colors active:bg-blue-400"
949
- style={{ touchAction: "none" }}
950
- onPointerDown={(e) => handlePanelResizeStart("right", e)}
951
- onPointerMove={handlePanelResizeMove}
952
- onPointerUp={handlePanelResizeEnd}
953
- />
954
- )}
955
-
956
- {/* Right panel: Code + Renders tabs (resizable, collapsible) */}
486
+ {/* Right panel: Renders-only (resizable, collapsible via header Renders button) */}
957
487
  {!rightCollapsed && (
958
- <div
959
- className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
960
- style={{ width: rightWidth }}
961
- >
962
- {/* Tab bar */}
963
- <div className="flex items-center border-b border-neutral-800 flex-shrink-0">
964
- <button
965
- onClick={() => setRightTab("code")}
966
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
967
- rightTab === "code"
968
- ? "text-neutral-200 border-b-2 border-[#3CE6AC]"
969
- : "text-neutral-500 hover:text-neutral-400"
970
- }`}
971
- >
972
- Code
973
- </button>
974
- <button
975
- onClick={() => setRightTab("renders")}
976
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
977
- rightTab === "renders"
978
- ? "text-neutral-200 border-b-2 border-[#3CE6AC]"
979
- : "text-neutral-500 hover:text-neutral-400"
980
- }`}
981
- >
982
- Renders{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
983
- </button>
984
- </div>
985
-
986
- {/* Tab content */}
987
- {rightTab === "code" ? (
988
- <div className="flex flex-1 min-h-0">
989
- {/* File tree sidebar */}
990
- {fileTree.length > 0 && (
991
- <div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
992
- <FileTree
993
- files={fileTree}
994
- activeFile={editingFile?.path ?? null}
995
- onSelectFile={handleFileSelect}
996
- />
997
- </div>
998
- )}
999
- {/* Code editor or media preview */}
1000
- <div className="flex-1 overflow-hidden min-w-0">
1001
- {editingFile ? (
1002
- isMediaFile(editingFile.path) ? (
1003
- <MediaPreview projectId={projectId} filePath={editingFile.path} />
1004
- ) : (
1005
- <SourceEditor
1006
- content={editingFile.content ?? ""}
1007
- filePath={editingFile.path}
1008
- onChange={handleContentChange}
1009
- />
1010
- )
1011
- ) : (
1012
- <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
1013
- Select a file to edit
1014
- </div>
1015
- )}
1016
- </div>
1017
- </div>
1018
- ) : (
488
+ <>
489
+ <div
490
+ className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
491
+ style={{ touchAction: "none" }}
492
+ onPointerDown={(e) => handlePanelResizeStart("right", e)}
493
+ onPointerMove={handlePanelResizeMove}
494
+ onPointerUp={handlePanelResizeEnd}
495
+ />
496
+ <div
497
+ className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
498
+ style={{ width: rightWidth }}
499
+ >
1019
500
  <RenderQueue
1020
501
  jobs={renderQueue.jobs}
502
+ projectId={projectId}
1021
503
  onDelete={renderQueue.deleteRender}
1022
504
  onClearCompleted={renderQueue.clearCompleted}
1023
505
  onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
1024
506
  isRendering={renderQueue.isRendering}
1025
507
  />
1026
- )}
1027
- </div>
508
+ </div>
509
+ </>
1028
510
  )}
1029
511
  </div>
1030
512
 
1031
513
  {/* Lint modal */}
1032
- {lintModal !== null && (
514
+ {lintModal !== null && projectId && (
1033
515
  <LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
1034
516
  )}
1035
517
  </div>