@hyperframes/studio 0.5.0-alpha.9 → 0.5.0

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 (65) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1438
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/LintModal.tsx +4 -3
  13. package/src/components/editor/PropertyPanel.tsx +206 -2466
  14. package/src/components/nle/NLELayout.tsx +47 -17
  15. package/src/components/nle/NLEPreview.tsx +5 -50
  16. package/src/components/sidebar/AssetsTab.tsx +4 -3
  17. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  18. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  19. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  20. package/src/components/ui/HyperframesLoader.tsx +104 -0
  21. package/src/components/ui/index.ts +2 -0
  22. package/src/icons/SystemIcons.tsx +2 -0
  23. package/src/player/components/CompositionThumbnail.tsx +10 -42
  24. package/src/player/components/EditModal.tsx +20 -5
  25. package/src/player/components/Player.tsx +129 -28
  26. package/src/player/components/PlayerControls.tsx +3 -44
  27. package/src/player/components/Timeline.test.ts +0 -12
  28. package/src/player/components/Timeline.tsx +25 -52
  29. package/src/player/components/TimelineClip.tsx +9 -21
  30. package/src/player/components/timelineEditing.test.ts +4 -2
  31. package/src/player/components/timelineEditing.ts +3 -1
  32. package/src/player/components/timelineTheme.test.ts +19 -0
  33. package/src/player/components/timelineTheme.ts +8 -4
  34. package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
  35. package/src/player/hooks/useTimelinePlayer.ts +206 -93
  36. package/src/player/lib/time.test.ts +11 -1
  37. package/src/player/lib/time.ts +6 -0
  38. package/src/player/store/playerStore.ts +1 -0
  39. package/src/styles/studio.css +112 -0
  40. package/src/utils/frameCapture.test.ts +26 -0
  41. package/src/utils/frameCapture.ts +40 -0
  42. package/src/utils/mediaTypes.ts +1 -1
  43. package/src/utils/projectRouting.test.ts +87 -0
  44. package/src/utils/projectRouting.ts +27 -0
  45. package/src/utils/sourcePatcher.test.ts +1 -128
  46. package/src/utils/sourcePatcher.ts +18 -130
  47. package/src/utils/timelineAssetDrop.test.ts +11 -31
  48. package/src/utils/timelineAssetDrop.ts +2 -22
  49. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  50. package/dist/assets/index-DKaNgV2Z.css +0 -1
  51. package/dist/assets/index-peNJzL-4.js +0 -105
  52. package/src/components/editor/DomEditOverlay.tsx +0 -445
  53. package/src/components/editor/colorValue.test.ts +0 -82
  54. package/src/components/editor/colorValue.ts +0 -175
  55. package/src/components/editor/domEditing.test.ts +0 -537
  56. package/src/components/editor/domEditing.ts +0 -762
  57. package/src/components/editor/floatingPanel.test.ts +0 -34
  58. package/src/components/editor/floatingPanel.ts +0 -54
  59. package/src/components/editor/fontAssets.ts +0 -32
  60. package/src/components/editor/fontCatalog.ts +0 -126
  61. package/src/components/editor/gradientValue.test.ts +0 -89
  62. package/src/components/editor/gradientValue.ts +0 -445
  63. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  64. package/src/utils/clipboard.test.ts +0 -88
  65. package/src/utils/clipboard.ts +0 -57
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
5
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
6
6
  import { NLEPreview } from "./NLEPreview";
7
7
  import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
8
+ import {
9
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
10
+ getTimelineToggleTitle,
11
+ } from "../../utils/timelineDiscovery";
8
12
 
9
13
  interface NLELayoutProps {
10
14
  projectId: string;
@@ -85,6 +89,7 @@ export const NLELayout = memo(function NLELayout({
85
89
  togglePlay,
86
90
  seek,
87
91
  onIframeLoad: baseOnIframeLoad,
92
+ refreshPlayer,
88
93
  saveSeekPosition,
89
94
  } = useTimelinePlayer();
90
95
 
@@ -98,15 +103,13 @@ export const NLELayout = memo(function NLELayout({
98
103
  usePlayerStore.getState().reset();
99
104
  }
100
105
 
101
- // Save seek position before the Player component creates a new player
102
- // on refreshKey change. The Player handles the actual reload via the
103
- // dual-player crossfade; we just need to persist the current time.
106
+ // Refresh the existing iframe in place when source files change.
104
107
  const prevRefreshKeyRef = useRef(refreshKey);
105
108
  useEffect(() => {
106
109
  if (refreshKey === prevRefreshKeyRef.current) return;
107
110
  prevRefreshKeyRef.current = refreshKey;
108
- saveSeekPosition();
109
- }, [refreshKey, saveSeekPosition]);
111
+ refreshPlayer();
112
+ }, [refreshKey, refreshPlayer]);
110
113
 
111
114
  // Wrap onIframeLoad to also notify parent of iframe ref
112
115
  const onIframeLoad = useCallback(() => {
@@ -198,6 +201,7 @@ export const NLELayout = memo(function NLELayout({
198
201
 
199
202
  // Resizable timeline height
200
203
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
204
+ const isTimelineVisible = timelineVisible ?? true;
201
205
  const isDragging = useRef(false);
202
206
  const containerRef = useRef<HTMLDivElement>(null);
203
207
 
@@ -205,10 +209,6 @@ export const NLELayout = memo(function NLELayout({
205
209
  const currentLevel = compositionStack[compositionStack.length - 1];
206
210
  const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
207
211
 
208
- useEffect(() => {
209
- onIframeRef?.(iframeRef.current);
210
- }, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
211
-
212
212
  // Save master seek position before drilling down so we can restore it on back-navigation.
213
213
  // saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
214
214
  const masterSeekRef = useRef(0);
@@ -371,16 +371,11 @@ export const NLELayout = memo(function NLELayout({
371
371
  onNavigate={handleNavigateComposition}
372
372
  />
373
373
  )}
374
- <PlayerControls
375
- onTogglePlay={togglePlay}
376
- onSeek={seek}
377
- timelineVisible={timelineVisible ?? true}
378
- onToggleTimeline={onToggleTimeline}
379
- />
374
+ <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
380
375
  </div>
381
376
  </div>
382
377
 
383
- {(timelineVisible ?? true) && (
378
+ {isTimelineVisible ? (
384
379
  <>
385
380
  {/* Resize divider */}
386
381
  <div
@@ -422,7 +417,42 @@ export const NLELayout = memo(function NLELayout({
422
417
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
423
418
  </div>
424
419
  </>
425
- )}
420
+ ) : onToggleTimeline ? (
421
+ <div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
422
+ <div className="flex h-10 items-center justify-between px-3">
423
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
424
+ Timeline
425
+ </div>
426
+ <button
427
+ type="button"
428
+ onClick={onToggleTimeline}
429
+ className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
430
+ title={getTimelineToggleTitle(false)}
431
+ aria-label="Show timeline editor"
432
+ >
433
+ <svg
434
+ width="13"
435
+ height="13"
436
+ viewBox="0 0 24 24"
437
+ fill="none"
438
+ stroke="currentColor"
439
+ strokeWidth="1.7"
440
+ strokeLinecap="round"
441
+ strokeLinejoin="round"
442
+ aria-hidden="true"
443
+ >
444
+ <rect x="3" y="13" width="18" height="8" rx="1" />
445
+ <path d="M7 9h10" />
446
+ <path d="M8 5h8" />
447
+ </svg>
448
+ <span>Show</span>
449
+ <span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
450
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
451
+ </span>
452
+ </button>
453
+ </div>
454
+ </div>
455
+ ) : null}
426
456
  </div>
427
457
  );
428
458
  });
@@ -1,4 +1,4 @@
1
- import { memo, useRef, useState, type Ref } from "react";
1
+ import { memo, type Ref } from "react";
2
2
  import { Player } from "../../player";
3
3
 
4
4
  interface NLEPreviewProps {
@@ -21,17 +21,6 @@ export function getPreviewPlayerKey({
21
21
  return directUrl ?? projectId;
22
22
  }
23
23
 
24
- /**
25
- * Manages the composition preview with crossfade on reload.
26
- *
27
- * When refreshKey changes, a new Player is mounted alongside the old one.
28
- * The old Player stays visible (opacity 1) until the new one fires onLoad,
29
- * at which point the old is removed. This avoids the flash that a simple
30
- * key-swap remount would cause.
31
- *
32
- * Uses the render-time state adjustment pattern (React-sanctioned) to detect
33
- * refreshKey changes — no useEffect needed.
34
- */
35
24
  export const NLEPreview = memo(function NLEPreview({
36
25
  projectId,
37
26
  iframeRef,
@@ -40,56 +29,22 @@ export const NLEPreview = memo(function NLEPreview({
40
29
  directUrl,
41
30
  refreshKey,
42
31
  }: NLEPreviewProps) {
43
- const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
44
- const prevRefreshKeyRef = useRef(refreshKey);
45
- const [retiringKey, setRetiringKey] = useState<string | null>(null);
46
- const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47
-
48
- // Detect refreshKey change during render (React-sanctioned derived state pattern).
49
- // When the key changes, the current active player becomes the retiring player
50
- // and a new active player is mounted alongside it.
51
- if (refreshKey !== prevRefreshKeyRef.current) {
52
- const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
53
- prevRefreshKeyRef.current = refreshKey;
54
- setRetiringKey(oldKey);
55
- }
56
-
57
- const activeKey = `${baseKey}:${refreshKey ?? 0}`;
58
-
59
- const handleNewPlayerLoad = () => {
60
- onIframeLoad();
61
- if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
62
- retiringTimerRef.current = setTimeout(() => {
63
- setRetiringKey(null);
64
- retiringTimerRef.current = null;
65
- }, 160);
66
- };
32
+ const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
67
33
 
68
34
  return (
69
35
  <div className="flex flex-col h-full min-h-0">
70
36
  <div
71
- className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
37
+ className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
72
38
  tabIndex={0}
73
39
  aria-label="Composition preview"
74
40
  >
75
- {retiringKey && (
76
- <Player
77
- key={retiringKey}
78
- projectId={directUrl ? undefined : projectId}
79
- directUrl={directUrl}
80
- onLoad={() => {}}
81
- portrait={portrait}
82
- style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
83
- />
84
- )}
85
41
  <Player
86
- key={activeKey}
42
+ key={playerKey}
87
43
  ref={iframeRef}
88
44
  projectId={directUrl ? undefined : projectId}
89
45
  directUrl={directUrl}
90
- onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
46
+ onLoad={onIframeLoad}
91
47
  portrait={portrait}
92
- style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
93
48
  />
94
49
  </div>
95
50
  </div>
@@ -2,7 +2,6 @@ import { memo, useState, useCallback, useRef } from "react";
2
2
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
3
  import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
4
4
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
5
- import { copyTextToClipboard } from "../../utils/clipboard";
6
5
 
7
6
  interface AssetsTabProps {
8
7
  projectId: string;
@@ -299,10 +298,12 @@ export const AssetsTab = memo(function AssetsTab({
299
298
  );
300
299
 
301
300
  const handleCopyPath = useCallback(async (path: string) => {
302
- const copied = await copyTextToClipboard(path);
303
- if (copied) {
301
+ try {
302
+ await navigator.clipboard.writeText(path);
304
303
  setCopiedPath(path);
305
304
  setTimeout(() => setCopiedPath(null), 1500);
305
+ } catch {
306
+ // ignore
306
307
  }
307
308
  }, []);
308
309
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { resolveCompositionPreviewScale, resolveThumbnailSeekTime } from "./CompositionsTab";
2
+ import { resolveCompositionPreviewScale } from "./CompositionsTab";
3
3
 
4
4
  describe("resolveCompositionPreviewScale", () => {
5
5
  it("scales a 16:9 stage to fit the composition card", () => {
@@ -35,18 +35,3 @@ describe("resolveCompositionPreviewScale", () => {
35
35
  ).toBeCloseTo(80 / 1920);
36
36
  });
37
37
  });
38
-
39
- describe("resolveThumbnailSeekTime", () => {
40
- it("uses the default 3s frame for compositions longer than 3s", () => {
41
- expect(resolveThumbnailSeekTime(6)).toBe(3);
42
- });
43
-
44
- it("uses the midpoint for compositions shorter than 3s", () => {
45
- expect(resolveThumbnailSeekTime(2)).toBe(1);
46
- });
47
-
48
- it("falls back to the default 3s frame when duration is unknown", () => {
49
- expect(resolveThumbnailSeekTime(null)).toBe(3);
50
- expect(resolveThumbnailSeekTime(Number.NaN)).toBe(3);
51
- });
52
- });
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useEffect, useRef, useState } from "react";
1
+ import { memo, useRef, useState } from "react";
2
2
 
3
3
  interface CompositionsTabProps {
4
4
  projectId: string;
@@ -8,17 +8,6 @@ interface CompositionsTabProps {
8
8
  }
9
9
 
10
10
  const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
11
- const THUMBNAIL_SEEK_TIME_SECONDS = 3;
12
- const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
13
-
14
- type PreviewWindow = Window & {
15
- __player?: {
16
- play?: () => void;
17
- pause?: () => void;
18
- seek?: (time: number) => void;
19
- getDuration?: () => number;
20
- };
21
- };
22
11
 
23
12
  export function resolveCompositionPreviewScale(input: {
24
13
  cardWidth: number;
@@ -39,54 +28,6 @@ export function resolveCompositionPreviewScale(input: {
39
28
  return Math.min(scaleX, scaleY);
40
29
  }
41
30
 
42
- export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
43
- if (
44
- Number.isFinite(durationSeconds) &&
45
- durationSeconds != null &&
46
- durationSeconds > 0 &&
47
- durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
48
- ) {
49
- return durationSeconds / 2;
50
- }
51
-
52
- return THUMBNAIL_SEEK_TIME_SECONDS;
53
- }
54
-
55
- function parsePositiveNumber(value: string | null): number | null {
56
- if (value == null) return null;
57
- const parsed = Number.parseFloat(value);
58
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
59
- }
60
-
61
- function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null {
62
- const win = iframe?.contentWindow as PreviewWindow | null;
63
- const playerDuration = win?.__player?.getDuration?.();
64
- if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
65
- return playerDuration;
66
- }
67
-
68
- const doc = iframe?.contentDocument;
69
- const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
70
- return (
71
- parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
72
- parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
73
- );
74
- }
75
-
76
- function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
77
- const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
78
- if (!player) return false;
79
-
80
- if (shouldPlay) {
81
- player.play?.();
82
- return true;
83
- }
84
-
85
- player.pause?.();
86
- player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
87
- return true;
88
- }
89
-
90
31
  function CompCard({
91
32
  projectId,
92
33
  comp,
@@ -100,25 +41,7 @@ function CompCard({
100
41
  }) {
101
42
  const [hovered, setHovered] = useState(false);
102
43
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
103
- const iframeRef = useRef<HTMLIFrameElement | null>(null);
104
44
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
105
- const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
106
-
107
- const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
108
- if (syncTimer.current) {
109
- clearTimeout(syncTimer.current);
110
- syncTimer.current = null;
111
- }
112
-
113
- const sync = (remainingAttempts: number) => {
114
- if (syncIframePlayback(iframeRef.current, shouldPlay) || remainingAttempts <= 0) return;
115
-
116
- syncTimer.current = setTimeout(() => sync(remainingAttempts - 1), 100);
117
- };
118
-
119
- sync(THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS);
120
- }, []);
121
-
122
45
  const handleEnter = () => {
123
46
  hoverTimer.current = setTimeout(() => setHovered(true), 300);
124
47
  };
@@ -130,6 +53,7 @@ function CompCard({
130
53
  setHovered(false);
131
54
  };
132
55
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
56
+ const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
133
57
  const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
134
58
  const previewScale = resolveCompositionPreviewScale({
135
59
  cardWidth: 80,
@@ -138,17 +62,6 @@ function CompCard({
138
62
  stageHeight: stageSize.height,
139
63
  });
140
64
 
141
- useEffect(() => {
142
- requestIframePlaybackSync(hovered);
143
- }, [hovered, requestIframePlaybackSync]);
144
-
145
- useEffect(() => {
146
- return () => {
147
- if (hoverTimer.current) clearTimeout(hoverTimer.current);
148
- if (syncTimer.current) clearTimeout(syncTimer.current);
149
- };
150
- }, []);
151
-
152
65
  return (
153
66
  <div
154
67
  onClick={onSelect}
@@ -161,34 +74,49 @@ function CompCard({
161
74
  }`}
162
75
  >
163
76
  <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
164
- <iframe
165
- ref={iframeRef}
166
- src={previewUrl}
167
- sandbox="allow-scripts allow-same-origin"
168
- loading="lazy"
169
- className="absolute left-0 top-0 border-none pointer-events-none"
170
- style={{
171
- transformOrigin: "0 0",
172
- width: stageSize.width,
173
- height: stageSize.height,
174
- transform: `scale(${previewScale})`,
175
- }}
176
- onLoad={(e) => {
177
- try {
178
- const iframe = e.currentTarget;
179
- const root = iframe.contentDocument?.querySelector("[data-composition-id]");
180
- const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
181
- const height =
182
- Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
183
- setStageSize({ width, height });
184
- requestIframePlaybackSync(hovered);
185
- } catch {
186
- setStageSize(DEFAULT_PREVIEW_STAGE);
187
- }
188
- }}
189
- title={`${name} preview`}
190
- tabIndex={-1}
191
- />
77
+ {/* Live iframe preview on hover */}
78
+ {hovered && (
79
+ <iframe
80
+ src={previewUrl}
81
+ sandbox="allow-scripts allow-same-origin"
82
+ className="absolute left-0 top-0 border-none pointer-events-none"
83
+ style={{
84
+ transformOrigin: "0 0",
85
+ width: stageSize.width,
86
+ height: stageSize.height,
87
+ transform: `scale(${previewScale})`,
88
+ }}
89
+ onLoad={(e) => {
90
+ try {
91
+ const iframe = e.currentTarget;
92
+ const root = iframe.contentDocument?.querySelector("[data-composition-id]");
93
+ const width =
94
+ Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
95
+ const height =
96
+ Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
97
+ setStageSize({ width, height });
98
+ } catch {
99
+ setStageSize(DEFAULT_PREVIEW_STAGE);
100
+ }
101
+ }}
102
+ tabIndex={-1}
103
+ />
104
+ )}
105
+ {/* Static thumbnail — hidden while hovering */}
106
+ <div
107
+ className="absolute inset-0 transition-opacity duration-150"
108
+ style={{ opacity: hovered ? 0 : 1 }}
109
+ >
110
+ <img
111
+ src={thumbnailUrl}
112
+ alt={name}
113
+ loading="lazy"
114
+ className="w-full h-full object-contain"
115
+ onError={(e) => {
116
+ (e.target as HTMLImageElement).style.display = "none";
117
+ }}
118
+ />
119
+ </div>
192
120
  </div>
193
121
  <div className="min-w-0 flex-1">
194
122
  <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
35
35
  codeChildren?: ReactNode;
36
36
  onLint?: () => void;
37
37
  linting?: boolean;
38
+ onToggleCollapse?: () => void;
38
39
  }
39
40
 
40
41
  export const LeftSidebar = memo(function LeftSidebar({
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
57
58
  codeChildren,
58
59
  onLint,
59
60
  linting,
61
+ onToggleCollapse,
60
62
  }: LeftSidebarProps) {
61
63
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
62
64
 
@@ -88,45 +90,64 @@ export const LeftSidebar = memo(function LeftSidebar({
88
90
  style={{ width }}
89
91
  >
90
92
  {/* Tabs — Code first */}
91
- <div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
92
- <div
93
- className="grid gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
94
- style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
93
+ <div className="flex border-b border-neutral-800/50 flex-shrink-0">
94
+ <button
95
+ type="button"
96
+ onClick={() => selectTab("code")}
97
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
98
+ tab === "code"
99
+ ? "text-neutral-200 border-b-2 border-studio-accent"
100
+ : "text-neutral-500 hover:text-neutral-400"
101
+ }`}
95
102
  >
103
+ Code
104
+ </button>
105
+ <button
106
+ type="button"
107
+ onClick={() => selectTab("compositions")}
108
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
109
+ tab === "compositions"
110
+ ? "text-neutral-200 border-b-2 border-studio-accent"
111
+ : "text-neutral-500 hover:text-neutral-400"
112
+ }`}
113
+ >
114
+ Compositions
115
+ </button>
116
+ <button
117
+ type="button"
118
+ onClick={() => selectTab("assets")}
119
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
120
+ tab === "assets"
121
+ ? "text-neutral-200 border-b-2 border-studio-accent"
122
+ : "text-neutral-500 hover:text-neutral-400"
123
+ }`}
124
+ >
125
+ Assets
126
+ </button>
127
+ {onToggleCollapse && (
96
128
  <button
97
129
  type="button"
98
- onClick={() => selectTab("code")}
99
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
100
- tab === "code"
101
- ? "bg-neutral-800 text-white"
102
- : "text-neutral-500 hover:text-neutral-200"
103
- }`}
104
- >
105
- Code
106
- </button>
107
- <button
108
- type="button"
109
- onClick={() => selectTab("compositions")}
110
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
111
- tab === "compositions"
112
- ? "bg-neutral-800 text-white"
113
- : "text-neutral-500 hover:text-neutral-200"
114
- }`}
115
- >
116
- Compositions
117
- </button>
118
- <button
119
- type="button"
120
- onClick={() => selectTab("assets")}
121
- className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
122
- tab === "assets"
123
- ? "bg-neutral-800 text-white"
124
- : "text-neutral-500 hover:text-neutral-200"
125
- }`}
130
+ onClick={onToggleCollapse}
131
+ className="mx-1 my-1 flex h-7 w-7 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"
132
+ title="Hide sidebar"
133
+ aria-label="Hide sidebar"
126
134
  >
127
- Assets
135
+ <svg
136
+ width="14"
137
+ height="14"
138
+ viewBox="0 0 24 24"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ strokeWidth="1.5"
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ aria-hidden="true"
145
+ >
146
+ <path d="m14 7-5 5 5 5" />
147
+ <path d="M19 4v16" />
148
+ </svg>
128
149
  </button>
129
- </div>
150
+ )}
130
151
  </div>
131
152
 
132
153
  {/* Tab content */}
@@ -0,0 +1,104 @@
1
+ export interface HyperframesLoaderProps {
2
+ /** Status text shown below the mark. */
3
+ title: string;
4
+ /** Optional secondary detail line. */
5
+ detail?: string;
6
+ /** Optional monospace third line for IDs, counts, or percentages. */
7
+ mono?: string;
8
+ /** Pixel size of the mark itself; status text scales independently. */
9
+ size?: number;
10
+ /** Optional normalized progress value from 0 to 1. */
11
+ progress?: number;
12
+ }
13
+
14
+ export function HyperframesLoader({
15
+ title,
16
+ detail,
17
+ mono,
18
+ size = 64,
19
+ progress,
20
+ }: HyperframesLoaderProps) {
21
+ const boundedProgress =
22
+ typeof progress === "number" && Number.isFinite(progress)
23
+ ? Math.min(1, Math.max(0, progress))
24
+ : undefined;
25
+ const markFrameSize = Math.round(size * 1.16);
26
+
27
+ return (
28
+ <div className="hf-loader" draggable={false}>
29
+ <div
30
+ className="hf-loader-mark-frame"
31
+ style={{ width: markFrameSize, height: markFrameSize }}
32
+ draggable={false}
33
+ >
34
+ <svg
35
+ className="hf-loader-mark"
36
+ width={size}
37
+ height={size}
38
+ viewBox="0 0 100 100"
39
+ fill="none"
40
+ xmlns="http://www.w3.org/2000/svg"
41
+ aria-hidden="true"
42
+ >
43
+ <g className="hf-loader-mark__mark" transform="translate(50 50)">
44
+ <g className="hf-loader-mark__core" transform="scale(1)" opacity=".92">
45
+ <g transform="translate(-50 -50)">
46
+ <path
47
+ d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z"
48
+ fill="url(#hf-loader-grad-left)"
49
+ />
50
+ <path
51
+ d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"
52
+ fill="url(#hf-loader-grad-right)"
53
+ />
54
+ </g>
55
+ </g>
56
+ </g>
57
+ <defs>
58
+ <linearGradient
59
+ id="hf-loader-grad-left"
60
+ x1="48.5676"
61
+ y1="25"
62
+ x2="44.7804"
63
+ y2="71.9384"
64
+ gradientUnits="userSpaceOnUse"
65
+ >
66
+ <stop stopColor="#06E3FA" />
67
+ <stop offset="1" stopColor="#4FDB5E" />
68
+ </linearGradient>
69
+ <linearGradient
70
+ id="hf-loader-grad-right"
71
+ x1="54.8282"
72
+ y1="73.8392"
73
+ x2="72.0989"
74
+ y2="32.8932"
75
+ gradientUnits="userSpaceOnUse"
76
+ >
77
+ <stop stopColor="#06E3FA" />
78
+ <stop offset="1" stopColor="#4FDB5E" />
79
+ </linearGradient>
80
+ </defs>
81
+ </svg>
82
+ </div>
83
+ <div className="hf-loader-title">{title}</div>
84
+ {detail && <div className="hf-loader-detail">{detail}</div>}
85
+ {boundedProgress !== undefined && (
86
+ <div className="hf-loader-progress" aria-hidden="true">
87
+ <div
88
+ className="hf-loader-progress__fill"
89
+ style={{ transform: `scaleX(${boundedProgress})` }}
90
+ />
91
+ </div>
92
+ )}
93
+ {mono && <div className="hf-loader-mono">{mono}</div>}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ export function StatusFrame(props: HyperframesLoaderProps) {
99
+ return (
100
+ <div className="hf-frame">
101
+ <HyperframesLoader {...props} />
102
+ </div>
103
+ );
104
+ }
@@ -1,2 +1,4 @@
1
1
  // Minimal UI primitives for studio canvas components
2
2
  export { Button, IconButton } from "./Button";
3
+ export { HyperframesLoader, StatusFrame } from "./HyperframesLoader";
4
+ export type { HyperframesLoaderProps } from "./HyperframesLoader";