@hyperframes/studio 0.8.6 → 0.8.8

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +3 -5
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +8 -8
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +8 -1
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -1,5 +1,8 @@
1
- import { memo, useState, useCallback } from "react";
1
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { BlockParam } from "@hyperframes/core/registry";
3
+ import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
4
+ import { useStudioPlaybackContext } from "../../contexts/StudioContext";
5
+ import { trackBlockParamCommit } from "../../telemetry/events";
3
6
 
4
7
  interface BlockParamsPanelProps {
5
8
  blockName: string;
@@ -9,12 +12,17 @@ interface BlockParamsPanelProps {
9
12
  onClose: () => void;
10
13
  }
11
14
 
15
+ type CommitState = { tone: "idle" | "saving" | "saved" | "error"; message?: string };
16
+
12
17
  export const BlockParamsPanel = memo(function BlockParamsPanel({
18
+ blockName,
13
19
  blockTitle,
14
20
  params,
15
- compositionPath: _compositionPath,
21
+ compositionPath,
16
22
  onClose,
17
23
  }: BlockParamsPanelProps) {
24
+ const fileManager = useFileManagerContextOptional();
25
+ const { setRefreshKey } = useStudioPlaybackContext();
18
26
  const [values, setValues] = useState<Record<string, string>>(() => {
19
27
  const initial: Record<string, string> = {};
20
28
  for (const p of params) {
@@ -22,10 +30,107 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
22
30
  }
23
31
  return initial;
24
32
  });
33
+ // Last value actually written to the block file per param — the literal we
34
+ // substitute when the next commit rewrites the file.
35
+ const appliedRef = useRef<Record<string, string>>(
36
+ Object.fromEntries(params.map((p) => [p.key, p.default])),
37
+ );
38
+ const [commitState, setCommitState] = useState<CommitState>({ tone: "idle" });
39
+ // Per-param debounce timers: a single shared timer would let a second
40
+ // param's edit silently cancel the first param's pending commit.
41
+ const commitTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
25
42
 
26
- const handleChange = useCallback((key: string, value: string) => {
27
- setValues((prev) => ({ ...prev, [key]: value }));
28
- }, []);
43
+ const commitParamNow = useCallback(
44
+ async (key: string, nextValue: string) => {
45
+ if (!fileManager) return;
46
+ const previous = appliedRef.current[key];
47
+ if (previous === undefined || previous === nextValue || !nextValue.trim()) return;
48
+ setCommitState({ tone: "saving" });
49
+ try {
50
+ const content = await fileManager.readProjectFile(compositionPath);
51
+ // Token-boundary match so "#0c0c0c" never rewrites part of "#0c0c0cff".
52
+ const matcher = new RegExp(
53
+ `(?<![-\\w#])${previous.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![-\\w])`,
54
+ "g",
55
+ );
56
+ const matches = content.match(matcher)?.length ?? 0;
57
+ if (matches === 0) {
58
+ setCommitState({
59
+ tone: "error",
60
+ message: `Couldn't find the current value in ${compositionPath} — it may have been edited by hand.`,
61
+ });
62
+ trackBlockParamCommit({ tone: "error", blockName, key });
63
+ return;
64
+ }
65
+ // The panel maps a param to a bare literal, with no per-occurrence
66
+ // binding metadata. At the string level it cannot tell "both of these
67
+ // belong to this param" from "one is unrelated adjacent content" (a
68
+ // sibling block, a decoration, a comment). A blind replace of a literal
69
+ // that appears more than once could silently mutate that unrelated
70
+ // content — so refuse and tell the user to disambiguate by hand. Only a
71
+ // unique single occurrence is safe to rewrite automatically.
72
+ if (matches > 1) {
73
+ setCommitState({
74
+ tone: "error",
75
+ message: `"${previous}" appears ${matches}× in ${compositionPath} — the panel can't tell which one belongs to this parameter, so it won't risk changing unrelated content. Edit the file directly to disambiguate.`,
76
+ });
77
+ trackBlockParamCommit({ tone: "error", blockName, key });
78
+ return;
79
+ }
80
+ await fileManager.writeProjectFile(compositionPath, content.replace(matcher, nextValue));
81
+ appliedRef.current[key] = nextValue;
82
+ setCommitState({ tone: "saved" });
83
+ trackBlockParamCommit({ tone: "saved", blockName, key });
84
+ setRefreshKey((k) => k + 1);
85
+ } catch {
86
+ setCommitState({ tone: "error", message: "Couldn't save the block file. Retry?" });
87
+ trackBlockParamCommit({ tone: "error", blockName, key });
88
+ }
89
+ },
90
+ [fileManager, compositionPath, setRefreshKey, blockName],
91
+ );
92
+
93
+ // Commits are serialized: two params committing concurrently would each
94
+ // read-modify-write the same file and the second write would drop the first.
95
+ const commitChainRef = useRef<Promise<void>>(Promise.resolve());
96
+ const commitParam = useCallback(
97
+ (key: string, nextValue: string) => {
98
+ const run = commitChainRef.current.then(() => commitParamNow(key, nextValue));
99
+ commitChainRef.current = run.catch(() => undefined);
100
+ return run;
101
+ },
102
+ [commitParamNow],
103
+ );
104
+
105
+ const handleChange = useCallback(
106
+ (key: string, value: string) => {
107
+ setValues((prev) => ({ ...prev, [key]: value }));
108
+ const timers = commitTimersRef.current;
109
+ const existing = timers.get(key);
110
+ if (existing) clearTimeout(existing);
111
+ timers.set(
112
+ key,
113
+ setTimeout(() => {
114
+ timers.delete(key);
115
+ void commitParam(key, value);
116
+ }, 300),
117
+ );
118
+ },
119
+ [commitParam],
120
+ );
121
+
122
+ // Flush (not discard) pending commits on unmount so closing the panel
123
+ // within the debounce window doesn't silently drop the last edit.
124
+ const flushRef = useRef<() => void>(() => {});
125
+ flushRef.current = () => {
126
+ for (const [key, timer] of commitTimersRef.current) {
127
+ clearTimeout(timer);
128
+ const value = values[key];
129
+ if (value !== undefined) void commitParam(key, value);
130
+ }
131
+ commitTimersRef.current.clear();
132
+ };
133
+ useEffect(() => () => flushRef.current(), []);
29
134
 
30
135
  return (
31
136
  <div className="flex flex-col h-full">
@@ -34,7 +139,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
34
139
  <button
35
140
  type="button"
36
141
  onClick={onClose}
37
- className="text-neutral-500 hover:text-neutral-300 transition-colors"
142
+ aria-label="Close block parameters"
143
+ className="p-1.5 -m-1 text-neutral-500 hover:text-neutral-300 active:scale-[0.97] transition-colors"
38
144
  >
39
145
  <svg
40
146
  width="14"
@@ -56,14 +162,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
56
162
  <div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
57
163
  Parameters
58
164
  </div>
165
+ {params.length === 0 && (
166
+ <div className="text-[10px] text-neutral-500">This block has no editable parameters.</div>
167
+ )}
168
+ {!fileManager && params.length > 0 && (
169
+ <div className="text-[10px] text-amber-400/90">
170
+ Block params can't be edited here — no project file access.
171
+ </div>
172
+ )}
59
173
  {params.map((param) => (
60
174
  <ParamControl
61
175
  key={param.key}
62
176
  param={param}
63
177
  value={values[param.key] ?? param.default}
178
+ disabled={!fileManager}
64
179
  onChange={(v) => handleChange(param.key, v)}
65
180
  />
66
181
  ))}
182
+ {commitState.tone === "saving" && (
183
+ <div className="text-[10px] text-neutral-500" role="status">
184
+ Saving…
185
+ </div>
186
+ )}
187
+ {commitState.tone === "saved" && (
188
+ <div className="text-[10px] text-emerald-500/90" role="status">
189
+ Saved to {compositionPath}
190
+ </div>
191
+ )}
192
+ {commitState.tone === "error" && (
193
+ <div className="text-[10px] text-red-400" role="alert">
194
+ {commitState.message}
195
+ </div>
196
+ )}
67
197
  </div>
68
198
  </div>
69
199
  );
@@ -72,10 +202,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
72
202
  function ParamControl({
73
203
  param,
74
204
  value,
205
+ disabled,
75
206
  onChange,
76
207
  }: {
77
208
  param: BlockParam;
78
209
  value: string;
210
+ disabled?: boolean;
79
211
  onChange: (value: string) => void;
80
212
  }) {
81
213
  return (
@@ -87,14 +219,18 @@ function ParamControl({
87
219
  <input
88
220
  type="color"
89
221
  value={value}
222
+ disabled={disabled}
223
+ aria-label={`${param.label} color`}
90
224
  onChange={(e) => onChange(e.target.value)}
91
- className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
225
+ className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
92
226
  />
93
227
  <input
94
228
  type="text"
95
229
  value={value}
230
+ disabled={disabled}
231
+ aria-label={`${param.label} value`}
96
232
  onChange={(e) => onChange(e.target.value)}
97
- className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700"
233
+ className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
98
234
  />
99
235
  </div>
100
236
  )}
@@ -107,8 +243,10 @@ function ParamControl({
107
243
  max={param.max ?? 100}
108
244
  step={param.step ?? 1}
109
245
  value={value}
246
+ disabled={disabled}
247
+ aria-label={param.label}
110
248
  onChange={(e) => onChange(e.target.value)}
111
- className="flex-1"
249
+ className="flex-1 disabled:cursor-not-allowed disabled:opacity-50"
112
250
  />
113
251
  <span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
114
252
  </div>
@@ -118,16 +256,20 @@ function ParamControl({
118
256
  <input
119
257
  type="text"
120
258
  value={value}
259
+ disabled={disabled}
260
+ aria-label={param.label}
121
261
  onChange={(e) => onChange(e.target.value)}
122
- className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
262
+ className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
123
263
  />
124
264
  )}
125
265
 
126
266
  {param.type === "select" && param.options && (
127
267
  <select
128
268
  value={value}
269
+ disabled={disabled}
270
+ aria-label={param.label}
129
271
  onChange={(e) => onChange(e.target.value)}
130
- className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
272
+ className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
131
273
  >
132
274
  {param.options.map((opt) => (
133
275
  <option key={opt.value} value={opt.value}>
@@ -79,34 +79,15 @@ export function BorderRadiusEditor({
79
79
  stroke="rgba(255,255,255,0.24)"
80
80
  strokeWidth={1.5}
81
81
  />
82
- <circle
83
- cx={sTL}
84
- cy={sTL}
85
- r={3}
86
- fill={linked ? "#3b82f6" : "#a78bfa"}
87
- className="cursor-pointer"
88
- />
89
- <circle
90
- cx={PREVIEW_W - sTR}
91
- cy={sTR}
92
- r={3}
93
- fill={linked ? "#3b82f6" : "#a78bfa"}
94
- className="cursor-pointer"
95
- />
82
+ <circle cx={sTL} cy={sTL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
83
+ <circle cx={PREVIEW_W - sTR} cy={sTR} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
96
84
  <circle
97
85
  cx={PREVIEW_W - sBR}
98
86
  cy={PREVIEW_H - sBR}
99
87
  r={3}
100
88
  fill={linked ? "#3b82f6" : "#a78bfa"}
101
- className="cursor-pointer"
102
- />
103
- <circle
104
- cx={sBL}
105
- cy={PREVIEW_H - sBL}
106
- r={3}
107
- fill={linked ? "#3b82f6" : "#a78bfa"}
108
- className="cursor-pointer"
109
89
  />
90
+ <circle cx={sBL} cy={PREVIEW_H - sBL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
110
91
  </svg>
111
92
 
112
93
  <button
@@ -49,7 +49,12 @@ export const FileTree = memo(function FileTree({
49
49
 
50
50
  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
51
51
  const [inlineInput, setInlineInput] = useState<InlineInputState | null>(null);
52
- const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
52
+ const [deleteTarget, setDeleteTarget] = useState<{
53
+ path: string;
54
+ isFolder: boolean;
55
+ x: number;
56
+ y: number;
57
+ } | null>(null);
53
58
  const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
54
59
  const dragSourceRef = useRef<string | null>(null);
55
60
 
@@ -145,13 +150,23 @@ export const FileTree = memo(function FileTree({
145
150
 
146
151
  // ── Delete ──
147
152
 
148
- const handleDelete = useCallback((path: string) => {
149
- setDeleteTarget(path);
150
- }, []);
153
+ const handleDelete = useCallback(
154
+ (path: string) => {
155
+ // Anchor the confirm near where the context menu was opened so it
156
+ // appears next to the row the user acted on.
157
+ setDeleteTarget({
158
+ path,
159
+ isFolder: contextMenu?.targetIsFolder ?? false,
160
+ x: contextMenu?.x ?? window.innerWidth / 2,
161
+ y: contextMenu?.y ?? window.innerHeight / 2,
162
+ });
163
+ },
164
+ [contextMenu],
165
+ );
151
166
 
152
167
  const handleDeleteConfirm = useCallback(() => {
153
168
  if (deleteTarget) {
154
- onDeleteFile?.(deleteTarget);
169
+ onDeleteFile?.(deleteTarget.path);
155
170
  setDeleteTarget(null);
156
171
  }
157
172
  }, [deleteTarget, onDeleteFile]);
@@ -227,15 +242,17 @@ export const FileTree = memo(function FileTree({
227
242
  <div className="flex items-center gap-0.5">
228
243
  <button
229
244
  onClick={() => handleNewFile("")}
230
- className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
245
+ className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
231
246
  title="New File"
247
+ aria-label="New File"
232
248
  >
233
249
  <Plus size={12} weight="bold" />
234
250
  </button>
235
251
  <button
236
252
  onClick={() => handleNewFolder("")}
237
- className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
253
+ className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
238
254
  title="New Folder"
255
+ aria-label="New Folder"
239
256
  >
240
257
  <FolderSimplePlus size={12} weight="duotone" />
241
258
  </button>
@@ -274,6 +291,11 @@ export const FileTree = memo(function FileTree({
274
291
  onCancel={() => inlineInput.onCancel?.()}
275
292
  />
276
293
  )}
294
+ {children.length === 0 && !inlineInput && (
295
+ <div className="px-3 py-4 text-center text-[11px] text-neutral-600">
296
+ No files yet{hasFileOps ? " — use + above to create one" : ""}.
297
+ </div>
298
+ )}
277
299
  {children.map((child) =>
278
300
  child.isFile && child.children.size === 0 ? (
279
301
  <TreeFile
@@ -308,15 +330,22 @@ export const FileTree = memo(function FileTree({
308
330
  )}
309
331
  </div>
310
332
 
311
- {/* Delete confirmation overlay */}
333
+ {/* Delete confirmation anchored near the row it was invoked on */}
312
334
  {deleteTarget && (
313
- <div className="border-t border-neutral-800/50 flex-shrink-0">
335
+ <div
336
+ className="fixed z-50 w-56"
337
+ style={{
338
+ left: Math.min(deleteTarget.x, window.innerWidth - 240),
339
+ top: Math.min(deleteTarget.y, window.innerHeight - 120),
340
+ }}
341
+ >
314
342
  <DeleteConfirm
315
343
  name={
316
- deleteTarget.includes("/")
317
- ? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
318
- : deleteTarget
344
+ deleteTarget.path.includes("/")
345
+ ? deleteTarget.path.slice(deleteTarget.path.lastIndexOf("/") + 1)
346
+ : deleteTarget.path
319
347
  }
348
+ isFolder={deleteTarget.isFolder}
320
349
  onConfirm={handleDeleteConfirm}
321
350
  onCancel={handleDeleteCancel}
322
351
  />
@@ -43,9 +43,14 @@ export function ContextMenu({
43
43
  onDelete: (path: string) => void;
44
44
  }) {
45
45
  const menuRef = useRef<HTMLDivElement>(null);
46
+ const restoreFocusRef = useRef<HTMLElement | null>(null);
46
47
 
47
48
  // eslint-disable-next-line no-restricted-syntax
48
49
  useEffect(() => {
50
+ restoreFocusRef.current =
51
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
52
+ const firstItem = menuRef.current?.querySelector("button");
53
+ if (firstItem instanceof HTMLElement) firstItem.focus();
49
54
  const handleClickOutside = (e: MouseEvent) => {
50
55
  if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
51
56
  onClose();
@@ -59,9 +64,28 @@ export function ContextMenu({
59
64
  return () => {
60
65
  document.removeEventListener("mousedown", handleClickOutside);
61
66
  document.removeEventListener("keydown", handleEscape);
67
+ restoreFocusRef.current?.focus();
62
68
  };
63
69
  }, [onClose]);
64
70
 
71
+ const handleMenuKeyDown = (e: React.KeyboardEvent) => {
72
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
73
+ return;
74
+ }
75
+ const menu = menuRef.current;
76
+ if (!menu) return;
77
+ e.preventDefault();
78
+ const items = Array.from(menu.querySelectorAll("button"));
79
+ if (items.length === 0) return;
80
+ const current = items.indexOf(document.activeElement as HTMLButtonElement);
81
+ let next = 0;
82
+ if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length;
83
+ else if (e.key === "ArrowUp") {
84
+ next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
85
+ } else if (e.key === "End") next = items.length - 1;
86
+ items[next]?.focus();
87
+ };
88
+
65
89
  const adjustedX = Math.min(state.x, window.innerWidth - 180);
66
90
  const adjustedY = Math.min(state.y, window.innerHeight - 200);
67
91
 
@@ -74,13 +98,16 @@ export function ContextMenu({
74
98
  return (
75
99
  <div
76
100
  ref={menuRef}
101
+ role="menu"
102
+ onKeyDown={handleMenuKeyDown}
77
103
  className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
78
104
  style={{ left: adjustedX, top: adjustedY }}
79
105
  >
80
106
  {state.targetIsFolder && (
81
107
  <>
82
108
  <button
83
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
109
+ role="menuitem"
110
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
84
111
  onClick={() => {
85
112
  onNewFile(state.targetPath);
86
113
  onClose();
@@ -90,7 +117,8 @@ export function ContextMenu({
90
117
  New File
91
118
  </button>
92
119
  <button
93
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
120
+ role="menuitem"
121
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
94
122
  onClick={() => {
95
123
  onNewFolder(state.targetPath);
96
124
  onClose();
@@ -105,7 +133,8 @@ export function ContextMenu({
105
133
  {!state.targetIsFolder && (
106
134
  <>
107
135
  <button
108
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
136
+ role="menuitem"
137
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
109
138
  onClick={() => {
110
139
  onNewFile(parentPath);
111
140
  onClose();
@@ -118,7 +147,8 @@ export function ContextMenu({
118
147
  </>
119
148
  )}
120
149
  <button
121
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
150
+ role="menuitem"
151
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
122
152
  onClick={() => {
123
153
  onRename(state.targetPath);
124
154
  onClose();
@@ -129,7 +159,8 @@ export function ContextMenu({
129
159
  </button>
130
160
  {!state.targetIsFolder && (
131
161
  <button
132
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
162
+ role="menuitem"
163
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
133
164
  onClick={() => {
134
165
  onDuplicate(state.targetPath);
135
166
  onClose();
@@ -141,7 +172,8 @@ export function ContextMenu({
141
172
  )}
142
173
  <div className="border-t border-neutral-700 my-1" />
143
174
  <button
144
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 cursor-pointer text-left"
175
+ role="menuitem"
176
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 focus-visible:bg-red-900/30 active:bg-red-900/50 outline-none cursor-pointer text-left"
145
177
  onClick={() => {
146
178
  onDelete(state.targetPath);
147
179
  onClose();
@@ -172,6 +204,13 @@ export function InlineInput({
172
204
  const inputRef = useRef<HTMLInputElement>(null);
173
205
  const committedRef = useRef(false);
174
206
  const [value, setValue] = useState(defaultValue);
207
+ const [error, setError] = useState<string | null>(null);
208
+
209
+ const validate = (name: string): string | null => {
210
+ if (/[/\\]/.test(name)) return "Name can't contain / or \\";
211
+ if (name.includes("..")) return "Name can't contain ..";
212
+ return null;
213
+ };
175
214
 
176
215
  // eslint-disable-next-line no-restricted-syntax
177
216
  useEffect(() => {
@@ -196,8 +235,16 @@ export function InlineInput({
196
235
  if (e.key === "Enter") {
197
236
  e.preventDefault();
198
237
  const trimmed = value.trim();
199
- if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
200
- else onCancel();
238
+ if (!trimmed) {
239
+ onCancel();
240
+ return;
241
+ }
242
+ const invalid = validate(trimmed);
243
+ if (invalid) {
244
+ setError(invalid);
245
+ return;
246
+ }
247
+ commit(trimmed);
201
248
  } else if (e.key === "Escape") {
202
249
  e.preventDefault();
203
250
  onCancel();
@@ -206,8 +253,7 @@ export function InlineInput({
206
253
 
207
254
  const handleBlur = () => {
208
255
  const trimmed = value.trim();
209
- if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
210
- commit(trimmed);
256
+ if (trimmed && trimmed !== defaultValue && !validate(trimmed)) commit(trimmed);
211
257
  else onCancel();
212
258
  };
213
259
 
@@ -221,15 +267,28 @@ export function InlineInput({
221
267
  ) : (
222
268
  <FileIcon path={value} />
223
269
  )}
224
- <input
225
- ref={inputRef}
226
- value={value}
227
- onChange={(e) => setValue(e.target.value)}
228
- onKeyDown={handleKeyDown}
229
- onBlur={handleBlur}
230
- className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]"
231
- spellCheck={false}
232
- />
270
+ <div className="flex-1 min-w-0">
271
+ <input
272
+ ref={inputRef}
273
+ value={value}
274
+ onChange={(e) => {
275
+ setValue(e.target.value);
276
+ if (error) setError(null);
277
+ }}
278
+ onKeyDown={handleKeyDown}
279
+ onBlur={handleBlur}
280
+ aria-invalid={error ? true : undefined}
281
+ className={`w-full min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border outline-none ${
282
+ error ? "border-red-500/70" : "border-neutral-600 focus:border-[#3CE6AC]"
283
+ }`}
284
+ spellCheck={false}
285
+ />
286
+ {error && (
287
+ <div className="mt-0.5 text-[10px] text-red-400" role="alert">
288
+ {error}
289
+ </div>
290
+ )}
291
+ </div>
233
292
  </div>
234
293
  );
235
294
  }
@@ -238,10 +297,12 @@ export function InlineInput({
238
297
 
239
298
  export function DeleteConfirm({
240
299
  name,
300
+ isFolder,
241
301
  onConfirm,
242
302
  onCancel,
243
303
  }: {
244
304
  name: string;
305
+ isFolder?: boolean;
245
306
  onConfirm: () => void;
246
307
  onCancel: () => void;
247
308
  }) {
@@ -269,7 +330,16 @@ export function DeleteConfirm({
269
330
  className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
270
331
  >
271
332
  <p className="text-neutral-300 mb-2">
272
- Delete <span className="font-medium text-neutral-100">{name}</span>?
333
+ {isFolder ? (
334
+ <>
335
+ Delete folder <span className="font-medium text-neutral-100">{name}</span> and
336
+ everything inside it?
337
+ </>
338
+ ) : (
339
+ <>
340
+ Delete <span className="font-medium text-neutral-100">{name}</span>?
341
+ </>
342
+ )}
273
343
  </p>
274
344
  <div className="flex gap-1.5">
275
345
  <button
@@ -61,6 +61,11 @@ export function GsapAnimationList({
61
61
 
62
62
  return (
63
63
  <div className="space-y-2">
64
+ {animations.length === 0 && (
65
+ <p className="text-[11px] leading-4 text-neutral-500">
66
+ No animations on this element yet — add an effect below to animate it.
67
+ </p>
68
+ )}
64
69
  {animations.map((animation, index) => (
65
70
  <AnimationCard
66
71
  {...trackedCallbacks}
@@ -29,9 +29,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
29
29
  e.stopPropagation();
30
30
  onClick();
31
31
  }}
32
- className="flex-shrink-0 p-0.5 transition-opacity hover:opacity-100"
32
+ className="relative flex-shrink-0 p-0.5 transition-opacity hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']"
33
33
  style={{ color, opacity }}
34
34
  title={title}
35
+ aria-label={title}
36
+ aria-pressed={state === "active"}
35
37
  >
36
38
  <svg width={size} height={size} viewBox="0 0 10 10">
37
39
  {isHold ? (
@@ -104,7 +104,8 @@ export function KeyframeEaseList({
104
104
  <button
105
105
  type="button"
106
106
  onClick={() => onToggle(isExpanded ? null : kf.percentage)}
107
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
107
+ aria-expanded={isExpanded}
108
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left active:scale-[0.99]"
108
109
  >
109
110
  <span className="text-[10px] font-medium text-neutral-400">{label}</span>
110
111
  <span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
@@ -113,7 +114,7 @@ export function KeyframeEaseList({
113
114
  height="8"
114
115
  viewBox="0 0 10 10"
115
116
  fill="currentColor"
116
- className={`text-neutral-500 transition-transform ${isExpanded ? "" : "-rotate-90"}`}
117
+ className={`text-neutral-500 transition-transform duration-150 ${isExpanded ? "" : "-rotate-90"}`}
117
118
  >
118
119
  <path d="M2 3l3 4 3-4z" />
119
120
  </svg>