@hyperframes/studio 0.5.0-alpha.8 → 0.5.0-alpha.9

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.
@@ -1,6 +1,8 @@
1
1
  import { useRef, useCallback } from "react";
2
2
  import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
5
+ import { useCaptionStore } from "../../captions/store";
4
6
 
5
7
  interface PlaybackAdapter {
6
8
  play: () => void;
@@ -105,6 +107,64 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
105
107
  }
106
108
  }
107
109
 
110
+ const SHUTTLE_SPEEDS = [1, 2, 4] as const;
111
+ const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
112
+ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
113
+ "input",
114
+ "textarea",
115
+ "select",
116
+ "button",
117
+ "a[href]",
118
+ "[contenteditable='true']",
119
+ "[role='button']",
120
+ "[role='checkbox']",
121
+ "[role='combobox']",
122
+ "[role='menuitem']",
123
+ "[role='radio']",
124
+ "[role='slider']",
125
+ "[role='spinbutton']",
126
+ "[role='switch']",
127
+ "[role='textbox']",
128
+ ].join(",");
129
+
130
+ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
131
+ if (!target || typeof target !== "object") return false;
132
+ const candidate = target as { closest?: unknown };
133
+ if (typeof candidate.closest !== "function") return false;
134
+ return (
135
+ (candidate.closest as (selector: string) => Element | null).call(
136
+ target,
137
+ PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
138
+ ) !== null
139
+ );
140
+ }
141
+
142
+ interface PlaybackShortcutCaptionState {
143
+ isCaptionEditMode: boolean;
144
+ selectedCaptionSegmentCount: number;
145
+ }
146
+
147
+ type PlaybackShortcutEvent = Pick<
148
+ KeyboardEvent,
149
+ "altKey" | "ctrlKey" | "metaKey" | "code" | "target"
150
+ >;
151
+
152
+ export function shouldIgnorePlaybackShortcutEvent(
153
+ event: PlaybackShortcutEvent,
154
+ captionState: PlaybackShortcutCaptionState = {
155
+ isCaptionEditMode: false,
156
+ selectedCaptionSegmentCount: 0,
157
+ },
158
+ ): boolean {
159
+ if (event.metaKey || event.ctrlKey || event.altKey) return true;
160
+ if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
161
+ return (
162
+ PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
163
+ captionState.isCaptionEditMode &&
164
+ captionState.selectedCaptionSegmentCount > 0
165
+ );
166
+ }
167
+
108
168
  /**
109
169
  * Parse [data-start] elements from a Document into TimelineElement[].
110
170
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -452,6 +512,13 @@ export function useTimelinePlayer() {
452
512
  const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
453
513
  const pendingSeekRef = useRef<number | null>(null);
454
514
  const isRefreshingRef = useRef(false);
515
+ const reverseRafRef = useRef<number>(0);
516
+ const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
517
+ const shuttleSpeedIndexRef = useRef(0);
518
+ const pressedCodesRef = useRef(new Set<string>());
519
+ const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
520
+ const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
521
+ const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
455
522
 
456
523
  // ZERO store subscriptions — this hook never causes re-renders.
457
524
  // All reads use getState() (point-in-time), all writes use the stable setters.
@@ -510,6 +577,10 @@ export function useTimelinePlayer() {
510
577
  }
511
578
  }, []);
512
579
 
580
+ const stopReverseLoop = useCallback(() => {
581
+ cancelAnimationFrame(reverseRafRef.current);
582
+ }, []);
583
+
513
584
  const startRAFLoop = useCallback(() => {
514
585
  const tick = () => {
515
586
  const adapter = getAdapter();
@@ -518,6 +589,14 @@ export function useTimelinePlayer() {
518
589
  const dur = adapter.getDuration();
519
590
  liveTime.notify(time); // direct DOM updates, no React re-render
520
591
  if (time >= dur && !adapter.isPlaying()) {
592
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
593
+ adapter.seek(0);
594
+ liveTime.notify(0);
595
+ adapter.play();
596
+ setIsPlaying(true);
597
+ rafRef.current = requestAnimationFrame(tick);
598
+ return;
599
+ }
521
600
  setCurrentTime(time); // sync Zustand once at end
522
601
  setIsPlaying(false);
523
602
  cancelAnimationFrame(rafRef.current);
@@ -560,6 +639,8 @@ export function useTimelinePlayer() {
560
639
  }, []);
561
640
 
562
641
  const play = useCallback(() => {
642
+ stopRAFLoop();
643
+ stopReverseLoop();
563
644
  const adapter = getAdapter();
564
645
  if (!adapter) return;
565
646
  if (adapter.getTime() >= adapter.getDuration()) {
@@ -568,18 +649,68 @@ export function useTimelinePlayer() {
568
649
  unmutePreviewMedia(iframeRef.current);
569
650
  applyPlaybackRate(usePlayerStore.getState().playbackRate);
570
651
  adapter.play();
652
+ shuttleDirectionRef.current = "forward";
571
653
  setIsPlaying(true);
572
654
  startRAFLoop();
573
- }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
655
+ }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
656
+
657
+ const playBackward = useCallback(
658
+ (rate: number) => {
659
+ stopRAFLoop();
660
+ stopReverseLoop();
661
+ const adapter = getAdapter();
662
+ if (!adapter) return;
663
+ const duration = Math.max(0, adapter.getDuration());
664
+ const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
665
+ adapter.pause();
666
+ if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
667
+ unmutePreviewMedia(iframeRef.current);
668
+ const speed = Math.max(0.1, Math.min(4, rate));
669
+ let startTime = initialTime;
670
+ let startedAt = performance.now();
671
+
672
+ const tick = (now: number) => {
673
+ const elapsed = ((now - startedAt) / 1000) * speed;
674
+ let nextTime = startTime - elapsed;
675
+ if (nextTime <= 0) {
676
+ if (usePlayerStore.getState().loopEnabled && duration > 0) {
677
+ startTime = duration;
678
+ startedAt = now;
679
+ nextTime = duration;
680
+ } else {
681
+ adapter.seek(0);
682
+ liveTime.notify(0);
683
+ setCurrentTime(0);
684
+ setIsPlaying(false);
685
+ shuttleDirectionRef.current = null;
686
+ reverseRafRef.current = 0;
687
+ return;
688
+ }
689
+ }
690
+ adapter.seek(Math.max(0, nextTime));
691
+ liveTime.notify(Math.max(0, nextTime));
692
+ setIsPlaying(true);
693
+ reverseRafRef.current = requestAnimationFrame(tick);
694
+ };
695
+
696
+ setIsPlaying(true);
697
+ shuttleDirectionRef.current = "backward";
698
+ reverseRafRef.current = requestAnimationFrame(tick);
699
+ },
700
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
701
+ );
574
702
 
575
703
  const pause = useCallback(() => {
704
+ stopReverseLoop();
576
705
  const adapter = getAdapter();
577
706
  if (!adapter) return;
578
707
  adapter.pause();
579
708
  setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
580
709
  setIsPlaying(false);
710
+ shuttleDirectionRef.current = null;
711
+ shuttleSpeedIndexRef.current = 0;
581
712
  stopRAFLoop();
582
- }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
713
+ }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
583
714
 
584
715
  const togglePlay = useCallback(() => {
585
716
  if (usePlayerStore.getState().isPlaying) {
@@ -591,18 +722,136 @@ export function useTimelinePlayer() {
591
722
 
592
723
  const seek = useCallback(
593
724
  (time: number) => {
725
+ stopReverseLoop();
594
726
  const adapter = getAdapter();
595
727
  if (!adapter) return;
596
- adapter.seek(time);
597
- liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) no re-render
598
- setCurrentTime(time); // sync store so Split/Delete have accurate time
728
+ const duration = Math.max(0, adapter.getDuration());
729
+ const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
730
+ adapter.seek(nextTime);
731
+ liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
732
+ setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
599
733
  stopRAFLoop();
600
734
  // Only update store if state actually changes (avoids unnecessary re-renders)
601
735
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
736
+ shuttleDirectionRef.current = null;
737
+ shuttleSpeedIndexRef.current = 0;
738
+ },
739
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
740
+ );
741
+
742
+ const stepFrames = useCallback(
743
+ (deltaFrames: number) => {
744
+ const adapter = getAdapter();
745
+ const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
746
+ seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
602
747
  },
603
- [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
748
+ [getAdapter, seek],
604
749
  );
605
750
 
751
+ const shuttle = useCallback(
752
+ (direction: "forward" | "backward") => {
753
+ if (shuttleDirectionRef.current === direction) {
754
+ shuttleSpeedIndexRef.current = Math.min(
755
+ shuttleSpeedIndexRef.current + 1,
756
+ SHUTTLE_SPEEDS.length - 1,
757
+ );
758
+ } else {
759
+ shuttleSpeedIndexRef.current = 0;
760
+ }
761
+ const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
762
+ usePlayerStore.getState().setPlaybackRate(speed);
763
+ if (direction === "forward") {
764
+ play();
765
+ } else {
766
+ playBackward(speed);
767
+ }
768
+ },
769
+ [play, playBackward],
770
+ );
771
+
772
+ const handlePlaybackKeyDown = useCallback(
773
+ (e: KeyboardEvent) => {
774
+ if (e.defaultPrevented) return;
775
+ const captionState = useCaptionStore.getState();
776
+ if (
777
+ shouldIgnorePlaybackShortcutEvent(e, {
778
+ isCaptionEditMode: captionState.isEditMode,
779
+ selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
780
+ })
781
+ ) {
782
+ return;
783
+ }
784
+ pressedCodesRef.current.add(e.code);
785
+ if (e.code === "Space") {
786
+ e.preventDefault();
787
+ togglePlay();
788
+ return;
789
+ }
790
+ if (e.code === "ArrowLeft") {
791
+ e.preventDefault();
792
+ stepFrames(e.shiftKey ? -10 : -1);
793
+ return;
794
+ }
795
+ if (e.code === "ArrowRight") {
796
+ e.preventDefault();
797
+ stepFrames(e.shiftKey ? 10 : 1);
798
+ return;
799
+ }
800
+ if (e.repeat) return;
801
+ if (e.code === "KeyK") {
802
+ e.preventDefault();
803
+ pause();
804
+ return;
805
+ }
806
+ if (e.code === "KeyJ") {
807
+ e.preventDefault();
808
+ if (pressedCodesRef.current.has("KeyK")) {
809
+ stepFrames(-1);
810
+ return;
811
+ }
812
+ shuttle("backward");
813
+ return;
814
+ }
815
+ if (e.code === "KeyL") {
816
+ e.preventDefault();
817
+ if (pressedCodesRef.current.has("KeyK")) {
818
+ stepFrames(1);
819
+ return;
820
+ }
821
+ shuttle("forward");
822
+ }
823
+ },
824
+ [pause, shuttle, stepFrames, togglePlay],
825
+ );
826
+
827
+ const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
828
+ pressedCodesRef.current.delete(e.code);
829
+ }, []);
830
+ playbackKeyDownRef.current = handlePlaybackKeyDown;
831
+ playbackKeyUpRef.current = handlePlaybackKeyUp;
832
+
833
+ const attachIframeShortcutListeners = useCallback(() => {
834
+ iframeShortcutCleanupRef.current?.();
835
+ iframeShortcutCleanupRef.current = null;
836
+
837
+ const iframeWin = iframeRef.current?.contentWindow;
838
+ const iframeDoc = iframeRef.current?.contentDocument;
839
+ if (!iframeWin && !iframeDoc) return;
840
+
841
+ const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
842
+ const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
843
+ iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
844
+ iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
845
+ iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
846
+ iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
847
+ iframeShortcutCleanupRef.current = () => {
848
+ iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
849
+ iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
850
+ iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
851
+ iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
852
+ };
853
+ }, []);
854
+
606
855
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
607
856
  const processTimelineMessage = useCallback(
608
857
  (data: {
@@ -906,6 +1155,7 @@ export function useTimelinePlayer() {
906
1155
  if (doc && iframeWin) {
907
1156
  normalizePreviewViewport(doc, iframeWin);
908
1157
  autoHealMissingCompositionIds(doc);
1158
+ attachIframeShortcutListeners();
909
1159
  }
910
1160
 
911
1161
  // Try reading __clipManifest if already available (fast path)
@@ -972,6 +1222,7 @@ export function useTimelinePlayer() {
972
1222
  processTimelineMessage,
973
1223
  enrichMissingCompositions,
974
1224
  syncTimelineElements,
1225
+ attachIframeShortcutListeners,
975
1226
  ]);
976
1227
 
977
1228
  /** Save the current playback time so the next onIframeLoad restores it. */
@@ -982,11 +1233,25 @@ export function useTimelinePlayer() {
982
1233
  : (usePlayerStore.getState().currentTime ?? 0);
983
1234
  isRefreshingRef.current = true;
984
1235
  stopRAFLoop();
1236
+ stopReverseLoop();
985
1237
  setIsPlaying(false);
986
- }, [getAdapter, stopRAFLoop, setIsPlaying]);
1238
+ }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
987
1239
 
988
1240
  const togglePlayRef = useRef(togglePlay);
989
1241
  togglePlayRef.current = togglePlay;
1242
+
1243
+ const refreshPlayer = useCallback(() => {
1244
+ const iframe = iframeRef.current;
1245
+ if (!iframe) return;
1246
+
1247
+ saveSeekPosition();
1248
+
1249
+ const src = iframe.src;
1250
+ const url = new URL(src, window.location.origin);
1251
+ url.searchParams.set("_t", String(Date.now()));
1252
+ iframe.src = url.toString();
1253
+ }, [saveSeekPosition]);
1254
+
990
1255
  const getAdapterRef = useRef(getAdapter);
991
1256
  getAdapterRef.current = getAdapter;
992
1257
  const processTimelineMessageRef = useRef(processTimelineMessage);
@@ -995,12 +1260,8 @@ export function useTimelinePlayer() {
995
1260
  enrichMissingCompositionsRef.current = enrichMissingCompositions;
996
1261
 
997
1262
  useMountEffect(() => {
998
- const handleKeyDown = (e: KeyboardEvent) => {
999
- if (e.code === "Space" && e.target === document.body) {
1000
- e.preventDefault();
1001
- togglePlayRef.current();
1002
- }
1003
- };
1263
+ const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1264
+ const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
1004
1265
 
1005
1266
  // Listen for timeline messages from the iframe runtime.
1006
1267
  // The runtime sends this AFTER all external compositions load,
@@ -1073,14 +1334,19 @@ export function useTimelinePlayer() {
1073
1334
  }
1074
1335
  };
1075
1336
 
1076
- window.addEventListener("keydown", handleKeyDown);
1337
+ window.addEventListener("keydown", handleWindowKeyDown, true);
1338
+ window.addEventListener("keyup", handleWindowKeyUp, true);
1077
1339
  window.addEventListener("message", handleMessage);
1078
1340
  document.addEventListener("visibilitychange", handleVisibilityChange);
1079
1341
  return () => {
1080
- window.removeEventListener("keydown", handleKeyDown);
1342
+ window.removeEventListener("keydown", handleWindowKeyDown, true);
1343
+ window.removeEventListener("keyup", handleWindowKeyUp, true);
1344
+ iframeShortcutCleanupRef.current?.();
1345
+ iframeShortcutCleanupRef.current = null;
1081
1346
  window.removeEventListener("message", handleMessage);
1082
1347
  document.removeEventListener("visibilitychange", handleVisibilityChange);
1083
1348
  stopRAFLoop();
1349
+ stopReverseLoop();
1084
1350
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1085
1351
  };
1086
1352
  });
@@ -1088,9 +1354,10 @@ export function useTimelinePlayer() {
1088
1354
  /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
1089
1355
  const resetPlayer = useCallback(() => {
1090
1356
  stopRAFLoop();
1357
+ stopReverseLoop();
1091
1358
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1092
1359
  usePlayerStore.getState().reset();
1093
- }, [stopRAFLoop]);
1360
+ }, [stopRAFLoop, stopReverseLoop]);
1094
1361
 
1095
1362
  return {
1096
1363
  iframeRef,
@@ -1099,6 +1366,7 @@ export function useTimelinePlayer() {
1099
1366
  togglePlay,
1100
1367
  seek,
1101
1368
  onIframeLoad,
1369
+ refreshPlayer,
1102
1370
  saveSeekPosition,
1103
1371
  resetPlayer,
1104
1372
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { formatTime } from "./time";
2
+ import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
3
3
 
4
4
  describe("formatTime", () => {
5
5
  it("formats zero seconds", () => {
@@ -55,3 +55,21 @@ describe("formatTime", () => {
55
55
  expect(formatTime(Infinity)).toBe("0:00");
56
56
  });
57
57
  });
58
+
59
+ describe("frame helpers", () => {
60
+ it("converts seconds to frames at the Studio preview rate", () => {
61
+ expect(secondsToFrame(0)).toBe(0);
62
+ expect(secondsToFrame(1)).toBe(30);
63
+ expect(secondsToFrame(1.5)).toBe(45);
64
+ });
65
+
66
+ it("converts frames to seconds at the Studio preview rate", () => {
67
+ expect(frameToSeconds(0)).toBe(0);
68
+ expect(frameToSeconds(30)).toBe(1);
69
+ expect(frameToSeconds(45)).toBe(1.5);
70
+ });
71
+
72
+ it("formats current and total frame display", () => {
73
+ expect(formatFrameTime(1, 5)).toBe("30f / 150f");
74
+ });
75
+ });
@@ -1,6 +1,26 @@
1
+ export const STUDIO_PREVIEW_FPS = 30;
2
+
1
3
  export function formatTime(time: number): string {
2
4
  if (!Number.isFinite(time) || time < 0) return "0:00";
3
5
  const mins = Math.floor(time / 60);
4
6
  const secs = Math.floor(time % 60);
5
7
  return `${mins}:${secs.toString().padStart(2, "0")}`;
6
8
  }
9
+
10
+ export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
11
+ if (!Number.isFinite(time) || time <= 0) return 0;
12
+ if (!Number.isFinite(fps) || fps <= 0) return 0;
13
+ return Math.round(time * fps);
14
+ }
15
+
16
+ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number {
17
+ if (!Number.isFinite(frame) || frame <= 0) return 0;
18
+ if (!Number.isFinite(fps) || fps <= 0) return 0;
19
+ return frame / fps;
20
+ }
21
+
22
+ export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
23
+ const currentFrame = secondsToFrame(time, fps);
24
+ const totalFrames = secondsToFrame(duration, fps);
25
+ return `${currentFrame}f / ${totalFrames}f`;
26
+ }
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
16
16
  expect(state.elements).toEqual([]);
17
17
  expect(state.selectedElementId).toBeNull();
18
18
  expect(state.playbackRate).toBe(1);
19
+ expect(state.loopEnabled).toBe(false);
19
20
  expect(state.zoomMode).toBe("fit");
20
21
  expect(state.manualZoomPercent).toBe(100);
21
22
  });
@@ -61,6 +62,13 @@ describe("usePlayerStore", () => {
61
62
  });
62
63
  });
63
64
 
65
+ describe("setLoopEnabled", () => {
66
+ it("updates loopEnabled", () => {
67
+ usePlayerStore.getState().setLoopEnabled(true);
68
+ expect(usePlayerStore.getState().loopEnabled).toBe(true);
69
+ });
70
+ });
71
+
64
72
  describe("setTimelineReady", () => {
65
73
  it("updates timelineReady", () => {
66
74
  usePlayerStore.getState().setTimelineReady(true);
@@ -205,9 +213,10 @@ describe("usePlayerStore", () => {
205
213
  expect(state.selectedElementId).toBeNull();
206
214
  });
207
215
 
208
- it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
216
+ it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
209
217
  const store = usePlayerStore.getState();
210
218
  store.setPlaybackRate(2);
219
+ store.setLoopEnabled(true);
211
220
  store.setZoomMode("manual");
212
221
  store.setManualZoomPercent(200);
213
222
 
@@ -216,6 +225,7 @@ describe("usePlayerStore", () => {
216
225
  const state = usePlayerStore.getState();
217
226
  // reset() only resets the fields explicitly listed in the reset function
218
227
  expect(state.playbackRate).toBe(2);
228
+ expect(state.loopEnabled).toBe(true);
219
229
  expect(state.zoomMode).toBe("manual");
220
230
  expect(state.manualZoomPercent).toBe(200);
221
231
  });
@@ -34,6 +34,7 @@ interface PlayerState {
34
34
  elements: TimelineElement[];
35
35
  selectedElementId: string | null;
36
36
  playbackRate: number;
37
+ loopEnabled: boolean;
37
38
  /** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
38
39
  zoomMode: ZoomMode;
39
40
  /** Timeline zoom percent relative to the fit width when in manual mode */
@@ -43,6 +44,7 @@ interface PlayerState {
43
44
  setCurrentTime: (time: number) => void;
44
45
  setDuration: (duration: number) => void;
45
46
  setPlaybackRate: (rate: number) => void;
47
+ setLoopEnabled: (enabled: boolean) => void;
46
48
  setTimelineReady: (ready: boolean) => void;
47
49
  setElements: (elements: TimelineElement[]) => void;
48
50
  setSelectedElementId: (id: string | null) => void;
@@ -76,11 +78,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
76
78
  elements: [],
77
79
  selectedElementId: null,
78
80
  playbackRate: 1,
81
+ loopEnabled: false,
79
82
  zoomMode: "fit",
80
83
  manualZoomPercent: 100,
81
84
 
82
85
  setIsPlaying: (playing) => set({ isPlaying: playing }),
83
86
  setPlaybackRate: (rate) => set({ playbackRate: rate }),
87
+ setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
84
88
  setZoomMode: (mode) => set({ zoomMode: mode }),
85
89
  setManualZoomPercent: (percent) =>
86
90
  set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
@@ -96,7 +100,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
96
100
  ),
97
101
  })),
98
102
  // Resets project-specific state when switching compositions.
99
- // playbackRate, zoomMode, and manualZoomPercent are intentionally preserved
103
+ // playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
100
104
  // because they are user preferences that should survive project switches.
101
105
  reset: () =>
102
106
  set({
@@ -1 +0,0 @@
1
- *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media(min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media(min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media(min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media(min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media(min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.-bottom-1\.5{bottom:-.375rem}.-right-1\.5{right:-.375rem}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.bottom-2{bottom:.5rem}.bottom-6{bottom:1.5rem}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-\[100px\]{left:100px}.left-\[176px\]{left:176px}.left-\[24px\]{left:24px}.left-\[31px\]{left:31px}.left-\[34px\]{left:34px}.left-\[52px\]{left:52px}.left-\[82px\]{left:82px}.right-0{right:0}.right-3{right:.75rem}.top-0{top:0}.top-1{top:.25rem}.top-1\/2{top:50%}.top-2{top:.5rem}.top-3{top:.75rem}.top-\[18px\]{top:18px}.top-\[21px\]{top:21px}.top-\[27px\]{top:27px}.top-\[3px\]{top:3px}.top-\[51px\]{top:51px}.top-\[calc\(100\%\+6px\)\]{top:calc(100% + 6px)}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[100\]{z-index:100}.z-\[1\]{z-index:1}.z-\[200\]{z-index:200}.z-\[2\]{z-index:2}.z-\[90\]{z-index:90}.z-\[91\]{z-index:91}.z-\[9999\]{z-index:9999}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-0\.5{margin-top:.125rem;margin-bottom:.125rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mb-0\.5{margin-bottom:.125rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-1\.5{margin-left:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-\[22px\]{margin-top:22px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.flow-root{display:flow-root}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.list-item{display:list-item}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-2{height:.5rem}.h-24{height:6rem}.h-28{height:7rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-36{height:9rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[18px\]{height:18px}.h-\[3px\]{height:3px}.h-\[45px\]{height:45px}.h-\[52px\]{height:52px}.h-\[5px\]{height:5px}.h-\[70px\]{height:70px}.h-full{height:100%}.h-px{height:1px}.max-h-24{max-height:6rem}.max-h-64{max-height:16rem}.max-h-\[70\%\]{max-height:70%}.max-h-\[80vh\]{max-height:80vh}.max-h-full{max-height:100%}.min-h-0{min-height:0px}.min-h-7{min-height:1.75rem}.min-h-8{min-height:2rem}.min-h-9{min-height:2.25rem}.w-0{width:0px}.w-1\.5{width:.375rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-\[110px\]{width:110px}.w-\[160px\]{width:160px}.w-\[292px\]{width:292px}.w-\[320px\]{width:320px}.w-\[480px\]{width:480px}.w-\[56px\]{width:56px}.w-\[72px\]{width:72px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0px}.min-w-7{min-width:1.75rem}.min-w-8{min-width:2rem}.min-w-9{min-width:2.25rem}.min-w-\[140px\]{min-width:140px}.min-w-\[160px\]{min-width:160px}.min-w-\[52px\]{min-width:52px}.min-w-\[56px\]{min-width:56px}.min-w-\[58px\]{min-width:58px}.min-w-\[72px\]{min-width:72px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\!transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-col-resize{cursor:col-resize}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-ew-resize{cursor:ew-resize}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-row-resize{cursor:row-resize}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.\!resize{resize:both!important}.resize{resize:both}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)_68px_28px\]{grid-template-columns:minmax(0,1fr) 68px 28px}.grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.grid-cols-\[minmax\(0\,1fr\)_auto_auto\]{grid-template-columns:minmax(0,1fr) auto auto}.grid-cols-\[repeat\(auto-fit\,minmax\(118px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(118px,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[11px\]{border-radius:11px}.rounded-\[14px\]{border-radius:14px}.rounded-\[18px\]{border-radius:18px}.rounded-\[9px\]{border-radius:9px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-xl{border-radius:.75rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-amber-500\/40{border-color:#f59e0b66}.border-green-500\/30{border-color:#22c55e4d}.border-neutral-600{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.border-neutral-700{--tw-border-opacity: 1;border-color:rgb(64 64 64 / var(--tw-border-opacity, 1))}.border-neutral-700\/40{border-color:#40404066}.border-neutral-700\/50{border-color:#40404080}.border-neutral-700\/60{border-color:#40404099}.border-neutral-800{--tw-border-opacity: 1;border-color:rgb(38 38 38 / var(--tw-border-opacity, 1))}.border-neutral-800\/30{border-color:#2626264d}.border-neutral-800\/40{border-color:#26262666}.border-neutral-800\/50{border-color:#26262680}.border-neutral-800\/60{border-color:#26262699}.border-neutral-800\/80{border-color:#262626cc}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-700\/50{border-color:#b91c1c80}.border-studio-accent{--tw-border-opacity: 1;border-color:rgb(60 230 172 / var(--tw-border-opacity, 1))}.border-studio-accent\/20{border-color:#3ce6ac33}.border-studio-accent\/25{border-color:#3ce6ac40}.border-studio-accent\/30{border-color:#3ce6ac4d}.border-studio-accent\/50{border-color:#3ce6ac80}.border-studio-accent\/60{border-color:#3ce6ac99}.border-studio-accent\/80{border-color:#3ce6accc}.border-transparent{border-color:transparent}.border-white{--tw-border-opacity: 1;border-color:rgb(255 255 255 / var(--tw-border-opacity, 1))}.border-white\/10{border-color:#ffffff1a}.border-white\/20{border-color:#fff3}.border-white\/90{border-color:#ffffffe6}.border-t-white{--tw-border-opacity: 1;border-top-color:rgb(255 255 255 / var(--tw-border-opacity, 1))}.bg-\[\#0a0a0b\]{--tw-bg-opacity: 1;background-color:rgb(10 10 11 / var(--tw-bg-opacity, 1))}.bg-\[\#0d1117\]{--tw-bg-opacity: 1;background-color:rgb(13 17 23 / var(--tw-bg-opacity, 1))}.bg-\[\#0f141c\]{--tw-bg-opacity: 1;background-color:rgb(15 20 28 / var(--tw-bg-opacity, 1))}.bg-\[\#3CE6AC\]\/10{background-color:#3ce6ac1a}.bg-\[\#3CE6AC\]\/5{background-color:#3ce6ac0d}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-black\/20{background-color:#0003}.bg-black\/50{background-color:#00000080}.bg-black\/60{background-color:#0009}.bg-black\/80{background-color:#000c}.bg-green-500\/20{background-color:#22c55e33}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-neutral-600{--tw-bg-opacity: 1;background-color:rgb(82 82 82 / var(--tw-bg-opacity, 1))}.bg-neutral-700{--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity, 1))}.bg-neutral-700\/40{background-color:#40404066}.bg-neutral-800{--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.bg-neutral-800\/60{background-color:#26262699}.bg-neutral-800\/70{background-color:#262626b3}.bg-neutral-900{--tw-bg-opacity: 1;background-color:rgb(23 23 23 / var(--tw-bg-opacity, 1))}.bg-neutral-900\/50{background-color:#17171780}.bg-neutral-900\/60{background-color:#17171799}.bg-neutral-900\/80{background-color:#171717cc}.bg-neutral-900\/95{background-color:#171717f2}.bg-neutral-950{--tw-bg-opacity: 1;background-color:rgb(10 10 10 / var(--tw-bg-opacity, 1))}.bg-neutral-950\/80{background-color:#0a0a0acc}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-900\/60{background-color:#7f1d1d99}.bg-red-900\/90{background-color:#7f1d1de6}.bg-red-950\/30{background-color:#450a0a4d}.bg-studio-accent{--tw-bg-opacity: 1;background-color:rgb(60 230 172 / var(--tw-bg-opacity, 1))}.bg-studio-accent\/10{background-color:#3ce6ac1a}.bg-studio-accent\/15{background-color:#3ce6ac26}.bg-studio-accent\/20{background-color:#3ce6ac33}.bg-studio-accent\/5{background-color:#3ce6ac0d}.bg-studio-accent\/90{background-color:#3ce6ace6}.bg-studio-accent\/\[0\.03\]{background-color:#3ce6ac08}.bg-studio-accent\/\[0\.05\]{background-color:#3ce6ac0d}.bg-studio-accent\/\[0\.06\]{background-color:#3ce6ac0f}.bg-studio-accent\/\[0\.07\]{background-color:#3ce6ac12}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/10{background-color:#ffffff1a}.bg-white\/70{background-color:#ffffffb3}.bg-white\/\[0\.035\]{background-color:#ffffff09}.bg-white\/\[0\.04\]{background-color:#ffffff0a}.bg-white\/\[0\.07\]{background-color:#ffffff12}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.from-black{--tw-gradient-from: #000 var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-white{--tw-gradient-from: #fff var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-0\.5{padding-bottom:.125rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pl-3{padding-left:.75rem}.pl-6{padding-left:1.5rem}.pr-1{padding-right:.25rem}.pr-9{padding-right:2.25rem}.pt-1\.5{padding-top:.375rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-4{line-height:1rem}.leading-5{line-height:1.25rem}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.12em\]{letter-spacing:.12em}.tracking-\[0\.14em\]{letter-spacing:.14em}.tracking-\[0\.16em\]{letter-spacing:.16em}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#09090B\]{--tw-text-opacity: 1;color:rgb(9 9 11 / var(--tw-text-opacity, 1))}.text-\[\#3ce6ac\]{--tw-text-opacity: 1;color:rgb(60 230 172 / var(--tw-text-opacity, 1))}.text-\[\#7f8796\]{--tw-text-opacity: 1;color:rgb(127 135 150 / var(--tw-text-opacity, 1))}.text-amber-100{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-neutral-100{--tw-text-opacity: 1;color:rgb(245 245 245 / var(--tw-text-opacity, 1))}.text-neutral-200{--tw-text-opacity: 1;color:rgb(229 229 229 / var(--tw-text-opacity, 1))}.text-neutral-300{--tw-text-opacity: 1;color:rgb(212 212 212 / var(--tw-text-opacity, 1))}.text-neutral-400{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.text-neutral-50{--tw-text-opacity: 1;color:rgb(250 250 250 / var(--tw-text-opacity, 1))}.text-neutral-500{--tw-text-opacity: 1;color:rgb(115 115 115 / var(--tw-text-opacity, 1))}.text-neutral-600{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.text-neutral-700{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.text-neutral-950{--tw-text-opacity: 1;color:rgb(10 10 10 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-200{--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-studio-accent{--tw-text-opacity: 1;color:rgb(60 230 172 / var(--tw-text-opacity, 1))}.text-studio-accent\/50{color:#3ce6ac80}.text-studio-accent\/80{color:#3ce6accc}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/60{color:#fff9}.underline{text-decoration-line:underline}.line-through{text-decoration-line:line-through}.placeholder-neutral-600::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(82 82 82 / var(--tw-placeholder-opacity, 1))}.placeholder-neutral-600::placeholder{--tw-placeholder-opacity: 1;color:rgb(82 82 82 / var(--tw-placeholder-opacity, 1))}.accent-\[\#3ce6ac\]{accent-color:#3ce6ac}.accent-studio-accent{accent-color:#3CE6AC}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.mix-blend-difference{mix-blend-mode:difference}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.35\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.35);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.45\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.45);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.85\)\,0_6px_14px_rgba\(0\,0\,0\,0\.5\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.85),0 6px 14px rgba(0,0,0,.5);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color), 0 6px 14px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.85\)\,0_8px_18px_rgba\(0\,0\,0\,0\.45\)\]{--tw-shadow: 0 0 0 1px rgba(0,0,0,.85),0 8px 18px rgba(0,0,0,.45);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color), 0 8px 18px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(60\,230\,172\,0\.25\)\]{--tw-shadow: 0 0 0 1px rgba(60,230,172,.25);--tw-shadow-colored: 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_18px_40px_rgba\(0\,0\,0\,0\.3\)\,0_4px_14px_rgba\(0\,0\,0\,0\.18\)\]{--tw-shadow: 0 18px 40px rgba(0,0,0,.3),0 4px 14px rgba(0,0,0,.18);--tw-shadow-colored: 0 18px 40px var(--tw-shadow-color), 0 4px 14px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_1px_2px_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow: 0 1px 2px rgba(0,0,0,.2);--tw-shadow-colored: 0 1px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_1px_3px_rgba\(0\,0\,0\,0\.28\)\]{--tw-shadow: 0 1px 3px rgba(0,0,0,.28);--tw-shadow-colored: 0 1px 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_rgba\(255\,255\,255\,0\.06\)\]{--tw-shadow: inset 0 0 0 1px rgba(255,255,255,.06);--tw-shadow-colored: inset 0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\,255\,255\,0\.03\)\]{--tw-shadow: inset 0 1px 0 rgba(255,255,255,.03);--tw-shadow-colored: inset 0 1px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\,255\,255\,0\.04\)\]{--tw-shadow: inset 0 1px 0 rgba(255,255,255,.04);--tw-shadow-colored: inset 0 1px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\,255\,255\,0\.08\)\]{--tw-shadow: inset 0 1px 0 rgba(255,255,255,.08);--tw-shadow-colored: inset 0 1px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_2px_rgba\(0\,0\,0\,0\.55\)\]{--tw-shadow: inset 0 1px 2px rgba(0,0,0,.55);--tw-shadow-colored: inset 0 1px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-black\/40{--tw-shadow-color: rgb(0 0 0 / .4);--tw-shadow: var(--tw-shadow-colored)}.shadow-black\/50{--tw-shadow-color: rgb(0 0 0 / .5);--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.outline-1{outline-width:1px}.-outline-offset-1{outline-offset:-1px}.outline-\[\#3CE6AC\]\/30{outline-color:#3ce6ac4d}.outline-\[\#3CE6AC\]\/40{outline-color:#3ce6ac66}.ring{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-inset{--tw-ring-inset: inset}.ring-studio-accent{--tw-ring-opacity: 1;--tw-ring-color: rgb(60 230 172 / var(--tw-ring-opacity, 1))}.ring-white\/50{--tw-ring-color: rgb(255 255 255 / .5)}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / .1)) drop-shadow(0 1px 1px rgb(0 0 0 / .06));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.invert{--tw-invert: invert(100%);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur: blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-150{transition-duration:.15s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}:root{color-scheme:dark}body{margin:0;padding:0;background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;overflow:hidden}#root{width:100vw;height:100vh;height:100dvh}.cm-editor{height:100%;font-size:13px}.cm-editor .cm-scroller{font-family:JetBrains Mono,Fira Code,SF Mono,monospace}.cm-editor.cm-focused{outline:none}.placeholder\:text-neutral-600::-moz-placeholder{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.placeholder\:text-neutral-600::placeholder{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.last\:border-0:last-child{border-width:0px}.focus-within\:border-neutral-600:focus-within{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.hover\:border-amber-400\/70:hover{border-color:#fbbf24b3}.hover\:border-neutral-500:hover{--tw-border-opacity: 1;border-color:rgb(115 115 115 / var(--tw-border-opacity, 1))}.hover\:border-neutral-600:hover{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.hover\:border-neutral-700:hover{--tw-border-opacity: 1;border-color:rgb(64 64 64 / var(--tw-border-opacity, 1))}.hover\:border-studio-accent\/40:hover{border-color:#3ce6ac66}.hover\:border-studio-accent\/50:hover{border-color:#3ce6ac80}.hover\:bg-neutral-200:hover{--tw-bg-opacity: 1;background-color:rgb(229 229 229 / var(--tw-bg-opacity, 1))}.hover\:bg-neutral-600:hover{--tw-bg-opacity: 1;background-color:rgb(82 82 82 / var(--tw-bg-opacity, 1))}.hover\:bg-neutral-800:hover{--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.hover\:bg-neutral-800\/30:hover{background-color:#2626264d}.hover\:bg-neutral-800\/50:hover{background-color:#26262680}.hover\:bg-neutral-800\/70:hover{background-color:#262626b3}.hover\:bg-neutral-900:hover{--tw-bg-opacity: 1;background-color:rgb(23 23 23 / var(--tw-bg-opacity, 1))}.hover\:bg-red-500:hover{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:bg-red-800\/60:hover{background-color:#991b1b99}.hover\:bg-red-900\/30:hover{background-color:#7f1d1d4d}.hover\:bg-studio-accent:hover{--tw-bg-opacity: 1;background-color:rgb(60 230 172 / var(--tw-bg-opacity, 1))}.hover\:bg-studio-accent\/25:hover{background-color:#3ce6ac40}.hover\:bg-studio-accent\/80:hover{background-color:#3ce6accc}.hover\:bg-white\/\[0\.06\]:hover{background-color:#ffffff0f}.hover\:text-amber-100:hover{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.hover\:text-amber-300:hover{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.hover\:text-green-400:hover{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.hover\:text-neutral-100:hover{--tw-text-opacity: 1;color:rgb(245 245 245 / var(--tw-text-opacity, 1))}.hover\:text-neutral-200:hover{--tw-text-opacity: 1;color:rgb(229 229 229 / var(--tw-text-opacity, 1))}.hover\:text-neutral-300:hover{--tw-text-opacity: 1;color:rgb(212 212 212 / var(--tw-text-opacity, 1))}.hover\:text-neutral-400:hover{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-studio-accent:hover{--tw-text-opacity: 1;color:rgb(60 230 172 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:ring-1:hover{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.hover\:ring-white\/30:hover{--tw-ring-color: rgb(255 255 255 / .3)}.hover\:brightness-110:hover{--tw-brightness: brightness(1.1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.focus\:border-\[\#3CE6AC\]:focus{--tw-border-opacity: 1;border-color:rgb(60 230 172 / var(--tw-border-opacity, 1))}.focus\:border-\[\#f5a400\]:focus{--tw-border-opacity: 1;border-color:rgb(245 164 0 / var(--tw-border-opacity, 1))}.focus\:border-neutral-600:focus{--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.focus\:border-studio-accent:focus{--tw-border-opacity: 1;border-color:rgb(60 230 172 / var(--tw-border-opacity, 1))}.focus\:border-studio-accent\/40:focus{border-color:#3ce6ac66}.focus\:border-studio-accent\/60:focus{border-color:#3ce6ac99}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-\[\#3ce6ac\]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(60 230 172 / var(--tw-ring-opacity, 1))}.focus\:ring-\[\#f5a400\]\/40:focus{--tw-ring-color: rgb(245 164 0 / .4)}.focus\:ring-studio-accent\/30:focus{--tw-ring-color: rgb(60 230 172 / .3)}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-studio-accent\/50:focus-visible{--tw-ring-color: rgb(60 230 172 / .5)}.active\:scale-\[0\.97\]:active{--tw-scale-x: .97;--tw-scale-y: .97;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:text-neutral-600:disabled{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.disabled\:text-neutral-700:disabled{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:scale-125{--tw-scale-x: 1.25;--tw-scale-y: 1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media(min-width:768px){.md\:inline{display:inline}}