@rendiv/studio 0.1.2 → 0.1.4

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.
@@ -0,0 +1,573 @@
1
+ import React, { useRef, useCallback, useMemo, useEffect, useState } from 'react';
2
+ import type { TimelineEditorProps, TrackEntry } from './timeline/types';
3
+ import { assignTracks } from './timeline/track-layout';
4
+ import { useTimelineZoom } from './timeline/use-timeline-zoom';
5
+ import { useTimelineDrag } from './timeline/use-timeline-drag';
6
+
7
+ const TRACK_HEIGHT = 32;
8
+ const TRACK_GAP = 2;
9
+ const RULER_HEIGHT = 28;
10
+ const LABEL_WIDTH = 140;
11
+ const TOOLBAR_HEIGHT = 32;
12
+ const EDGE_HIT_ZONE = 6;
13
+
14
+ // Block colors — cycle through a palette
15
+ const BLOCK_COLORS = [
16
+ '#1f6feb', '#238636', '#8957e5', '#da3633',
17
+ '#d29922', '#1a7f37', '#6639ba', '#cf222e',
18
+ ];
19
+
20
+ function getBlockColor(index: number): string {
21
+ return BLOCK_COLORS[index % BLOCK_COLORS.length];
22
+ }
23
+
24
+ function formatTimecode(frame: number, fps: number): string {
25
+ const totalSeconds = frame / fps;
26
+ const m = Math.floor(totalSeconds / 60);
27
+ const s = Math.floor(totalSeconds % 60);
28
+ const f = frame % fps;
29
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`;
30
+ }
31
+
32
+ export const TimelineEditor: React.FC<TimelineEditorProps> = ({
33
+ entries,
34
+ currentFrame,
35
+ totalFrames,
36
+ fps,
37
+ compositionName,
38
+ onSeek,
39
+ overrides,
40
+ onOverrideChange,
41
+ onOverrideRemove,
42
+ onOverridesClear,
43
+ view,
44
+ onViewChange,
45
+ }) => {
46
+ const trackAreaRef = useRef<HTMLDivElement>(null);
47
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
48
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; namePath: string } | null>(null);
49
+
50
+ const { pixelsPerFrame, scrollLeft, setScrollLeft, setPixelsPerFrame, handleWheel } = useTimelineZoom({
51
+ totalFrames,
52
+ containerRef: trackAreaRef,
53
+ });
54
+
55
+ const tracks = useMemo(
56
+ () => assignTracks(entries, overrides),
57
+ [entries, overrides],
58
+ );
59
+
60
+ const tracksRef = useRef(tracks);
61
+ tracksRef.current = tracks;
62
+
63
+ // Check whether a block can be placed on a track without overlapping other blocks
64
+ const canPlaceOnTrack = useCallback((namePath: string, from: number, duration: number, targetTrack: number): boolean => {
65
+ const currentTracks = tracksRef.current;
66
+ if (targetTrack >= currentTracks.length) return true; // new empty track
67
+ const trackEntries = currentTracks[targetTrack]?.entries ?? [];
68
+ const end = from + duration;
69
+ return !trackEntries.some((te) => {
70
+ if (te.entry.namePath === namePath) return false; // skip self
71
+ const teEnd = te.entry.from + te.entry.durationInFrames;
72
+ return from < teEnd && end > te.entry.from; // overlap check
73
+ });
74
+ }, []);
75
+
76
+ const { startDrag } = useTimelineDrag({
77
+ pixelsPerFrame,
78
+ trackHeight: TRACK_HEIGHT,
79
+ trackGap: TRACK_GAP,
80
+ onOverrideChange,
81
+ canPlaceOnTrack,
82
+ });
83
+
84
+ // Attach wheel handler
85
+ useEffect(() => {
86
+ const el = trackAreaRef.current;
87
+ if (!el) return;
88
+ el.addEventListener('wheel', handleWheel, { passive: false });
89
+ return () => el.removeEventListener('wheel', handleWheel);
90
+ }, [handleWheel]);
91
+
92
+ // Sync scroll position
93
+ useEffect(() => {
94
+ const el = trackAreaRef.current;
95
+ if (el) el.scrollLeft = scrollLeft;
96
+ }, [scrollLeft]);
97
+
98
+ // Close context menu on click elsewhere
99
+ useEffect(() => {
100
+ if (!contextMenu) return;
101
+ const close = () => setContextMenu(null);
102
+ window.addEventListener('click', close);
103
+ return () => window.removeEventListener('click', close);
104
+ }, [contextMenu]);
105
+
106
+ const totalWidth = totalFrames * pixelsPerFrame;
107
+ const trackAreaHeight = Math.max(1, tracks.length) * (TRACK_HEIGHT + TRACK_GAP);
108
+
109
+ // Ruler tick computation
110
+ const rulerTicks = useMemo(() => {
111
+ const ticks: { frame: number; label: string; major: boolean }[] = [];
112
+ // Target ~100px between major ticks
113
+ const framesPerMajor = Math.max(1, Math.round(100 / pixelsPerFrame));
114
+ // Round to nice intervals
115
+ const nice = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600];
116
+ let interval = nice.find((n) => n >= framesPerMajor) ?? framesPerMajor;
117
+ // Make sure minor ticks divide evenly
118
+ const minorInterval = interval >= 10 ? interval / 5 : interval >= 2 ? interval / 2 : 1;
119
+
120
+ for (let f = 0; f <= totalFrames; f += minorInterval) {
121
+ const fr = Math.round(f);
122
+ if (fr > totalFrames) break;
123
+ const isMajor = fr % interval === 0;
124
+ ticks.push({ frame: fr, label: isMajor ? formatTimecode(fr, fps) : '', major: isMajor });
125
+ }
126
+ return ticks;
127
+ }, [totalFrames, pixelsPerFrame, fps]);
128
+
129
+ // Playhead seeking via ruler click/drag
130
+ const isSeekingRef = useRef(false);
131
+
132
+ const getFrameFromClientX = useCallback((clientX: number): number => {
133
+ const el = trackAreaRef.current;
134
+ if (!el) return 0;
135
+ const rect = el.getBoundingClientRect();
136
+ const x = clientX - rect.left + el.scrollLeft;
137
+ return Math.max(0, Math.min(totalFrames - 1, Math.round(x / pixelsPerFrame)));
138
+ }, [pixelsPerFrame, totalFrames]);
139
+
140
+ const handleRulerMouseDown = useCallback((e: React.MouseEvent) => {
141
+ e.preventDefault();
142
+ isSeekingRef.current = true;
143
+ onSeek(getFrameFromClientX(e.clientX));
144
+ }, [getFrameFromClientX, onSeek]);
145
+
146
+ useEffect(() => {
147
+ const handleMouseMove = (e: MouseEvent) => {
148
+ if (!isSeekingRef.current) return;
149
+ onSeek(getFrameFromClientX(e.clientX));
150
+ };
151
+ const handleMouseUp = () => { isSeekingRef.current = false; };
152
+ window.addEventListener('mousemove', handleMouseMove);
153
+ window.addEventListener('mouseup', handleMouseUp);
154
+ return () => {
155
+ window.removeEventListener('mousemove', handleMouseMove);
156
+ window.removeEventListener('mouseup', handleMouseUp);
157
+ };
158
+ }, [getFrameFromClientX, onSeek]);
159
+
160
+ // Block mouse handlers
161
+ const handleBlockMouseDown = useCallback((e: React.MouseEvent, te: TrackEntry) => {
162
+ e.stopPropagation();
163
+ if (e.button !== 0) return;
164
+
165
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
166
+ const relX = e.clientX - rect.left;
167
+ const width = rect.width;
168
+
169
+ let edge: 'body' | 'left' | 'right' = 'body';
170
+ if (relX <= EDGE_HIT_ZONE) edge = 'left';
171
+ else if (relX >= width - EDGE_HIT_ZONE) edge = 'right';
172
+
173
+ setSelectedPath(te.entry.namePath);
174
+ startDrag(te.entry.namePath, edge, e.clientX, e.clientY, te.entry.from, te.entry.durationInFrames, te.trackIndex);
175
+ }, [startDrag]);
176
+
177
+ const handleBlockContextMenu = useCallback((e: React.MouseEvent, te: TrackEntry) => {
178
+ e.preventDefault();
179
+ e.stopPropagation();
180
+ setContextMenu({ x: e.clientX, y: e.clientY, namePath: te.entry.namePath });
181
+ }, []);
182
+
183
+ // Track area click for seeking (when clicking empty space)
184
+ const handleTrackAreaClick = useCallback((e: React.MouseEvent) => {
185
+ if (e.target === e.currentTarget || (e.target as HTMLElement).dataset?.trackBg) {
186
+ onSeek(getFrameFromClientX(e.clientX));
187
+ setSelectedPath(null);
188
+ }
189
+ }, [getFrameFromClientX, onSeek]);
190
+
191
+ // Zoom slider
192
+ const handleZoomChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
193
+ setPixelsPerFrame(Number(e.target.value));
194
+ }, [setPixelsPerFrame]);
195
+
196
+ const playheadX = currentFrame * pixelsPerFrame;
197
+ const hasOverrides = overrides.size > 0;
198
+
199
+ return (
200
+ <div style={containerStyle}>
201
+ {/* Toolbar */}
202
+ <div style={toolbarStyle}>
203
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
204
+ <span style={{ fontSize: 11, color: '#8b949e' }}>{compositionName}</span>
205
+ <span style={{ fontSize: 11, color: '#484f58' }}>|</span>
206
+ <span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'monospace' }}>
207
+ {formatTimecode(currentFrame, fps)} / {formatTimecode(totalFrames, fps)}
208
+ </span>
209
+ </div>
210
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
211
+ {hasOverrides && (
212
+ <button onClick={onOverridesClear} style={resetBtnStyle} title="Reset all overrides">
213
+ Reset All
214
+ </button>
215
+ )}
216
+ <label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#8b949e' }}>
217
+ Zoom
218
+ <input
219
+ type="range"
220
+ min={0.5}
221
+ max={20}
222
+ step={0.1}
223
+ value={pixelsPerFrame}
224
+ onChange={handleZoomChange}
225
+ style={{ width: 80, accentColor: '#58a6ff' }}
226
+ />
227
+ </label>
228
+ <ViewToggle view={view} onChange={onViewChange} />
229
+ </div>
230
+ </div>
231
+
232
+ {/* Main area: labels + tracks + ruler */}
233
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
234
+ {/* Track labels */}
235
+ <div style={labelsContainerStyle}>
236
+ <div style={{ height: RULER_HEIGHT, borderBottom: '1px solid #30363d' }} />
237
+ {tracks.map((track) => (
238
+ <div key={track.id} style={trackLabelStyle}>
239
+ <span style={{ fontSize: 10, color: '#484f58' }}>Track {track.id + 1}</span>
240
+ </div>
241
+ ))}
242
+ {tracks.length === 0 && (
243
+ <div style={{ ...trackLabelStyle, color: '#484f58', fontStyle: 'italic' }}>
244
+ No sequences
245
+ </div>
246
+ )}
247
+ </div>
248
+
249
+ {/* Scrollable track area */}
250
+ <div
251
+ ref={trackAreaRef}
252
+ style={trackAreaContainerStyle}
253
+ onClick={handleTrackAreaClick}
254
+ onScroll={(e) => setScrollLeft((e.target as HTMLElement).scrollLeft)}
255
+ >
256
+ {/* Ruler */}
257
+ <div style={{ ...rulerStyle, width: totalWidth }}>
258
+ {rulerTicks.map((tick, i) => (
259
+ <div
260
+ key={i}
261
+ style={{
262
+ position: 'absolute',
263
+ left: tick.frame * pixelsPerFrame,
264
+ top: 0,
265
+ height: '100%',
266
+ display: 'flex',
267
+ flexDirection: 'column',
268
+ justifyContent: 'flex-end',
269
+ alignItems: 'center',
270
+ }}
271
+ >
272
+ <div style={{
273
+ width: 1,
274
+ height: tick.major ? 10 : 5,
275
+ backgroundColor: tick.major ? '#484f58' : '#30363d',
276
+ }} />
277
+ {tick.label && (
278
+ <span style={{
279
+ position: 'absolute',
280
+ top: 2,
281
+ left: 2,
282
+ fontSize: 9,
283
+ color: '#8b949e',
284
+ fontFamily: 'monospace',
285
+ whiteSpace: 'nowrap',
286
+ pointerEvents: 'none',
287
+ }}>
288
+ {tick.label}
289
+ </span>
290
+ )}
291
+ </div>
292
+ ))}
293
+ {/* Ruler seek overlay */}
294
+ <div
295
+ style={{ position: 'absolute', inset: 0, cursor: 'pointer' }}
296
+ onMouseDown={handleRulerMouseDown}
297
+ />
298
+ </div>
299
+
300
+ {/* Track rows */}
301
+ <div style={{ position: 'relative', width: totalWidth, minHeight: trackAreaHeight }}>
302
+ {/* Track background rows */}
303
+ {tracks.map((track) => (
304
+ <div
305
+ key={track.id}
306
+ data-track-bg="true"
307
+ style={{
308
+ position: 'absolute',
309
+ top: track.id * (TRACK_HEIGHT + TRACK_GAP),
310
+ left: 0,
311
+ width: totalWidth,
312
+ height: TRACK_HEIGHT,
313
+ backgroundColor: track.id % 2 === 0 ? '#161b22' : '#1c2128',
314
+ }}
315
+ />
316
+ ))}
317
+
318
+ {/* Blocks */}
319
+ {tracks.flatMap((track) =>
320
+ track.entries.map((te) => {
321
+ const left = te.entry.from * pixelsPerFrame;
322
+ const width = Math.max(2, te.entry.durationInFrames * pixelsPerFrame);
323
+ const top = te.trackIndex * (TRACK_HEIGHT + TRACK_GAP);
324
+ const isSelected = te.entry.namePath === selectedPath;
325
+ const color = getBlockColor(te.trackIndex);
326
+
327
+ return (
328
+ <div
329
+ key={te.entry.id}
330
+ onMouseDown={(e) => handleBlockMouseDown(e, te)}
331
+ onContextMenu={(e) => handleBlockContextMenu(e, te)}
332
+ title={`${te.entry.namePath}\nFrom: ${te.entry.from} Duration: ${te.entry.durationInFrames}`}
333
+ style={{
334
+ position: 'absolute',
335
+ left,
336
+ top: top + 2,
337
+ width,
338
+ height: TRACK_HEIGHT - 4,
339
+ backgroundColor: color,
340
+ opacity: isSelected ? 1 : 0.75,
341
+ borderRadius: 4,
342
+ border: isSelected ? '1px solid #58a6ff' : '1px solid transparent',
343
+ cursor: 'grab',
344
+ display: 'flex',
345
+ alignItems: 'center',
346
+ overflow: 'hidden',
347
+ userSelect: 'none',
348
+ boxSizing: 'border-box',
349
+ }}
350
+ >
351
+ {/* Left resize handle */}
352
+ <div style={{ ...edgeHandleStyle, left: 0, cursor: 'ew-resize' }} />
353
+
354
+ {/* Label */}
355
+ <span style={{
356
+ flex: 1,
357
+ fontSize: 10,
358
+ color: '#fff',
359
+ padding: '0 8px',
360
+ overflow: 'hidden',
361
+ textOverflow: 'ellipsis',
362
+ whiteSpace: 'nowrap',
363
+ pointerEvents: 'none',
364
+ fontWeight: 500,
365
+ }}>
366
+ {te.entry.name}
367
+ {te.hasOverride && (
368
+ <span style={{ marginLeft: 4, fontSize: 8, opacity: 0.7 }}>*</span>
369
+ )}
370
+ </span>
371
+
372
+ {/* Right resize handle */}
373
+ <div style={{ ...edgeHandleStyle, right: 0, cursor: 'ew-resize' }} />
374
+ </div>
375
+ );
376
+ }),
377
+ )}
378
+
379
+ {/* Playhead */}
380
+ <div style={{
381
+ position: 'absolute',
382
+ left: playheadX,
383
+ top: -RULER_HEIGHT,
384
+ width: 1,
385
+ height: trackAreaHeight + RULER_HEIGHT,
386
+ backgroundColor: '#f85149',
387
+ pointerEvents: 'none',
388
+ zIndex: 10,
389
+ }}>
390
+ {/* Playhead marker */}
391
+ <div style={{
392
+ position: 'absolute',
393
+ top: 0,
394
+ left: -5,
395
+ width: 0,
396
+ height: 0,
397
+ borderLeft: '5px solid transparent',
398
+ borderRight: '5px solid transparent',
399
+ borderTop: '8px solid #f85149',
400
+ }} />
401
+ </div>
402
+ </div>
403
+ </div>
404
+ </div>
405
+
406
+ {/* Selection info bar */}
407
+ {selectedPath && (
408
+ <div style={infoBarStyle}>
409
+ <span style={{ fontSize: 11, color: '#e6edf3', fontFamily: 'monospace' }}>{selectedPath}</span>
410
+ {overrides.has(selectedPath) && (
411
+ <button
412
+ onClick={() => { onOverrideRemove(selectedPath); setSelectedPath(null); }}
413
+ style={resetBtnStyle}
414
+ >
415
+ Reset Position
416
+ </button>
417
+ )}
418
+ </div>
419
+ )}
420
+
421
+ {/* Context menu */}
422
+ {contextMenu && (
423
+ <div style={{ ...contextMenuStyle, left: contextMenu.x, top: contextMenu.y }}>
424
+ {overrides.has(contextMenu.namePath) && (
425
+ <div
426
+ style={contextMenuItemStyle}
427
+ onClick={() => { onOverrideRemove(contextMenu.namePath); setContextMenu(null); }}
428
+ >
429
+ Reset Position
430
+ </div>
431
+ )}
432
+ <div
433
+ style={contextMenuItemStyle}
434
+ onClick={() => { setSelectedPath(contextMenu.namePath); setContextMenu(null); }}
435
+ >
436
+ Select
437
+ </div>
438
+ </div>
439
+ )}
440
+ </div>
441
+ );
442
+ };
443
+
444
+ // --- View Toggle (same style as StudioApp) ---
445
+ const ViewToggle: React.FC<{ view: 'editor' | 'tree'; onChange: (v: 'editor' | 'tree') => void }> = ({ view, onChange }) => (
446
+ <div style={{ display: 'flex', gap: 2, padding: '2px', backgroundColor: '#0d1117', borderRadius: 6 }}>
447
+ {(['editor', 'tree'] as const).map((v) => (
448
+ <button
449
+ key={v}
450
+ onClick={() => onChange(v)}
451
+ style={{
452
+ padding: '3px 10px',
453
+ fontSize: 11,
454
+ fontWeight: 500,
455
+ border: 'none',
456
+ borderRadius: 4,
457
+ cursor: 'pointer',
458
+ backgroundColor: view === v ? '#30363d' : 'transparent',
459
+ color: view === v ? '#e6edf3' : '#8b949e',
460
+ fontFamily: 'system-ui, -apple-system, sans-serif',
461
+ }}
462
+ >
463
+ {v === 'editor' ? 'Tracks' : 'Tree'}
464
+ </button>
465
+ ))}
466
+ </div>
467
+ );
468
+
469
+ // --- Styles ---
470
+
471
+ const containerStyle: React.CSSProperties = {
472
+ display: 'flex',
473
+ flexDirection: 'column',
474
+ height: '100%',
475
+ backgroundColor: '#0d1117',
476
+ color: '#e6edf3',
477
+ fontFamily: 'system-ui, -apple-system, sans-serif',
478
+ fontSize: 13,
479
+ };
480
+
481
+ const toolbarStyle: React.CSSProperties = {
482
+ display: 'flex',
483
+ alignItems: 'center',
484
+ justifyContent: 'space-between',
485
+ height: TOOLBAR_HEIGHT,
486
+ padding: '0 12px',
487
+ backgroundColor: '#161b22',
488
+ borderBottom: '1px solid #30363d',
489
+ flexShrink: 0,
490
+ };
491
+
492
+ const labelsContainerStyle: React.CSSProperties = {
493
+ width: LABEL_WIDTH,
494
+ minWidth: LABEL_WIDTH,
495
+ flexShrink: 0,
496
+ backgroundColor: '#161b22',
497
+ borderRight: '1px solid #30363d',
498
+ overflow: 'hidden',
499
+ };
500
+
501
+ const trackLabelStyle: React.CSSProperties = {
502
+ height: TRACK_HEIGHT + TRACK_GAP,
503
+ display: 'flex',
504
+ alignItems: 'center',
505
+ padding: '0 8px',
506
+ fontSize: 11,
507
+ color: '#8b949e',
508
+ borderBottom: '1px solid #21262d',
509
+ };
510
+
511
+ const trackAreaContainerStyle: React.CSSProperties = {
512
+ flex: 1,
513
+ overflow: 'auto',
514
+ position: 'relative',
515
+ };
516
+
517
+ const rulerStyle: React.CSSProperties = {
518
+ position: 'sticky',
519
+ top: 0,
520
+ height: RULER_HEIGHT,
521
+ backgroundColor: '#161b22',
522
+ borderBottom: '1px solid #30363d',
523
+ zIndex: 5,
524
+ };
525
+
526
+ const edgeHandleStyle: React.CSSProperties = {
527
+ position: 'absolute',
528
+ top: 0,
529
+ width: EDGE_HIT_ZONE,
530
+ height: '100%',
531
+ zIndex: 1,
532
+ };
533
+
534
+ const resetBtnStyle: React.CSSProperties = {
535
+ padding: '2px 8px',
536
+ fontSize: 10,
537
+ fontWeight: 500,
538
+ color: '#f85149',
539
+ backgroundColor: 'transparent',
540
+ border: '1px solid #f8514933',
541
+ borderRadius: 4,
542
+ cursor: 'pointer',
543
+ fontFamily: 'system-ui, -apple-system, sans-serif',
544
+ };
545
+
546
+ const infoBarStyle: React.CSSProperties = {
547
+ display: 'flex',
548
+ alignItems: 'center',
549
+ justifyContent: 'space-between',
550
+ height: 24,
551
+ padding: '0 12px',
552
+ backgroundColor: '#161b22',
553
+ borderTop: '1px solid #30363d',
554
+ flexShrink: 0,
555
+ };
556
+
557
+ const contextMenuStyle: React.CSSProperties = {
558
+ position: 'fixed',
559
+ zIndex: 100,
560
+ backgroundColor: '#1c2128',
561
+ border: '1px solid #30363d',
562
+ borderRadius: 6,
563
+ padding: '4px 0',
564
+ boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
565
+ minWidth: 140,
566
+ };
567
+
568
+ const contextMenuItemStyle: React.CSSProperties = {
569
+ padding: '6px 12px',
570
+ fontSize: 12,
571
+ color: '#e6edf3',
572
+ cursor: 'pointer',
573
+ };
package/ui/TopBar.tsx CHANGED
@@ -9,8 +9,8 @@ interface TopBarProps {
9
9
  entryPoint: string;
10
10
  onRender: () => void;
11
11
  queueCount: number;
12
- queueOpen: boolean;
13
- onToggleQueue: () => void;
12
+ panelOpen: boolean;
13
+ onTogglePanel: () => void;
14
14
  }
15
15
 
16
16
  const RenderIcon: React.FC = () => (
@@ -19,14 +19,14 @@ const RenderIcon: React.FC = () => (
19
19
  </svg>
20
20
  );
21
21
 
22
- const QueueIcon: React.FC<{ open: boolean }> = ({ open }) => (
22
+ const PanelIcon: React.FC<{ open: boolean }> = ({ open }) => (
23
23
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
24
24
  <rect x="10" y="2" width="4" height="12" rx="1" fill={open ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.5" />
25
25
  <rect x="2" y="2" width="6" height="12" rx="1" stroke="currentColor" strokeWidth="1.5" />
26
26
  </svg>
27
27
  );
28
28
 
29
- export const TopBar: React.FC<TopBarProps> = ({ composition, entryPoint, onRender, queueCount, queueOpen, onToggleQueue }) => {
29
+ export const TopBar: React.FC<TopBarProps> = ({ composition, entryPoint, onRender, queueCount, panelOpen, onTogglePanel }) => {
30
30
  const [copied, setCopied] = useState(false);
31
31
 
32
32
  const handleCopyCommand = useCallback(() => {
@@ -94,13 +94,13 @@ export const TopBar: React.FC<TopBarProps> = ({ composition, entryPoint, onRende
94
94
  alignItems: 'center',
95
95
  gap: 6,
96
96
  position: 'relative' as const,
97
- color: queueOpen ? colors.accent : colors.textPrimary,
98
- borderColor: queueOpen ? colors.accent : colors.border,
97
+ color: panelOpen ? colors.accent : colors.textPrimary,
98
+ borderColor: panelOpen ? colors.accent : colors.border,
99
99
  }}
100
- onClick={onToggleQueue}
101
- title="Toggle render queue panel"
100
+ onClick={onTogglePanel}
101
+ title="Toggle side panel"
102
102
  >
103
- <QueueIcon open={queueOpen} />
103
+ <PanelIcon open={panelOpen} />
104
104
  {queueCount > 0 && (
105
105
  <span style={badgeStyle}>{queueCount}</span>
106
106
  )}