@hyperframes/studio 0.8.51 → 0.8.53-desktop.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 (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -1,101 +0,0 @@
1
- import { useCallback, useRef } from "react";
2
- import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
3
-
4
- /**
5
- * Horizontal drag/keyboard-resizable divider between the preview and the
6
- * timeline. Implements the separator pattern: ArrowUp grows the timeline,
7
- * ArrowDown shrinks it (mirrors the drag direction).
8
- */
9
- export function TimelineResizeDivider({
10
- timelineH,
11
- setTimelineH,
12
- persistTimelineH,
13
- containerRef,
14
- disabled,
15
- }: {
16
- timelineH: number;
17
- setTimelineH: React.Dispatch<React.SetStateAction<number>>;
18
- persistTimelineH: (h: number) => void;
19
- containerRef: React.RefObject<HTMLDivElement | null>;
20
- disabled: boolean;
21
- }) {
22
- const isDragging = useRef(false);
23
- const timelineHRef = useRef(timelineH);
24
- timelineHRef.current = timelineH;
25
-
26
- const handlePointerDown = useCallback(
27
- (e: React.PointerEvent) => {
28
- if (disabled) return;
29
- e.preventDefault();
30
- isDragging.current = true;
31
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
32
- },
33
- [disabled],
34
- );
35
-
36
- const handlePointerMove = useCallback(
37
- (e: React.PointerEvent) => {
38
- if (disabled) return;
39
- if (!isDragging.current || !containerRef.current) return;
40
- const rect = containerRef.current.getBoundingClientRect();
41
- const mouseY = e.clientY - rect.top;
42
- setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
43
- },
44
- [disabled, containerRef, setTimelineH],
45
- );
46
-
47
- const handlePointerUp = useCallback(() => {
48
- if (isDragging.current) persistTimelineH(timelineHRef.current);
49
- isDragging.current = false;
50
- }, [persistTimelineH]);
51
-
52
- const handleKeyDown = useCallback(
53
- (e: React.KeyboardEvent) => {
54
- if (disabled) return;
55
- if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
56
- e.preventDefault();
57
- const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
58
- const delta = e.key === "ArrowUp" ? 16 : -16;
59
- setTimelineH((prev) => {
60
- const next = fitTimelineHeight(containerH, prev + delta);
61
- persistTimelineH(next);
62
- return next;
63
- });
64
- },
65
- [disabled, containerRef, setTimelineH, persistTimelineH],
66
- );
67
-
68
- return (
69
- // Horizontal resize divider: 3px visible seam (h-[3px]), 10px pointer-capture
70
- // zone via the absolutely-positioned inner hit area so the layout gap stays
71
- // at 3px while draggability is preserved over the full band.
72
- <div
73
- role="separator"
74
- aria-orientation="horizontal"
75
- aria-label="Resize timeline (arrow keys)"
76
- aria-valuenow={Math.round(timelineH)}
77
- aria-valuemin={MIN_TIMELINE_H}
78
- aria-valuemax={Math.round(
79
- (containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H,
80
- )}
81
- tabIndex={0}
82
- className="group relative h-[3px] shrink-0 cursor-row-resize z-10 outline-hidden focus-visible:bg-studio-accent/20"
83
- style={{ touchAction: "none" }}
84
- onPointerDown={handlePointerDown}
85
- onPointerMove={handlePointerMove}
86
- onPointerUp={handlePointerUp}
87
- onPointerCancel={handlePointerUp}
88
- onKeyDown={handleKeyDown}
89
- >
90
- {/* Expanded hit zone, deliberately asymmetric: 4px up into the transport
91
- row's vertical centring slack, the 3px seam, then 3px down into the
92
- timeline card's border + toolbar padding. It stops at 10px rather than
93
- the 24px WCAG 2.2 (2.5.8) target because that is all the dead space
94
- there is: 28px transport buttons sit above and 28px toolbar buttons
95
- below, and silently stealing their clicks is the worse bug. */}
96
- <div className="absolute inset-x-0 top-[-4px] h-[10px]" />
97
- {/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */}
98
- <div className="h-[3px] w-full bg-transparent transition-colors group-hover:bg-white/12 group-active:bg-white/18 group-focus-visible:bg-studio-accent/60" />
99
- </div>
100
- );
101
- }
@@ -1,28 +0,0 @@
1
- // @vitest-environment happy-dom
2
-
3
- import { afterEach, expect, it, vi } from "vitest";
4
- import { getPersistedTab } from "./LeftSidebar";
5
-
6
- vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
7
-
8
- const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
9
-
10
- afterEach(() => {
11
- if (originalDescriptor) Object.defineProperty(globalThis, "localStorage", originalDescriptor);
12
- });
13
-
14
- it("falls back to the default tab when localStorage is blocked", () => {
15
- // Chrome throws on the property read itself when site data is blocked for
16
- // the document. This runs as a useState initializer, so throwing here takes
17
- // Studio to the crash boundary.
18
- Object.defineProperty(globalThis, "localStorage", {
19
- configurable: true,
20
- get() {
21
- throw new Error(
22
- "Failed to read the 'localStorage' property from 'Window': Access is denied for this document.",
23
- );
24
- },
25
- });
26
-
27
- expect(getPersistedTab()).toBe("compositions");
28
- });
@@ -1,348 +0,0 @@
1
- import {
2
- memo,
3
- useState,
4
- useCallback,
5
- useImperativeHandle,
6
- useMemo,
7
- useRef,
8
- forwardRef,
9
- type ReactNode,
10
- } from "react";
11
- import { CompositionsTab } from "./CompositionsTab";
12
- import { AssetsTab } from "./AssetsTab";
13
- import { trackStudioEvent } from "../../utils/studioTelemetry";
14
- import { safeLocalStorage } from "../../utils/safeStorage";
15
- import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
16
- import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
17
- import { FileTree } from "../editor/FileTree";
18
- import { Tooltip } from "../ui";
19
-
20
- export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
21
-
22
- export interface LeftSidebarHandle {
23
- selectTab: (tab: SidebarTab) => void;
24
- getTab: () => SidebarTab;
25
- }
26
-
27
- const STORAGE_KEY = "hf-studio-sidebar-tab";
28
-
29
- const SIDEBAR_TABS: Array<{ id: SidebarTab; label: string; tooltip: string }> = [
30
- { id: "code", label: "Code", tooltip: "Source code editor" },
31
- { id: "compositions", label: "Comps", tooltip: "Compositions and sub-compositions" },
32
- { id: "assets", label: "Assets", tooltip: "Videos, images, audio, fonts" },
33
- { id: "blocks", label: "Catalog", tooltip: "Browse blocks and components" },
34
- ];
35
-
36
- // Both the `localStorage` reference and `getItem` itself can throw when the
37
- // browsing context is partitioned or site data is blocked — the same case
38
- // telemetry/config.ts documents. This runs as a `useState` initializer, so an
39
- // unguarded throw here takes the whole editor to the crash boundary rather
40
- // than losing one remembered tab.
41
- export function getPersistedTab(): SidebarTab {
42
- let stored: string | null = null;
43
- try {
44
- stored = safeLocalStorage()?.getItem(STORAGE_KEY) ?? null;
45
- } catch {
46
- /* storage unavailable — fall back to the default tab */
47
- }
48
- if (stored === "assets") return "assets";
49
- if (stored === "code") return "code";
50
- if (stored === "blocks") return "blocks";
51
- return "compositions";
52
- }
53
-
54
- interface LeftSidebarProps {
55
- width?: number;
56
- projectId: string;
57
- compositions: string[];
58
- assets: string[];
59
- activeComposition: string | null;
60
- onSelectComposition: (comp: string) => void;
61
- onImportFiles?: (files: FileList, dir?: string) => void | Promise<void>;
62
- fileTree?: string[];
63
- editingFile?: { path: string; content: string | null } | null;
64
- onSelectFile?: (path: string) => void;
65
- onCreateFile?: (path: string) => void;
66
- onCreateFolder?: (path: string) => void;
67
- onDeleteFile?: (path: string) => void;
68
- onRenameFile?: (oldPath: string, newPath: string) => void;
69
- onDuplicateFile?: (path: string) => void;
70
- onMoveFile?: (oldPath: string, newPath: string) => void;
71
- codeChildren?: ReactNode;
72
- onRenderComposition?: (comp: string) => void;
73
- isRendering?: boolean;
74
- onLint?: () => void;
75
- linting?: boolean;
76
- lintFindingCount?: number;
77
- lintHasError?: boolean;
78
- lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
79
- onToggleCollapse?: () => void;
80
- onAddBlock?: (blockName: string) => void | Promise<void>;
81
- onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
82
- takeoverContent?: ReactNode;
83
- onAddAssetToTimeline?: (path: string) => void;
84
- onAddCompositionToTimeline?: (path: string) => void;
85
- }
86
-
87
- export const LeftSidebar = memo(
88
- forwardRef<LeftSidebarHandle, LeftSidebarProps>(function LeftSidebar(
89
- {
90
- width = 240,
91
- projectId,
92
- compositions,
93
- assets,
94
- activeComposition,
95
- onSelectComposition,
96
- onImportFiles,
97
- fileTree: fileProp,
98
- editingFile,
99
- onSelectFile,
100
- onCreateFile,
101
- onCreateFolder,
102
- onDeleteFile,
103
- onRenameFile,
104
- onDuplicateFile,
105
- onMoveFile,
106
- codeChildren,
107
- onRenderComposition,
108
- isRendering,
109
- onLint,
110
- linting,
111
- lintFindingCount,
112
- lintHasError,
113
- lintFindingsByFile,
114
- onToggleCollapse,
115
- onAddBlock,
116
- onPreviewBlock,
117
- takeoverContent,
118
- onAddAssetToTimeline,
119
- onAddCompositionToTimeline,
120
- },
121
- ref,
122
- ) {
123
- const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
124
- const tabRef = useRef(tab);
125
- tabRef.current = tab;
126
- const tablistRef = useRef<HTMLDivElement>(null);
127
- const masterCompositionPath = useMemo(
128
- () => resolveMasterCompositionPath(compositions),
129
- [compositions],
130
- );
131
-
132
- const selectTab = useCallback((t: SidebarTab) => {
133
- setTab(t);
134
- try {
135
- safeLocalStorage()?.setItem(STORAGE_KEY, t);
136
- } catch {
137
- /* storage unavailable — the tab just won't be remembered */
138
- }
139
- trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
140
- }, []);
141
-
142
- const getTab = useCallback(() => tabRef.current, []);
143
-
144
- useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
145
-
146
- // APG tabs pattern: Left/Right move focus AND selection between tabs.
147
- const handleTablistKeyDown = useCallback(
148
- (e: React.KeyboardEvent) => {
149
- if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
150
- e.preventDefault();
151
- const ids = SIDEBAR_TABS.map((t) => t.id);
152
- const idx = ids.indexOf(tabRef.current);
153
- const next = ids[(idx + (e.key === "ArrowRight" ? 1 : -1) + ids.length) % ids.length];
154
- selectTab(next);
155
- tablistRef.current?.querySelector<HTMLButtonElement>(`[data-tab-id="${next}"]`)?.focus();
156
- },
157
- [selectTab],
158
- );
159
-
160
- return (
161
- <div
162
- className="flex flex-col h-full overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
163
- style={{ width }}
164
- >
165
- {takeoverContent ? (
166
- <div className="flex min-h-0 flex-1">{takeoverContent}</div>
167
- ) : (
168
- <>
169
- {/* Tabs — Code first */}
170
- <div className="border-b border-neutral-800/50 px-3 py-3 shrink-0">
171
- <div className="flex items-center gap-2">
172
- <div
173
- ref={tablistRef}
174
- role="tablist"
175
- aria-label="Sidebar panels"
176
- onKeyDown={handleTablistKeyDown}
177
- 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)]"
178
- style={{ gridTemplateColumns: "1fr 1fr 1fr 1fr" }}
179
- >
180
- {SIDEBAR_TABS.map((t) => (
181
- <Tooltip key={t.id} label={t.tooltip} side="bottom">
182
- <button
183
- type="button"
184
- role="tab"
185
- data-tab-id={t.id}
186
- aria-selected={tab === t.id}
187
- aria-controls={`sidebar-panel-${t.id}`}
188
- tabIndex={tab === t.id ? 0 : -1}
189
- onClick={() => selectTab(t.id)}
190
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all active:scale-[0.97] ${
191
- tab === t.id
192
- ? "bg-neutral-800 text-white"
193
- : "text-neutral-500 hover:text-neutral-200"
194
- }`}
195
- >
196
- {t.label}
197
- </button>
198
- </Tooltip>
199
- ))}
200
- </div>
201
- {onToggleCollapse && (
202
- <button
203
- type="button"
204
- onClick={onToggleCollapse}
205
- className="flex h-8 w-8 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"
206
- title="Hide sidebar"
207
- aria-label="Hide sidebar"
208
- >
209
- <svg
210
- width="14"
211
- height="14"
212
- viewBox="0 0 24 24"
213
- fill="none"
214
- stroke="currentColor"
215
- strokeWidth="1.5"
216
- strokeLinecap="round"
217
- strokeLinejoin="round"
218
- aria-hidden="true"
219
- >
220
- <path d="m14 7-5 5 5 5" />
221
- <path d="M19 4v16" />
222
- </svg>
223
- </button>
224
- )}
225
- </div>
226
- </div>
227
-
228
- {/* Tab content */}
229
- {tab === "compositions" && (
230
- <div
231
- id="sidebar-panel-compositions"
232
- role="tabpanel"
233
- className="flex flex-col flex-1 min-h-0"
234
- >
235
- <CompositionsTab
236
- projectId={projectId}
237
- compositions={compositions}
238
- activeComposition={activeComposition}
239
- masterCompositionPath={masterCompositionPath}
240
- onSelect={onSelectComposition}
241
- onAddToTimeline={onAddCompositionToTimeline}
242
- onRenderComposition={onRenderComposition}
243
- isRendering={isRendering}
244
- lintFindingsByFile={lintFindingsByFile}
245
- />
246
- </div>
247
- )}
248
- {tab === "assets" && (
249
- <div
250
- id="sidebar-panel-assets"
251
- role="tabpanel"
252
- className="flex flex-col flex-1 min-h-0"
253
- >
254
- <AssetsTab
255
- projectId={projectId}
256
- assets={assets}
257
- onImport={onImportFiles}
258
- onDelete={onDeleteFile}
259
- onRename={onRenameFile}
260
- onAddAssetToTimeline={onAddAssetToTimeline}
261
- />
262
- </div>
263
- )}
264
- {tab === "code" && (
265
- <div id="sidebar-panel-code" role="tabpanel" className="flex flex-1 min-h-0">
266
- {(fileProp?.length ?? 0) > 0 && (
267
- <div className="w-[160px] shrink-0 border-r border-neutral-800 overflow-y-auto">
268
- <FileTree
269
- files={fileProp ?? []}
270
- activeFile={editingFile?.path ?? null}
271
- onSelectFile={onSelectFile ?? (() => {})}
272
- onCreateFile={onCreateFile}
273
- onCreateFolder={onCreateFolder}
274
- onDeleteFile={onDeleteFile}
275
- onRenameFile={onRenameFile}
276
- onDuplicateFile={onDuplicateFile}
277
- onMoveFile={onMoveFile}
278
- onImportFiles={onImportFiles}
279
- lintFindingsByFile={lintFindingsByFile}
280
- />
281
- </div>
282
- )}
283
- <div className="flex-1 overflow-hidden min-w-0">
284
- {codeChildren ?? (
285
- <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
286
- Select a file to edit
287
- </div>
288
- )}
289
- </div>
290
- </div>
291
- )}
292
-
293
- {tab === "blocks" && (
294
- <div
295
- id="sidebar-panel-blocks"
296
- role="tabpanel"
297
- className="flex flex-col flex-1 min-h-0"
298
- >
299
- <BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
300
- </div>
301
- )}
302
-
303
- {/* Lint button pinned at the bottom */}
304
- {onLint && (
305
- <div className="border-t border-neutral-800 p-2 shrink-0">
306
- <button
307
- onClick={onLint}
308
- disabled={linting}
309
- 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 enabled:hover:text-amber-300 enabled:hover:bg-neutral-800 enabled:active:scale-[0.98] transition-colors disabled:opacity-40"
310
- >
311
- <svg
312
- width="12"
313
- height="12"
314
- viewBox="0 0 24 24"
315
- fill="none"
316
- stroke="currentColor"
317
- strokeWidth="2"
318
- >
319
- <path d="M9 11l3 3L22 4" />
320
- <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
321
- </svg>
322
- {linting ? "Linting…" : "Lint"}
323
- {!linting && lintFindingCount != null && lintFindingCount > 0 && (
324
- <span
325
- data-lint-badge={lintHasError ? "error" : "warning"}
326
- className={
327
- lintHasError
328
- ? "ml-1 min-w-[16px] rounded-full bg-panel-danger/25 px-1 text-[9px] font-bold text-panel-danger animate-pulse motion-reduce:animate-none"
329
- : "ml-1 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[9px] font-bold text-amber-400"
330
- }
331
- >
332
- {lintFindingCount}
333
- <span className="sr-only">
334
- {lintHasError
335
- ? " lint findings, including errors"
336
- : " lint findings, warnings only"}
337
- </span>
338
- </span>
339
- )}
340
- </button>
341
- </div>
342
- )}
343
- </>
344
- )}
345
- </div>
346
- );
347
- }),
348
- );
@@ -1,51 +0,0 @@
1
- import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
-
3
- const MIN_INSPECTOR_SPLIT_PERCENT = 20;
4
- const MAX_INSPECTOR_SPLIT_PERCENT = 75;
5
-
6
- export function useInspectorSplitResize() {
7
- const [layersPanePercent, setLayersPanePercent] = useState(40);
8
- const splitContainerRef = useRef<HTMLDivElement>(null);
9
- const splitDragRef = useRef<{
10
- startY: number;
11
- startPercent: number;
12
- height: number;
13
- } | null>(null);
14
-
15
- const handleInspectorSplitResizeStart = useCallback(
16
- (event: ReactPointerEvent<HTMLDivElement>) => {
17
- event.preventDefault();
18
- event.currentTarget.setPointerCapture(event.pointerId);
19
- const height = splitContainerRef.current?.getBoundingClientRect().height ?? 0;
20
- splitDragRef.current = {
21
- startY: event.clientY,
22
- startPercent: layersPanePercent,
23
- height,
24
- };
25
- },
26
- [layersPanePercent],
27
- );
28
-
29
- const handleInspectorSplitResizeMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
30
- const drag = splitDragRef.current;
31
- if (!drag || drag.height <= 0) return;
32
- const deltaPercent = ((event.clientY - drag.startY) / drag.height) * 100;
33
- const next = Math.min(
34
- MAX_INSPECTOR_SPLIT_PERCENT,
35
- Math.max(MIN_INSPECTOR_SPLIT_PERCENT, drag.startPercent + deltaPercent),
36
- );
37
- setLayersPanePercent(next);
38
- }, []);
39
-
40
- const handleInspectorSplitResizeEnd = useCallback(() => {
41
- splitDragRef.current = null;
42
- }, []);
43
-
44
- return {
45
- layersPanePercent,
46
- splitContainerRef,
47
- handleInspectorSplitResizeStart,
48
- handleInspectorSplitResizeMove,
49
- handleInspectorSplitResizeEnd,
50
- };
51
- }
@@ -1,164 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- MIN_PREVIEW_H,
4
- MIN_PREVIEW_W,
5
- MIN_TIMELINE_H,
6
- RAIL_W,
7
- defaultPanelWidths,
8
- fitPanelWidths,
9
- fitTimelineHeight,
10
- } from "./fitPanels";
11
-
12
- function fitAt(viewportWidth: number) {
13
- return fitPanelWidths(viewportWidth, defaultPanelWidths(viewportWidth));
14
- }
15
-
16
- describe("defaultPanelWidths", () => {
17
- // Frozen literals, deliberately NOT compared against the live hook: the hook
18
- // now delegates here, so asserting against it would be circular and would
19
- // silently stop guarding the "wide windows are untouched" promise.
20
- it.each([
21
- [1680, 384, 424],
22
- [1512, 384, 424],
23
- [1280, 329, 364],
24
- ])("keeps %ipx identical to the pre-change defaults", (vw, left, right) => {
25
- expect(defaultPanelWidths(vw)).toEqual({ left, right });
26
- });
27
- });
28
-
29
- describe("fitPanelWidths", () => {
30
- it.each([1680, 1512, 1280])("leaves %ipx untouched by reconciliation", (vw) => {
31
- const preferred = defaultPanelWidths(vw);
32
- const fitted = fitPanelWidths(vw, preferred);
33
- expect(fitted.left).toBe(preferred.left);
34
- expect(fitted.right).toBe(preferred.right);
35
- expect(fitted.autoCollapseLeft).toBe(false);
36
- expect(fitted.autoCollapseRight).toBe(false);
37
- });
38
-
39
- it.each([1680, 1512, 1280, 1100, 960, 860, 760, 640, 560, 480])(
40
- "never starves the preview at %ipx",
41
- (vw) => {
42
- expect(fitAt(vw).preview).toBeGreaterThanOrEqual(MIN_PREVIEW_W);
43
- },
44
- );
45
-
46
- it("matches the projected preview widths from the plan", () => {
47
- const projected: Array<[number, number]> = [
48
- [1680, 866],
49
- [1512, 698],
50
- [1280, 581],
51
- [1100, 499],
52
- [960, 427],
53
- [860, 360],
54
- [760, 432],
55
- [640, 592],
56
- [560, 512],
57
- [480, 432],
58
- ];
59
- for (const [vw, preview] of projected) {
60
- expect({ vw, preview: fitAt(vw).preview }).toEqual({ vw, preview });
61
- }
62
- });
63
-
64
- it("fits everything at its minimum at exactly 860, the derived threshold", () => {
65
- const fitted = fitAt(860);
66
- expect(fitted).toMatchObject({
67
- left: 214,
68
- right: 280,
69
- preview: MIN_PREVIEW_W,
70
- autoCollapseLeft: false,
71
- });
72
- });
73
-
74
- it("rails the sidebar one pixel below the threshold", () => {
75
- expect(fitAt(860).autoCollapseLeft).toBe(false);
76
- expect(fitAt(859).autoCollapseLeft).toBe(true);
77
- expect(fitAt(859).left).toBe(RAIL_W);
78
- });
79
-
80
- it("hides the inspector one pixel below its own threshold", () => {
81
- expect(fitAt(700).autoCollapseRight).toBe(false);
82
- expect(fitAt(700).right).toBeGreaterThan(0);
83
- expect(fitAt(699).autoCollapseRight).toBe(true);
84
- expect(fitAt(699).right).toBe(0);
85
- });
86
-
87
- it("never drives an EXPANDED sidebar below its usable minimum", () => {
88
- // Guards the leftFloor/rightFloor split: the squeeze fallback may only reach
89
- // the rail width when the panel is actually railed.
90
- const fitted = fitPanelWidths(900, { left: 600, right: 600 });
91
- expect(fitted.left).toBeGreaterThanOrEqual(200);
92
- expect(fitted.right).toBeGreaterThanOrEqual(280);
93
- });
94
-
95
- it("caps a single panel at 40% of the window", () => {
96
- expect(fitPanelWidths(1600, { left: 900, right: 280 }).left).toBe(640);
97
- });
98
-
99
- it("lets the preview floor win when the cap alone is not enough", () => {
100
- // 40% of 1000 is 400, but 400 + 280 + 6 leaves the preview at 314.
101
- const fitted = fitPanelWidths(1000, { left: 900, right: 280 });
102
- expect(fitted.left).toBe(354);
103
- expect(fitted.preview).toBe(MIN_PREVIEW_W);
104
- });
105
-
106
- it("survives a viewport narrower than the preview floor alone", () => {
107
- const fitted = fitAt(300);
108
- expect(fitted.preview).toBeGreaterThanOrEqual(0);
109
- expect(Number.isNaN(fitted.preview)).toBe(false);
110
- expect(fitted.left).toBe(RAIL_W);
111
- });
112
-
113
- it("returns preferences untouched before the shell is measured", () => {
114
- const preferred = { left: 320, right: 400 };
115
- expect(fitPanelWidths(0, preferred)).toMatchObject({ ...preferred, preview: 0 });
116
- expect(fitPanelWidths(Number.NaN, preferred)).toMatchObject(preferred);
117
- });
118
-
119
- it("is idempotent, so a resize loop cannot oscillate", () => {
120
- for (const vw of [1512, 1100, 860, 760, 560]) {
121
- const once = fitPanelWidths(vw, defaultPanelWidths(vw));
122
- const twice = fitPanelWidths(vw, once);
123
- expect(twice).toEqual(once);
124
- }
125
- });
126
- });
127
-
128
- describe("fitTimelineHeight", () => {
129
- it("keeps a height that fits", () => {
130
- expect(fitTimelineHeight(717, 429)).toBe(429);
131
- });
132
-
133
- it("reclaims height for the preview when the shell shrinks", () => {
134
- // The measured bug: mounted at 760 tall, dragged to 520. Preview was 47px.
135
- expect(fitTimelineHeight(477, 429)).toBe(477 - MIN_PREVIEW_H);
136
- expect(477 - fitTimelineHeight(477, 429)).toBeGreaterThanOrEqual(MIN_PREVIEW_H);
137
- });
138
-
139
- it("raises a too-small preference to the timeline minimum", () => {
140
- expect(fitTimelineHeight(717, 10)).toBe(MIN_TIMELINE_H);
141
- });
142
-
143
- it("keeps the timeline usable when the shell is shorter than both minimums", () => {
144
- expect(fitTimelineHeight(120, 429)).toBe(MIN_TIMELINE_H);
145
- });
146
-
147
- it("returns the preference before the shell is measured", () => {
148
- expect(fitTimelineHeight(0, 429)).toBe(429);
149
- });
150
-
151
- it("is idempotent", () => {
152
- const once = fitTimelineHeight(477, 429);
153
- expect(fitTimelineHeight(477, once)).toBe(once);
154
- });
155
-
156
- it("would return the preferred height if callers kept one", () => {
157
- // The helper is preference-preserving; the vertical CALLER still stores the
158
- // clamped value, so a shrink-then-grow does not restore the old height the
159
- // way panel widths do. Tracked as a known asymmetry, not fixed here.
160
- const preferred = 429;
161
- expect(fitTimelineHeight(477, preferred)).toBe(277);
162
- expect(fitTimelineHeight(717, preferred)).toBe(429);
163
- });
164
- });