@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -2,6 +2,11 @@ import { memo, useState, useRef, useEffect } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
3
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
4
4
 
5
+ export interface CompositionDimensions {
6
+ width: number;
7
+ height: number;
8
+ }
9
+
5
10
  type StartRenderHandler = (
6
11
  format: "mp4" | "webm" | "mov",
7
12
  quality: "draft" | "standard" | "high",
@@ -16,37 +21,97 @@ interface RenderQueueProps {
16
21
  onClearCompleted: () => void;
17
22
  onStartRender: StartRenderHandler;
18
23
  isRendering: boolean;
24
+ /**
25
+ * Authored dimensions of the active composition. Used to pick the
26
+ * matching preset (landscape / portrait / square) when the user selects
27
+ * a 1080p or 4K scale. `null` falls back to landscape (legacy default).
28
+ */
29
+ compositionDimensions?: CompositionDimensions | null;
19
30
  }
20
31
 
21
- // Indexing the table by `ResolutionPreset | "auto"` makes adding a new preset
22
- // to `core.types` (e.g. an 8K row) a TypeScript error here instead of a
23
- // silently missing dropdown entry. Order is fixed by the array below.
24
- const RESOLUTION_LABELS: Record<ResolutionPreset | "auto", { label: string; title: string }> = {
25
- auto: { label: "Auto", title: "Render at the composition's authored resolution" },
26
- landscape: { label: "1080p ↔", title: "1920×1080 landscape" },
27
- portrait: { label: "1080p ↕", title: "1080×1920 portrait" },
28
- "landscape-4k": {
29
- label: "4K ↔",
30
- title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
31
- },
32
- "portrait-4k": {
33
- label: "4K ↕",
34
- title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
35
- },
32
+ // Orientation is derived from the composition's authored aspect ratio,
33
+ // not chosen by the user picking "1080p portrait" for a landscape comp
34
+ // would just produce a wrong-aspect render.
35
+ type RenderScale = "auto" | "1080p" | "4k";
36
+
37
+ const SCALE_OPTION_ORDER: RenderScale[] = ["auto", "1080p", "4k"];
38
+
39
+ const SCALE_LABEL: Record<RenderScale, string> = {
40
+ auto: "Auto",
41
+ "1080p": "1080p",
42
+ "4k": "4K",
36
43
  };
37
44
 
38
- const RESOLUTION_OPTION_ORDER: (ResolutionPreset | "auto")[] = [
39
- "auto",
40
- "landscape",
41
- "portrait",
42
- "landscape-4k",
43
- "portrait-4k",
44
- ];
45
+ // Mirrors `CANVAS_DIMENSIONS` in @hyperframes/core. Studio can't import from
46
+ // the core barrel (it transitively pulls in node:fs) and the values are stable.
47
+ const CANVAS_DIMENSIONS: Record<ResolutionPreset, CompositionDimensions> = {
48
+ landscape: { width: 1920, height: 1080 },
49
+ portrait: { width: 1080, height: 1920 },
50
+ "landscape-4k": { width: 3840, height: 2160 },
51
+ "portrait-4k": { width: 2160, height: 3840 },
52
+ square: { width: 1080, height: 1080 },
53
+ "square-4k": { width: 2160, height: 2160 },
54
+ };
55
+
56
+ type CompAspect = "landscape" | "portrait" | "square";
45
57
 
46
- const RESOLUTION_OPTIONS = RESOLUTION_OPTION_ORDER.map((value) => ({
47
- value,
48
- ...RESOLUTION_LABELS[value],
49
- }));
58
+ function compAspect(dims: CompositionDimensions | null | undefined): CompAspect {
59
+ // Missing dims fall through to landscape (legacy default — "landscape" was
60
+ // the first preset). Studio shows resolved dims inline, so the user can see
61
+ // when this fallback is in effect.
62
+ if (dims == null) return "landscape";
63
+ if (dims.width === dims.height) return "square";
64
+ return dims.height > dims.width ? "portrait" : "landscape";
65
+ }
66
+
67
+ function resolveResolution(
68
+ scale: RenderScale,
69
+ dims: CompositionDimensions | null | undefined,
70
+ ): ResolutionPreset | "auto" {
71
+ if (scale === "auto") return "auto";
72
+ const aspect = compAspect(dims);
73
+ if (scale === "1080p") return aspect;
74
+ return aspect === "landscape"
75
+ ? "landscape-4k"
76
+ : aspect === "portrait"
77
+ ? "portrait-4k"
78
+ : "square-4k";
79
+ }
80
+
81
+ function resolvedDimensions(
82
+ scale: RenderScale,
83
+ dims: CompositionDimensions | null | undefined,
84
+ ): CompositionDimensions | null {
85
+ if (scale === "auto") return dims ?? null;
86
+ const preset = resolveResolution(scale, dims);
87
+ return preset === "auto" ? null : CANVAS_DIMENSIONS[preset];
88
+ }
89
+
90
+ // Mirrors the producer's resolveDeviceScaleFactor validation
91
+ // (renderOrchestrator.ts:608): the chosen preset must match the comp's aspect
92
+ // ratio exactly (cross-multiplied), can't downsample, and must be an integer
93
+ // scale factor. Without this guard the user can pick a preset that throws at
94
+ // render time — e.g. 1080p on a 1080×1080 square or 1080p on a 1280×720 comp
95
+ // (1.5× isn't integer).
96
+ function scaleApplies(scale: RenderScale, dims: CompositionDimensions | null | undefined): boolean {
97
+ if (scale === "auto" || dims == null) return true;
98
+ const preset = resolveResolution(scale, dims);
99
+ if (preset === "auto") return true;
100
+ const target = CANVAS_DIMENSIONS[preset];
101
+ if (target.width * dims.height !== target.height * dims.width) return false;
102
+ if (target.width < dims.width) return false;
103
+ return Number.isInteger(target.width / dims.width);
104
+ }
105
+
106
+ function scaleOptionLabel(
107
+ scale: RenderScale,
108
+ dims: CompositionDimensions | null | undefined,
109
+ ): string {
110
+ const resolved = resolvedDimensions(scale, dims);
111
+ return resolved
112
+ ? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
113
+ : SCALE_LABEL[scale];
114
+ }
50
115
 
51
116
  const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
52
117
  mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
@@ -127,9 +192,11 @@ const QUALITY_OPTIONS: {
127
192
  function FormatExportButton({
128
193
  onStartRender,
129
194
  isRendering,
195
+ compositionDimensions,
130
196
  }: {
131
197
  onStartRender: StartRenderHandler;
132
198
  isRendering: boolean;
199
+ compositionDimensions?: CompositionDimensions | null;
133
200
  }) {
134
201
  const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
135
202
  const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
@@ -150,12 +217,11 @@ function FormatExportButton({
150
217
  value={resolution}
151
218
  onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
152
219
  disabled={isRendering}
153
- title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title}
154
220
  className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
155
221
  >
156
- {RESOLUTION_OPTIONS.map((r) => (
157
- <option key={r.value} value={r.value} title={r.title}>
158
- {r.label}
222
+ {SCALE_OPTION_ORDER.map((value) => (
223
+ <option key={value} value={value} disabled={!scaleApplies(value, compositionDimensions)}>
224
+ {scaleOptionLabel(value, compositionDimensions)}
159
225
  </option>
160
226
  ))}
161
227
  </select>
@@ -215,6 +281,7 @@ export const RenderQueue = memo(function RenderQueue({
215
281
  onClearCompleted,
216
282
  onStartRender,
217
283
  isRendering,
284
+ compositionDimensions,
218
285
  }: RenderQueueProps) {
219
286
  const listRef = useRef<HTMLDivElement>(null);
220
287
 
@@ -241,7 +308,11 @@ export const RenderQueue = memo(function RenderQueue({
241
308
  Clear
242
309
  </button>
243
310
  )}
244
- <FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
311
+ <FormatExportButton
312
+ onStartRender={onStartRender}
313
+ isRendering={isRendering}
314
+ compositionDimensions={compositionDimensions}
315
+ />
245
316
  </div>
246
317
  </div>
247
318
 
@@ -14,8 +14,14 @@ export interface RenderJob {
14
14
  // Mirrors `CanvasResolution` from @hyperframes/core. Kept local because
15
15
  // studio's tsconfig doesn't include node types, and the core barrel
16
16
  // transitively pulls in modules with `node:fs` imports. Drift risk is
17
- // low (4 string literals tied to a stable enum).
18
- export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
17
+ // low (6 string literals kept in sync manually with CANVAS_DIMENSIONS).
18
+ export type ResolutionPreset =
19
+ | "landscape"
20
+ | "portrait"
21
+ | "landscape-4k"
22
+ | "portrait-4k"
23
+ | "square"
24
+ | "square-4k";
19
25
 
20
26
  export interface StartRenderOptions {
21
27
  fps?: number;
@@ -1,10 +1,20 @@
1
- import { memo, useState, useCallback, type ReactNode } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
1
+ import {
2
+ memo,
3
+ useState,
4
+ useCallback,
5
+ useImperativeHandle,
6
+ forwardRef,
7
+ type ReactNode,
8
+ } from "react";
3
9
  import { CompositionsTab } from "./CompositionsTab";
4
10
  import { AssetsTab } from "./AssetsTab";
5
11
  import { FileTree } from "../editor/FileTree";
6
12
 
7
- type SidebarTab = "compositions" | "assets" | "code";
13
+ export type SidebarTab = "compositions" | "assets" | "code";
14
+
15
+ export interface LeftSidebarHandle {
16
+ selectTab: (tab: SidebarTab) => void;
17
+ }
8
18
 
9
19
  const STORAGE_KEY = "hf-studio-sidebar-tab";
10
20
 
@@ -39,201 +49,191 @@ interface LeftSidebarProps {
39
49
  takeoverContent?: ReactNode;
40
50
  }
41
51
 
42
- export const LeftSidebar = memo(function LeftSidebar({
43
- width = 240,
44
- projectId,
45
- compositions,
46
- assets,
47
- activeComposition,
48
- onSelectComposition,
49
- onImportFiles,
50
- fileTree: fileProp,
51
- editingFile,
52
- onSelectFile,
53
- onCreateFile,
54
- onCreateFolder,
55
- onDeleteFile,
56
- onRenameFile,
57
- onDuplicateFile,
58
- onMoveFile,
59
- codeChildren,
60
- onLint,
61
- linting,
62
- onToggleCollapse,
63
- takeoverContent,
64
- }: LeftSidebarProps) {
65
- const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
52
+ export const LeftSidebar = memo(
53
+ forwardRef<LeftSidebarHandle, LeftSidebarProps>(function LeftSidebar(
54
+ {
55
+ width = 240,
56
+ projectId,
57
+ compositions,
58
+ assets,
59
+ activeComposition,
60
+ onSelectComposition,
61
+ onImportFiles,
62
+ fileTree: fileProp,
63
+ editingFile,
64
+ onSelectFile,
65
+ onCreateFile,
66
+ onCreateFolder,
67
+ onDeleteFile,
68
+ onRenameFile,
69
+ onDuplicateFile,
70
+ onMoveFile,
71
+ codeChildren,
72
+ onLint,
73
+ linting,
74
+ onToggleCollapse,
75
+ takeoverContent,
76
+ },
77
+ ref,
78
+ ) {
79
+ const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
66
80
 
67
- const selectTab = useCallback((t: SidebarTab) => {
68
- setTab(t);
69
- localStorage.setItem(STORAGE_KEY, t);
70
- }, []);
81
+ const selectTab = useCallback((t: SidebarTab) => {
82
+ setTab(t);
83
+ localStorage.setItem(STORAGE_KEY, t);
84
+ }, []);
71
85
 
72
- // Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets
73
- useMountEffect(() => {
74
- const handler = (e: KeyboardEvent) => {
75
- if (!e.metaKey && !e.ctrlKey) return;
76
- if (e.key === "1") {
77
- e.preventDefault();
78
- selectTab("compositions");
79
- }
80
- if (e.key === "2") {
81
- e.preventDefault();
82
- selectTab("assets");
83
- }
84
- };
85
- window.addEventListener("keydown", handler);
86
- return () => window.removeEventListener("keydown", handler);
87
- });
86
+ useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
88
87
 
89
- return (
90
- <div
91
- className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
92
- style={{ width }}
93
- >
94
- {takeoverContent ? (
95
- <div className="flex min-h-0 flex-1">{takeoverContent}</div>
96
- ) : (
97
- <>
98
- {/* Tabs — Code first */}
99
- <div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
100
- <div className="flex items-center gap-2">
101
- <div
102
- className="grid min-w-0 flex-1 gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
103
- style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
104
- >
105
- <button
106
- type="button"
107
- onClick={() => selectTab("code")}
108
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
109
- tab === "code"
110
- ? "bg-neutral-800 text-white"
111
- : "text-neutral-500 hover:text-neutral-200"
112
- }`}
113
- >
114
- Code
115
- </button>
116
- <button
117
- type="button"
118
- onClick={() => selectTab("compositions")}
119
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
120
- tab === "compositions"
121
- ? "bg-neutral-800 text-white"
122
- : "text-neutral-500 hover:text-neutral-200"
123
- }`}
124
- >
125
- Compositions
126
- </button>
127
- <button
128
- type="button"
129
- onClick={() => selectTab("assets")}
130
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
131
- tab === "assets"
132
- ? "bg-neutral-800 text-white"
133
- : "text-neutral-500 hover:text-neutral-200"
134
- }`}
88
+ return (
89
+ <div
90
+ className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
91
+ style={{ width }}
92
+ >
93
+ {takeoverContent ? (
94
+ <div className="flex min-h-0 flex-1">{takeoverContent}</div>
95
+ ) : (
96
+ <>
97
+ {/* Tabs — Code first */}
98
+ <div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
99
+ <div className="flex items-center gap-2">
100
+ <div
101
+ className="grid min-w-0 flex-1 gap-0.5 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
102
+ style={{ gridTemplateColumns: "1fr 1fr 1fr" }}
135
103
  >
136
- Assets
137
- </button>
104
+ <button
105
+ type="button"
106
+ onClick={() => selectTab("code")}
107
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
108
+ tab === "code"
109
+ ? "bg-neutral-800 text-white"
110
+ : "text-neutral-500 hover:text-neutral-200"
111
+ }`}
112
+ >
113
+ Code
114
+ </button>
115
+ <button
116
+ type="button"
117
+ onClick={() => selectTab("compositions")}
118
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
119
+ tab === "compositions"
120
+ ? "bg-neutral-800 text-white"
121
+ : "text-neutral-500 hover:text-neutral-200"
122
+ }`}
123
+ >
124
+ Comps
125
+ </button>
126
+ <button
127
+ type="button"
128
+ onClick={() => selectTab("assets")}
129
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
130
+ tab === "assets"
131
+ ? "bg-neutral-800 text-white"
132
+ : "text-neutral-500 hover:text-neutral-200"
133
+ }`}
134
+ >
135
+ Assets
136
+ </button>
137
+ </div>
138
+ {onToggleCollapse && (
139
+ <button
140
+ type="button"
141
+ onClick={onToggleCollapse}
142
+ className="flex h-8 w-8 flex-shrink-0 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"
143
+ title="Hide sidebar"
144
+ aria-label="Hide sidebar"
145
+ >
146
+ <svg
147
+ width="14"
148
+ height="14"
149
+ viewBox="0 0 24 24"
150
+ fill="none"
151
+ stroke="currentColor"
152
+ strokeWidth="1.5"
153
+ strokeLinecap="round"
154
+ strokeLinejoin="round"
155
+ aria-hidden="true"
156
+ >
157
+ <path d="m14 7-5 5 5 5" />
158
+ <path d="M19 4v16" />
159
+ </svg>
160
+ </button>
161
+ )}
138
162
  </div>
139
- {onToggleCollapse && (
163
+ </div>
164
+
165
+ {/* Tab content */}
166
+ {tab === "compositions" && (
167
+ <CompositionsTab
168
+ projectId={projectId}
169
+ compositions={compositions}
170
+ activeComposition={activeComposition}
171
+ onSelect={onSelectComposition}
172
+ />
173
+ )}
174
+ {tab === "assets" && (
175
+ <AssetsTab
176
+ projectId={projectId}
177
+ assets={assets}
178
+ onImport={onImportFiles}
179
+ onDelete={onDeleteFile}
180
+ onRename={onRenameFile}
181
+ />
182
+ )}
183
+ {tab === "code" && (
184
+ <div className="flex flex-1 min-h-0">
185
+ {(fileProp?.length ?? 0) > 0 && (
186
+ <div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
187
+ <FileTree
188
+ files={fileProp ?? []}
189
+ activeFile={editingFile?.path ?? null}
190
+ onSelectFile={onSelectFile ?? (() => {})}
191
+ onCreateFile={onCreateFile}
192
+ onCreateFolder={onCreateFolder}
193
+ onDeleteFile={onDeleteFile}
194
+ onRenameFile={onRenameFile}
195
+ onDuplicateFile={onDuplicateFile}
196
+ onMoveFile={onMoveFile}
197
+ onImportFiles={onImportFiles}
198
+ />
199
+ </div>
200
+ )}
201
+ <div className="flex-1 overflow-hidden min-w-0">
202
+ {codeChildren ?? (
203
+ <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
204
+ Select a file to edit
205
+ </div>
206
+ )}
207
+ </div>
208
+ </div>
209
+ )}
210
+
211
+ {/* Lint button pinned at the bottom */}
212
+ {onLint && (
213
+ <div className="border-t border-neutral-800 p-2 flex-shrink-0">
140
214
  <button
141
- type="button"
142
- onClick={onToggleCollapse}
143
- className="flex h-8 w-8 flex-shrink-0 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"
144
- title="Hide sidebar"
145
- aria-label="Hide sidebar"
215
+ onClick={onLint}
216
+ disabled={linting}
217
+ className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
146
218
  >
147
219
  <svg
148
- width="14"
149
- height="14"
220
+ width="12"
221
+ height="12"
150
222
  viewBox="0 0 24 24"
151
223
  fill="none"
152
224
  stroke="currentColor"
153
- strokeWidth="1.5"
154
- strokeLinecap="round"
155
- strokeLinejoin="round"
156
- aria-hidden="true"
225
+ strokeWidth="2"
157
226
  >
158
- <path d="m14 7-5 5 5 5" />
159
- <path d="M19 4v16" />
227
+ <path d="M9 11l3 3L22 4" />
228
+ <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
160
229
  </svg>
230
+ {linting ? "Linting…" : "Lint"}
161
231
  </button>
162
- )}
163
- </div>
164
- </div>
165
-
166
- {/* Tab content */}
167
- {tab === "compositions" && (
168
- <CompositionsTab
169
- projectId={projectId}
170
- compositions={compositions}
171
- activeComposition={activeComposition}
172
- onSelect={onSelectComposition}
173
- />
174
- )}
175
- {tab === "assets" && (
176
- <AssetsTab
177
- projectId={projectId}
178
- assets={assets}
179
- onImport={onImportFiles}
180
- onDelete={onDeleteFile}
181
- onRename={onRenameFile}
182
- />
183
- )}
184
- {tab === "code" && (
185
- <div className="flex flex-1 min-h-0">
186
- {(fileProp?.length ?? 0) > 0 && (
187
- <div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
188
- <FileTree
189
- files={fileProp ?? []}
190
- activeFile={editingFile?.path ?? null}
191
- onSelectFile={onSelectFile ?? (() => {})}
192
- onCreateFile={onCreateFile}
193
- onCreateFolder={onCreateFolder}
194
- onDeleteFile={onDeleteFile}
195
- onRenameFile={onRenameFile}
196
- onDuplicateFile={onDuplicateFile}
197
- onMoveFile={onMoveFile}
198
- onImportFiles={onImportFiles}
199
- />
200
- </div>
201
- )}
202
- <div className="flex-1 overflow-hidden min-w-0">
203
- {codeChildren ?? (
204
- <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
205
- Select a file to edit
206
- </div>
207
- )}
208
232
  </div>
209
- </div>
210
- )}
211
-
212
- {/* Lint button pinned at the bottom */}
213
- {onLint && (
214
- <div className="border-t border-neutral-800 p-2 flex-shrink-0">
215
- <button
216
- onClick={onLint}
217
- disabled={linting}
218
- className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
219
- >
220
- <svg
221
- width="12"
222
- height="12"
223
- viewBox="0 0 24 24"
224
- fill="none"
225
- stroke="currentColor"
226
- strokeWidth="2"
227
- >
228
- <path d="M9 11l3 3L22 4" />
229
- <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
230
- </svg>
231
- {linting ? "Linting…" : "Lint"}
232
- </button>
233
- </div>
234
- )}
235
- </>
236
- )}
237
- </div>
238
- );
239
- });
233
+ )}
234
+ </>
235
+ )}
236
+ </div>
237
+ );
238
+ }),
239
+ );