@hyperframes/studio 0.6.85 → 0.6.87

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 (88) hide show
  1. package/dist/assets/{hyperframes-player-DRpY3xHh.js → hyperframes-player-0esDKGRk.js} +1 -1
  2. package/dist/assets/index-BA19FAPN.js +143 -0
  3. package/dist/assets/index-CGlIm_-E.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +159 -6
  7. package/src/components/StudioHeader.tsx +20 -7
  8. package/src/components/StudioPreviewArea.tsx +6 -1
  9. package/src/components/StudioRightPanel.tsx +13 -0
  10. package/src/components/StudioToast.tsx +47 -7
  11. package/src/components/TimelineToolbar.tsx +12 -122
  12. package/src/components/editor/AnimationCard.tsx +64 -10
  13. package/src/components/editor/ArcPathControls.tsx +131 -0
  14. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  15. package/src/components/editor/DomEditOverlay.tsx +70 -11
  16. package/src/components/editor/DopesheetStrip.tsx +141 -0
  17. package/src/components/editor/EaseCurveSection.tsx +82 -7
  18. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  20. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  21. package/src/components/editor/LayersPanel.tsx +14 -12
  22. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  23. package/src/components/editor/PropertyPanel.tsx +196 -66
  24. package/src/components/editor/SourceEditor.tsx +0 -1
  25. package/src/components/editor/StaggerControls.tsx +61 -0
  26. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  27. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  28. package/src/components/editor/domEditing.test.ts +43 -0
  29. package/src/components/editor/domEditing.ts +2 -0
  30. package/src/components/editor/domEditingElement.ts +25 -2
  31. package/src/components/editor/domEditingLayers.test.ts +78 -0
  32. package/src/components/editor/domEditingLayers.ts +33 -13
  33. package/src/components/editor/domEditingTypes.ts +1 -0
  34. package/src/components/editor/manualEditingAvailability.ts +1 -1
  35. package/src/components/editor/manualEdits.ts +3 -0
  36. package/src/components/editor/manualEditsDom.ts +23 -5
  37. package/src/components/editor/manualOffsetDrag.ts +59 -0
  38. package/src/components/editor/panelTokens.ts +10 -0
  39. package/src/components/editor/propertyPanelColor.tsx +2 -2
  40. package/src/components/editor/propertyPanelFill.tsx +1 -1
  41. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  42. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  43. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  44. package/src/components/editor/propertyPanelSections.tsx +4 -6
  45. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  46. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  47. package/src/components/renders/RenderQueue.tsx +121 -100
  48. package/src/components/renders/RenderQueueItem.tsx +13 -13
  49. package/src/contexts/DomEditContext.tsx +12 -0
  50. package/src/contexts/FileManagerContext.tsx +3 -0
  51. package/src/contexts/StudioContext.tsx +0 -4
  52. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  53. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  54. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  55. package/src/hooks/gsapRuntimePreview.ts +19 -0
  56. package/src/hooks/useAppHotkeys.ts +18 -0
  57. package/src/hooks/useAskAgentModal.ts +2 -4
  58. package/src/hooks/useDomEditCommits.ts +11 -17
  59. package/src/hooks/useDomEditSession.ts +47 -4
  60. package/src/hooks/useEnableKeyframes.ts +171 -0
  61. package/src/hooks/useFileManager.ts +7 -0
  62. package/src/hooks/useGestureRecording.ts +340 -0
  63. package/src/hooks/useGsapScriptCommits.ts +171 -35
  64. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  65. package/src/hooks/useGsapTweenCache.ts +169 -11
  66. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  67. package/src/hooks/useStudioContextValue.ts +5 -4
  68. package/src/hooks/useStudioUrlState.ts +1 -2
  69. package/src/hooks/useTimelineEditing.ts +50 -3
  70. package/src/hooks/useToast.ts +6 -1
  71. package/src/player/components/ShortcutsPanel.tsx +40 -0
  72. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  73. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  74. package/src/player/lib/timelineDOM.test.ts +55 -0
  75. package/src/player/lib/timelineDOM.ts +13 -0
  76. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  77. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  78. package/src/player/store/playerStore.ts +43 -0
  79. package/src/utils/audioBeatDetection.ts +58 -0
  80. package/src/utils/globalTimeCompiler.test.ts +169 -0
  81. package/src/utils/globalTimeCompiler.ts +77 -0
  82. package/src/utils/gsapSoftReload.ts +30 -10
  83. package/src/utils/keyframeSnapping.test.ts +74 -0
  84. package/src/utils/keyframeSnapping.ts +63 -0
  85. package/src/utils/rdpSimplify.ts +183 -0
  86. package/src/utils/sourcePatcher.ts +2 -0
  87. package/dist/assets/index-DHcptK1_.css +0 -1
  88. package/dist/assets/index-DtSCUvYQ.js +0 -140
@@ -257,9 +257,9 @@ export function SliderControl({
257
257
  onMouseUp={() => commitDraft(draft)}
258
258
  onTouchEnd={() => commitDraft(draft)}
259
259
  onBlur={() => commitDraft(draft)}
260
- className="h-2 min-w-0 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-[#3ce6ac] disabled:cursor-not-allowed"
260
+ className="h-4 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
261
261
  />
262
- <div className="min-w-[52px] rounded-xl border border-neutral-800 bg-neutral-900 px-2 py-2 text-right text-[11px] font-medium text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
262
+ <div className="min-w-[44px] rounded-md bg-panel-input px-2 py-1.5 text-right text-[11px] font-medium text-panel-text-1 tabular-nums">
263
263
  {formatDisplayValue?.(draft) ?? displayValue}
264
264
  </div>
265
265
  </div>
@@ -279,7 +279,7 @@ export function SegmentedControl({
279
279
  }) {
280
280
  return (
281
281
  <div
282
- className="grid min-w-0 gap-1 rounded-xl bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
282
+ className="grid min-w-0 gap-[2px] rounded-md bg-panel-input p-[2px]"
283
283
  style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
284
284
  >
285
285
  {options.map((option) => (
@@ -288,10 +288,10 @@ export function SegmentedControl({
288
288
  type="button"
289
289
  disabled={disabled}
290
290
  onClick={() => onChange(option.value)}
291
- className={`min-w-0 truncate rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
291
+ className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
292
292
  option.value === value
293
- ? "bg-neutral-800 text-white shadow-[0_1px_3px_rgba(0,0,0,0.28)]"
294
- : "text-neutral-500 hover:text-neutral-200"
293
+ ? "bg-panel-hover text-white"
294
+ : "text-panel-text-4 hover:text-panel-text-2"
295
295
  }`}
296
296
  >
297
297
  {option.label}
@@ -336,7 +336,7 @@ export function SelectField({
336
336
 
337
337
  export function Section({
338
338
  title,
339
- icon,
339
+ icon: _icon,
340
340
  children,
341
341
  accessory,
342
342
  defaultCollapsed = false,
@@ -350,32 +350,45 @@ export function Section({
350
350
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
351
351
 
352
352
  return (
353
- <section className="min-w-0 border-t border-neutral-800/80">
353
+ <section className="min-w-0 border-t border-panel-border">
354
354
  <button
355
355
  type="button"
356
356
  onClick={() => setCollapsed((v) => !v)}
357
- className="flex w-full items-center justify-between gap-2 px-4 py-3"
357
+ className="flex w-full items-center justify-between gap-2 px-4 py-2.5"
358
358
  >
359
- <div className="flex min-w-0 items-center gap-2.5">
360
- <span className="flex-shrink-0 text-neutral-500">{icon}</span>
361
- <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
362
- {title}
363
- </h3>
364
- </div>
359
+ <h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
365
360
  <div className="flex items-center gap-2">
366
361
  {accessory}
367
- <svg
368
- width="10"
369
- height="10"
370
- viewBox="0 0 10 10"
371
- fill="currentColor"
372
- className={`flex-shrink-0 text-neutral-500 transition-transform ${collapsed ? "-rotate-90" : ""}`}
373
- >
374
- <path d="M2 3l3 4 3-4z" />
375
- </svg>
362
+ {collapsed && (
363
+ <svg
364
+ width="12"
365
+ height="12"
366
+ viewBox="0 0 12 12"
367
+ fill="none"
368
+ className="flex-shrink-0 text-panel-text-5"
369
+ >
370
+ <path
371
+ d="M6 2.5v7M2.5 6h7"
372
+ stroke="currentColor"
373
+ strokeWidth="1.2"
374
+ strokeLinecap="round"
375
+ />
376
+ </svg>
377
+ )}
378
+ {!collapsed && (
379
+ <svg
380
+ width="10"
381
+ height="10"
382
+ viewBox="0 0 10 10"
383
+ fill="currentColor"
384
+ className="flex-shrink-0 text-panel-text-5"
385
+ >
386
+ <path d="M2 3l3 4 3-4z" />
387
+ </svg>
388
+ )}
376
389
  </div>
377
390
  </button>
378
- {!collapsed && <div className="px-4 pb-4">{children}</div>}
391
+ {!collapsed && <div className="px-4 pb-3">{children}</div>}
379
392
  </section>
380
393
  );
381
394
  }
@@ -262,15 +262,13 @@ function TextFieldEditor({
262
262
  onRemoveTextField: (fieldKey: string) => void;
263
263
  }) {
264
264
  return (
265
- <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
265
+ <div className="space-y-3">
266
266
  <div className={showRemove ? "flex min-w-0 items-center justify-between gap-2" : "min-w-0"}>
267
267
  <div className="min-w-0">
268
268
  <div className="truncate text-[11px] font-medium text-neutral-100">
269
269
  {formatTextFieldPreview(field.value) || "Text"}
270
270
  </div>
271
- <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
272
- {field.tagName}
273
- </div>
271
+ <div className="text-[10px] text-neutral-500">{field.tagName}</div>
274
272
  </div>
275
273
  {showRemove && (
276
274
  <button
@@ -368,7 +366,7 @@ export function TextSection({
368
366
 
369
367
  if (textFields.length === 1) {
370
368
  return (
371
- <Section title="Text" icon={<Type size={15} />}>
369
+ <Section title="Text" icon={<Type size={15} />} defaultCollapsed>
372
370
  <TextFieldEditor
373
371
  field={activeField}
374
372
  styles={styles}
@@ -426,7 +424,7 @@ export function TextSection({
426
424
  {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
427
425
  </span>
428
426
  </div>
429
- <span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-neutral-500">
427
+ <span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
430
428
  {field.tagName}
431
429
  </span>
432
430
  </div>
@@ -33,6 +33,7 @@ import {
33
33
  } from "./propertyPanelPrimitives";
34
34
  import { ColorField } from "./propertyPanelColor";
35
35
  import { GradientField, ImageFillField } from "./propertyPanelFill";
36
+ import { BorderRadiusEditor } from "./BorderRadiusEditor";
36
37
 
37
38
  export function StyleSections({
38
39
  projectId,
@@ -41,6 +42,7 @@ export function StyleSections({
41
42
  assets,
42
43
  onSetStyle,
43
44
  onImportAssets,
45
+ gsapBorderRadius,
44
46
  }: {
45
47
  projectId: string;
46
48
  element: DomEditSelection;
@@ -48,10 +50,19 @@ export function StyleSections({
48
50
  assets: string[];
49
51
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
50
52
  onImportAssets?: (files: FileList) => Promise<string[]>;
53
+ gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
51
54
  }) {
52
55
  const styleEditingDisabled = !element.capabilities.canEditStyles;
53
56
  const isFlex = styles.display === "flex" || styles.display === "inline-flex";
54
57
  const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
58
+ const radiusTL =
59
+ gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue;
60
+ const radiusTR =
61
+ gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue;
62
+ const radiusBR =
63
+ gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
64
+ const radiusBL =
65
+ gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
55
66
  const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
56
67
  const borderWidthValue =
57
68
  parsePxMetricValue(styles["border-width"] ?? "") ??
@@ -155,15 +166,26 @@ export function StyleSections({
155
166
 
156
167
  {hasVisualBackground && (
157
168
  <Section title="Radius" icon={<Settings size={15} />} defaultCollapsed>
158
- <SliderControl
159
- value={radiusValue}
160
- min={0}
161
- max={Math.max(240, Math.ceil(radiusValue))}
162
- step={1}
169
+ <BorderRadiusEditor
170
+ tl={radiusTL}
171
+ tr={radiusTR}
172
+ br={radiusBR}
173
+ bl={radiusBL}
163
174
  disabled={styleEditingDisabled}
164
- displayValue={`${formatNumericValue(radiusValue)}px`}
165
- formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
166
- onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
175
+ onCommit={(corner, value) => {
176
+ const px = `${formatNumericValue(value)}px`;
177
+ if (corner === "all") {
178
+ onSetStyle("border-radius", px);
179
+ } else {
180
+ const prop = {
181
+ tl: "border-top-left-radius",
182
+ tr: "border-top-right-radius",
183
+ br: "border-bottom-right-radius",
184
+ bl: "border-bottom-left-radius",
185
+ }[corner];
186
+ onSetStyle(prop, px);
187
+ }
188
+ }}
167
189
  />
168
190
  </Section>
169
191
  )}
@@ -17,6 +17,14 @@ import {
17
17
  toOverlayRect,
18
18
  } from "./domEditOverlayGeometry";
19
19
 
20
+ function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
21
+ if (a.length !== b.length) return false;
22
+ for (let i = 0; i < a.length; i++) {
23
+ if (!rectsEqual(a[i]!, b[i]!)) return false;
24
+ }
25
+ return true;
26
+ }
27
+
20
28
  interface UseDomEditOverlayRectsOptions {
21
29
  iframeRef: RefObject<HTMLIFrameElement | null>;
22
30
  overlayRef: RefObject<HTMLDivElement | null>;
@@ -37,6 +45,7 @@ interface UseDomEditOverlayRectsResult {
37
45
  groupOverlayItems: GroupOverlayItem[];
38
46
  groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
39
47
  setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
48
+ childRects: OverlayRect[];
40
49
  }
41
50
 
42
51
  export function useDomEditOverlayRects({
@@ -51,6 +60,7 @@ export function useDomEditOverlayRects({
51
60
  const [overlayRect, setOverlayRectState] = useState<OverlayRect | null>(null);
52
61
  const [hoverRect, setHoverRectState] = useState<OverlayRect | null>(null);
53
62
  const [groupOverlayItems, setGroupOverlayItemsState] = useState<GroupOverlayItem[]>([]);
63
+ const [childRects, setChildRectsState] = useState<OverlayRect[]>([]);
54
64
 
55
65
  const overlayRectRef = useRef<OverlayRect | null>(null);
56
66
  const hoverRectRef = useRef<OverlayRect | null>(null);
@@ -58,6 +68,7 @@ export function useDomEditOverlayRects({
58
68
  const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
59
69
  const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
60
70
  const resolvedGroupElementRef = useRef<Map<string, HTMLElement>>(new Map());
71
+ const childRectsRef = useRef<OverlayRect[]>([]);
61
72
 
62
73
  const setOverlayRect = (next: OverlayRect | null) => {
63
74
  if (rectsEqual(overlayRectRef.current, next)) return;
@@ -102,7 +113,13 @@ export function useDomEditOverlayRects({
102
113
 
103
114
  const update = () => {
104
115
  frame = requestAnimationFrame(update);
105
- if (rafPausedRef.current) return;
116
+ if (rafPausedRef.current) {
117
+ if (childRectsRef.current.length > 0) {
118
+ childRectsRef.current = [];
119
+ setChildRectsState([]);
120
+ }
121
+ return;
122
+ }
106
123
 
107
124
  const sel = selectionRef.current;
108
125
  const iframe = iframeRef.current;
@@ -132,13 +149,39 @@ export function useDomEditOverlayRects({
132
149
  resolvedElementRef as ResolvedElementRef,
133
150
  );
134
151
  if (el && isElementVisibleForOverlay(el)) {
135
- setOverlayRect(toOverlayRect(overlayEl, iframe, el));
152
+ const nextRect = toOverlayRect(overlayEl, iframe, el);
153
+ setOverlayRect(nextRect);
154
+ const descendants = el.querySelectorAll("*");
155
+ if (descendants.length > 0 && descendants.length <= 60) {
156
+ const nextChildRects: OverlayRect[] = [];
157
+ for (let i = 0; i < descendants.length; i++) {
158
+ const child = descendants[i] as HTMLElement;
159
+ if (!child.getBoundingClientRect) continue;
160
+ const r = toOverlayRect(overlayEl, iframe, child);
161
+ if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
162
+ }
163
+ if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
164
+ childRectsRef.current = nextChildRects;
165
+ setChildRectsState(nextChildRects);
166
+ }
167
+ } else if (childRectsRef.current.length > 0) {
168
+ childRectsRef.current = [];
169
+ setChildRectsState([]);
170
+ }
136
171
  } else {
137
172
  setOverlayRect(null);
173
+ if (childRectsRef.current.length > 0) {
174
+ childRectsRef.current = [];
175
+ setChildRectsState([]);
176
+ }
138
177
  }
139
178
  } else {
140
179
  resolvedElementRef.current = null;
141
180
  setOverlayRect(null);
181
+ if (childRectsRef.current.length > 0) {
182
+ childRectsRef.current = [];
183
+ setChildRectsState([]);
184
+ }
142
185
  }
143
186
 
144
187
  const group = groupSelectionsRef.current;
@@ -203,5 +246,6 @@ export function useDomEditOverlayRects({
203
246
  groupOverlayItems,
204
247
  groupOverlayItemsRef,
205
248
  setGroupOverlayItems,
249
+ childRects,
206
250
  };
207
251
  }
@@ -154,22 +154,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
154
154
  strokeWidth="2"
155
155
  strokeLinecap="round"
156
156
  strokeLinejoin="round"
157
- className="text-neutral-600 hover:text-neutral-400 transition-colors cursor-help"
157
+ className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
158
158
  >
159
159
  <circle cx="12" cy="12" r="10" />
160
160
  <path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
161
161
  <line x1="12" y1="17" x2="12.01" y2="17" />
162
162
  </svg>
163
163
  {open && (
164
- <div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-neutral-900 border border-neutral-700 shadow-lg z-50">
165
- <p className="text-[10px] font-semibold text-neutral-200 mb-0.5">{info.label}</p>
166
- <p className="text-[9px] text-neutral-400 leading-tight">{info.desc}</p>
164
+ <div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50">
165
+ <p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
166
+ <p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
167
167
  <div className="mt-1.5 pt-1.5 border-t border-neutral-800">
168
168
  {(["mp4", "mov", "webm"] as const)
169
169
  .filter((f) => f !== format)
170
170
  .map((f) => (
171
- <p key={f} className="text-[9px] text-neutral-500 leading-relaxed">
172
- <span className="text-neutral-400 font-medium">{FORMAT_INFO[f].label}</span>
171
+ <p key={f} className="text-[9px] text-panel-text-4 leading-relaxed">
172
+ <span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
173
173
  {" — "}
174
174
  {FORMAT_INFO[f].desc}
175
175
  </p>
@@ -209,80 +209,97 @@ function FormatExportButton({
209
209
  // MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
210
210
  const showQuality = format !== "mov";
211
211
 
212
+ const selectCls =
213
+ "h-7 w-full px-2 text-[11px] bg-panel-input rounded-md text-panel-text-1 outline-none cursor-pointer disabled:opacity-50 hover:bg-panel-hover transition-colors";
214
+
212
215
  return (
213
- <div className="flex items-center gap-1 flex-wrap justify-end">
214
- <FormatInfoTooltip format={format} />
215
- {/* Resolution must remain the leftmost <select> in this row — it
216
- carries `rounded-l` for the joined-button look. If you ever hide it
217
- (feature-flag, etc.), move `rounded-l` to whichever element ends up
218
- leftmost. */}
219
- <select
220
- value={resolution}
221
- onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
222
- disabled={isRendering}
223
- className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
224
- >
225
- {SCALE_OPTION_ORDER.map((value) => (
226
- <option key={value} value={value} disabled={!scaleApplies(value, compositionDimensions)}>
227
- {scaleOptionLabel(value, compositionDimensions)}
228
- </option>
229
- ))}
230
- </select>
231
- {showQuality && (
232
- <select
233
- value={quality}
234
- onChange={(e) => {
235
- const v = e.target.value as "draft" | "standard" | "high";
236
- setQuality(v);
237
- persistRenderSettings(format, v, fps);
238
- }}
239
- disabled={isRendering}
240
- title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
241
- className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
242
- >
243
- {QUALITY_OPTIONS.map((q) => (
244
- <option key={q.value} value={q.value} title={q.title}>
245
- {q.label}
246
- </option>
247
- ))}
248
- </select>
249
- )}
250
- <select
251
- value={fps}
252
- onChange={(e) => {
253
- const v = Number(e.target.value) as 24 | 30 | 60;
254
- setFps(v);
255
- persistRenderSettings(format, quality, v);
256
- }}
257
- disabled={isRendering}
258
- title="Frames per second"
259
- className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
260
- >
261
- <option value={24}>24fps</option>
262
- <option value={30}>30fps</option>
263
- <option value={60}>60fps</option>
264
- </select>
265
- <select
266
- value={format}
267
- onChange={(e) => {
268
- const v = e.target.value as "mp4" | "webm" | "mov";
269
- setFormat(v);
270
- persistRenderSettings(v, quality, fps);
271
- }}
272
- disabled={isRendering}
273
- className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
274
- >
275
- <option value="mp4">MP4</option>
276
- <option value="mov">MOV</option>
277
- <option value="webm">WebM</option>
278
- </select>
216
+ <div className="flex flex-col gap-3">
217
+ <div className="grid grid-cols-2 gap-2">
218
+ <div className="flex flex-col gap-1">
219
+ <div className="flex items-center gap-1">
220
+ <span className="text-[10px] text-panel-text-4">Format</span>
221
+ <FormatInfoTooltip format={format} />
222
+ </div>
223
+ <select
224
+ value={format}
225
+ onChange={(e) => {
226
+ const v = e.target.value as "mp4" | "webm" | "mov";
227
+ setFormat(v);
228
+ persistRenderSettings(v, quality, fps);
229
+ }}
230
+ disabled={isRendering}
231
+ className={selectCls}
232
+ >
233
+ <option value="mp4">MP4</option>
234
+ <option value="mov">MOV (ProRes)</option>
235
+ <option value="webm">WebM</option>
236
+ </select>
237
+ </div>
238
+ <div className="flex flex-col gap-1">
239
+ <span className="text-[10px] text-panel-text-4">Resolution</span>
240
+ <select
241
+ value={resolution}
242
+ onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
243
+ disabled={isRendering}
244
+ className={selectCls}
245
+ >
246
+ {SCALE_OPTION_ORDER.map((value) => (
247
+ <option
248
+ key={value}
249
+ value={value}
250
+ disabled={!scaleApplies(value, compositionDimensions)}
251
+ >
252
+ {scaleOptionLabel(value, compositionDimensions)}
253
+ </option>
254
+ ))}
255
+ </select>
256
+ </div>
257
+ <div className="flex flex-col gap-1">
258
+ <span className="text-[10px] text-panel-text-4">Frame rate</span>
259
+ <select
260
+ value={fps}
261
+ onChange={(e) => {
262
+ const v = Number(e.target.value) as 24 | 30 | 60;
263
+ setFps(v);
264
+ persistRenderSettings(format, quality, v);
265
+ }}
266
+ disabled={isRendering}
267
+ className={selectCls}
268
+ >
269
+ <option value={24}>24 fps</option>
270
+ <option value={30}>30 fps</option>
271
+ <option value={60}>60 fps</option>
272
+ </select>
273
+ </div>
274
+ {showQuality && (
275
+ <div className="flex flex-col gap-1">
276
+ <span className="text-[10px] text-panel-text-4">Quality</span>
277
+ <select
278
+ value={quality}
279
+ onChange={(e) => {
280
+ const v = e.target.value as "draft" | "standard" | "high";
281
+ setQuality(v);
282
+ persistRenderSettings(format, v, fps);
283
+ }}
284
+ disabled={isRendering}
285
+ className={selectCls}
286
+ >
287
+ {QUALITY_OPTIONS.map((q) => (
288
+ <option key={q.value} value={q.value}>
289
+ {q.label}
290
+ </option>
291
+ ))}
292
+ </select>
293
+ </div>
294
+ )}
295
+ </div>
279
296
  <button
280
297
  onClick={() => {
281
298
  trackStudioEvent("render_start", { format, quality, resolution, fps });
282
299
  void onStartRender(format, quality, resolution, fps);
283
300
  }}
284
301
  disabled={isRendering}
285
- className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
302
+ className="w-full flex items-center justify-center h-8 text-[11px] font-semibold rounded-md bg-panel-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
286
303
  >
287
304
  {isRendering ? "Rendering..." : "Export"}
288
305
  </button>
@@ -313,23 +330,12 @@ export const RenderQueue = memo(function RenderQueue({
313
330
 
314
331
  return (
315
332
  <div className="flex flex-col h-full">
316
- {/* Header no title, already shown in header button */}
317
- <div className="flex items-center justify-end flex-wrap gap-y-1.5 px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
318
- <div className="flex items-center gap-1.5">
319
- {completedCount > 0 && (
320
- <button
321
- onClick={onClearCompleted}
322
- className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
323
- >
324
- Clear
325
- </button>
326
- )}
327
- <FormatExportButton
328
- onStartRender={onStartRender}
329
- isRendering={isRendering}
330
- compositionDimensions={compositionDimensions}
331
- />
332
- </div>
333
+ <div className="px-3 py-3 border-b border-panel-border flex-shrink-0">
334
+ <FormatExportButton
335
+ onStartRender={onStartRender}
336
+ isRendering={isRendering}
337
+ compositionDimensions={compositionDimensions}
338
+ />
333
339
  </div>
334
340
 
335
341
  {/* Job list */}
@@ -343,7 +349,7 @@ export const RenderQueue = memo(function RenderQueue({
343
349
  fill="none"
344
350
  stroke="currentColor"
345
351
  strokeWidth="1.5"
346
- className="text-neutral-700"
352
+ className="text-panel-text-5"
347
353
  >
348
354
  <rect
349
355
  x="2"
@@ -361,17 +367,32 @@ export const RenderQueue = memo(function RenderQueue({
361
367
  strokeLinejoin="round"
362
368
  />
363
369
  </svg>
364
- <p className="text-[10px] text-neutral-600 text-center">No renders yet</p>
370
+ <p className="text-[10px] text-panel-text-5 text-center">No renders yet</p>
365
371
  </div>
366
372
  ) : (
367
- jobs.map((job) => (
368
- <RenderQueueItem
369
- key={job.id}
370
- job={job}
371
- projectId={projectId}
372
- onDelete={() => onDelete(job.id)}
373
- />
374
- ))
373
+ <div>
374
+ {completedCount > 0 && (
375
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-panel-border">
376
+ <span className="text-[10px] text-panel-text-4">
377
+ {jobs.length} render{jobs.length === 1 ? "" : "s"}
378
+ </span>
379
+ <button
380
+ onClick={onClearCompleted}
381
+ className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
382
+ >
383
+ Clear
384
+ </button>
385
+ </div>
386
+ )}
387
+ {jobs.map((job) => (
388
+ <RenderQueueItem
389
+ key={job.id}
390
+ job={job}
391
+ projectId={projectId}
392
+ onDelete={() => onDelete(job.id)}
393
+ />
394
+ ))}
395
+ </div>
375
396
  )}
376
397
  </div>
377
398
  </div>