@hyperframes/studio 0.7.37 → 0.7.39

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 (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -3,6 +3,7 @@ import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframe
3
3
  import type { StoryboardFrameView } from "../../hooks/useStoryboard";
4
4
  import { useFileManagerContext } from "../../contexts/FileManagerContext";
5
5
  import { useViewMode } from "../../contexts/ViewModeContext";
6
+ import { Button } from "../ui/Button";
6
7
  import { FramePoster, posterTime } from "./FramePoster";
7
8
  import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
8
9
 
@@ -67,7 +68,25 @@ export function StoryboardFrameFocus({
67
68
  const dirty = draft !== (frame.voiceover ?? "");
68
69
  const canOpenPreview = frame.srcExists && Boolean(frame.src);
69
70
 
70
- // Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
71
+ const saveVoiceover = useCallback(() => {
72
+ return applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
73
+ }, [applyEdit, frame.index, draft]);
74
+
75
+ // Closing the tab with a dirty voiceover would lose it silently — same
76
+ // guard the sibling markdown editor registers for the same class of loss.
77
+ useEffect(() => {
78
+ if (!dirty) return;
79
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
80
+ e.preventDefault();
81
+ };
82
+ window.addEventListener("beforeunload", onBeforeUnload);
83
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
84
+ }, [dirty]);
85
+
86
+ // Leaving the frame drops the in-memory voiceover draft; confirm while it's
87
+ // dirty. An in-flight save does NOT count as safe: if it fails after unmount
88
+ // the error lands on an unmounted component and the draft is silently lost,
89
+ // so keep confirming until the save actually lands (dirty clears on success).
71
90
  const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
72
91
  const handleBack = () => {
73
92
  if (confirmLeave()) onBack();
@@ -158,18 +177,27 @@ export function StoryboardFrameFocus({
158
177
  <h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
159
178
  🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
160
179
  </h3>
161
- <button
162
- type="button"
163
- onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
164
- disabled={!dirty || busy}
165
- className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
180
+ <Button
181
+ size="sm"
182
+ variant="primary"
183
+ onClick={saveVoiceover}
184
+ disabled={!dirty}
185
+ loading={busy}
186
+ className="bg-emerald-600 text-white enabled:hover:bg-emerald-500 shadow-none"
166
187
  >
167
- Save
168
- </button>
188
+ {busy ? "Saving…" : "Save"}
189
+ </Button>
169
190
  </div>
170
191
  <textarea
171
192
  value={draft}
172
193
  onChange={(e) => setDraft(e.target.value)}
194
+ onBlur={() => {
195
+ // Same autosave paradigm as the status row above — mixed save
196
+ // models inside one panel taught users the panel autosaves,
197
+ // then lost their voiceover. Explicit Save stays as the
198
+ // affordance; blur is the safety net.
199
+ if (dirty && !busy) void saveVoiceover();
200
+ }}
173
201
  rows={3}
174
202
  placeholder="What the narrator says over this frame…"
175
203
  className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
@@ -189,14 +217,9 @@ export function StoryboardFrameFocus({
189
217
  </section>
190
218
  )}
191
219
 
192
- <button
193
- type="button"
194
- onClick={openInPreview}
195
- disabled={!canOpenPreview}
196
- className="rounded border border-neutral-700 px-3 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:opacity-40"
197
- >
220
+ <Button size="sm" variant="secondary" onClick={openInPreview} disabled={!canOpenPreview}>
198
221
  Open in Preview →
199
- </button>
222
+ </Button>
200
223
  </div>
201
224
  </div>
202
225
  </div>
@@ -217,7 +240,7 @@ function NavButton({
217
240
  type="button"
218
241
  onClick={onClick}
219
242
  disabled={disabled}
220
- className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
243
+ className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 enabled:active:scale-[0.98] transition-transform disabled:opacity-30"
221
244
  >
222
245
  {label}
223
246
  </button>
@@ -113,16 +113,33 @@ const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
113
113
  ];
114
114
 
115
115
  function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: SubView) => void }) {
116
+ // Complete tabs contract: roving tabIndex + arrow-key navigation (the roles
117
+ // alone promised keyboard behavior the buttons didn't have).
118
+ const handleKeyDown = (e: React.KeyboardEvent) => {
119
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
120
+ e.preventDefault();
121
+ const currentIndex = SUB_VIEWS.findIndex((v) => v.value === value);
122
+ const delta = e.key === "ArrowRight" ? 1 : -1;
123
+ const next = SUB_VIEWS[(currentIndex + delta + SUB_VIEWS.length) % SUB_VIEWS.length];
124
+ if (next) onChange(next.value);
125
+ };
126
+
116
127
  return (
117
- <div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5" role="tablist">
128
+ <div
129
+ className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5"
130
+ role="tablist"
131
+ aria-label="Storyboard view"
132
+ onKeyDown={handleKeyDown}
133
+ >
118
134
  {SUB_VIEWS.map((option) => (
119
135
  <button
120
136
  key={option.value}
121
137
  type="button"
122
138
  role="tab"
123
139
  aria-selected={value === option.value}
140
+ tabIndex={value === option.value ? 0 : -1}
124
141
  onClick={() => onChange(option.value)}
125
- className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
142
+ className={`rounded px-3 py-1 text-xs font-medium transition-colors active:scale-[0.98] outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
126
143
  value === option.value
127
144
  ? "bg-neutral-700 text-neutral-100"
128
145
  : "text-neutral-400 hover:text-neutral-200"
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { useStoryboard } from "../../hooks/useStoryboard";
3
+ import { Button } from "../ui/Button";
3
4
  import { StoryboardLoaded } from "./StoryboardLoaded";
4
5
 
5
6
  export interface StoryboardViewProps {
@@ -22,6 +23,11 @@ export function StoryboardView({ projectId, onSelectComposition }: StoryboardVie
22
23
  return (
23
24
  <StoryboardFrame>
24
25
  <Message tone="error">Couldn’t load the storyboard: {error}</Message>
26
+ <div className="flex justify-center">
27
+ <Button size="sm" variant="secondary" onClick={reload}>
28
+ Retry
29
+ </Button>
30
+ </div>
25
31
  </StoryboardFrame>
26
32
  );
27
33
  }
@@ -0,0 +1,124 @@
1
+ import type { MutableRefObject } from "react";
2
+ import type { EditHistoryKind } from "../utils/editHistory";
3
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
4
+ import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch";
5
+ import { hasRelativeLutSource } from "./studioMediaJobs";
6
+
7
+ export type ColorGradingScope = "source-file" | "project";
8
+ export type ColorGradingScopeResult = { changedFiles: number; changedElements: number };
9
+
10
+ type ProjectFileReader = (path: string) => Promise<string>;
11
+ type ProjectFileWriter = (path: string, content: string) => Promise<void>;
12
+ type ShowToast = (message: string, tone?: "error" | "info") => void;
13
+ type RecordEdit = (entry: {
14
+ label: string;
15
+ kind: EditHistoryKind;
16
+ files: Record<string, { before: string; after: string }>;
17
+ }) => Promise<void>;
18
+
19
+ export const EMPTY_COLOR_GRADING_SCOPE_RESULT: ColorGradingScopeResult = {
20
+ changedFiles: 0,
21
+ changedElements: 0,
22
+ };
23
+
24
+ interface ApplyColorGradingScopeOptions {
25
+ scope: ColorGradingScope;
26
+ value: string | null;
27
+ selectedSourceFile: string;
28
+ fileTree: string[];
29
+ projectId: string;
30
+ domEditSaveTimestampRef: MutableRefObject<number>;
31
+ waitForPendingDomEditSaves: () => Promise<void>;
32
+ readProjectFile: ProjectFileReader;
33
+ writeProjectFile: ProjectFileWriter;
34
+ recordEdit: RecordEdit;
35
+ reloadPreview: () => void;
36
+ showToast: ShowToast;
37
+ }
38
+
39
+ function colorGradingScopePaths(
40
+ scope: ColorGradingScope,
41
+ selectedSourceFile: string,
42
+ fileTree: string[],
43
+ ): string[] {
44
+ return scope === "source-file"
45
+ ? [selectedSourceFile]
46
+ : fileTree.filter((path) => /\.html?$/i.test(path));
47
+ }
48
+
49
+ async function patchColorGradingScopeFiles(
50
+ paths: string[],
51
+ value: string | null,
52
+ readProjectFile: ProjectFileReader,
53
+ ): Promise<{ files: Record<string, string>; changedElements: number }> {
54
+ const snapshots = await Promise.all(
55
+ Array.from(new Set(paths)).map(async (path) => ({
56
+ path,
57
+ before: await readProjectFile(path),
58
+ })),
59
+ );
60
+ const files: Record<string, string> = {};
61
+ let changedElements = 0;
62
+
63
+ for (const { path, before } of snapshots) {
64
+ const result = patchMediaColorGradingInHtml(before, value);
65
+ if (result.html !== before) {
66
+ files[path] = result.html;
67
+ changedElements += result.count;
68
+ }
69
+ }
70
+
71
+ return { files, changedElements };
72
+ }
73
+
74
+ // fallow-ignore-next-line complexity
75
+ export async function applyColorGradingScopeUpdate({
76
+ scope,
77
+ value,
78
+ selectedSourceFile,
79
+ fileTree,
80
+ projectId,
81
+ domEditSaveTimestampRef,
82
+ waitForPendingDomEditSaves,
83
+ readProjectFile,
84
+ writeProjectFile,
85
+ recordEdit,
86
+ reloadPreview,
87
+ showToast,
88
+ }: ApplyColorGradingScopeOptions): Promise<ColorGradingScopeResult> {
89
+ await waitForPendingDomEditSaves();
90
+ if (scope === "project" && hasRelativeLutSource(value)) {
91
+ showToast(
92
+ "Project-wide color grading cannot copy relative LUT paths. Apply to this file or use a URL/data LUT.",
93
+ "error",
94
+ );
95
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
96
+ }
97
+
98
+ const { files, changedElements } = await patchColorGradingScopeFiles(
99
+ colorGradingScopePaths(scope, selectedSourceFile, fileTree),
100
+ value,
101
+ readProjectFile,
102
+ );
103
+ if (Object.keys(files).length === 0) {
104
+ showToast("No color grading changed", "info");
105
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
106
+ }
107
+
108
+ domEditSaveTimestampRef.current = Date.now();
109
+ const changedPaths = await saveProjectFilesWithHistory({
110
+ projectId,
111
+ label: value ? "Apply color grading" : "Clear color grading",
112
+ kind: "manual",
113
+ files,
114
+ readFile: readProjectFile,
115
+ writeFile: writeProjectFile,
116
+ recordEdit,
117
+ });
118
+ reloadPreview();
119
+ showToast(
120
+ `${value ? "Applied" : "Cleared"} color grading on ${changedElements} media item${changedElements === 1 ? "" : "s"}`,
121
+ "info",
122
+ );
123
+ return { changedFiles: changedPaths.length, changedElements };
124
+ }
@@ -0,0 +1,123 @@
1
+ import type {
2
+ BackgroundRemovalProgress,
3
+ BackgroundRemovalResult,
4
+ } from "./editor/propertyPanelTypes";
5
+
6
+ const MEDIA_JOB_RECONNECT_TIMEOUT_MS = 15_000;
7
+ const ABSOLUTE_OR_ROOT_SOURCE_RE = /^(?:[a-z][a-z0-9+.-]*:|\/)/i;
8
+
9
+ function parseSerializedColorGrading(value: string): { lut?: { src?: unknown } } | null {
10
+ try {
11
+ return JSON.parse(value) as { lut?: { src?: unknown } } | null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function readLutSource(value: string | null): string {
18
+ const src = value ? parseSerializedColorGrading(value)?.lut?.src : null;
19
+ return typeof src === "string" ? src.trim() : "";
20
+ }
21
+
22
+ export function hasRelativeLutSource(value: string | null): boolean {
23
+ const src = readLutSource(value);
24
+ return src !== "" && !ABSOLUTE_OR_ROOT_SOURCE_RE.test(src);
25
+ }
26
+
27
+ function parseProgressEvent(event: Event): BackgroundRemovalProgress | Error {
28
+ try {
29
+ return JSON.parse((event as MessageEvent).data) as BackgroundRemovalProgress;
30
+ } catch {
31
+ return new Error("Invalid background-removal progress event");
32
+ }
33
+ }
34
+
35
+ function getCompleteProgressResult(
36
+ progress: BackgroundRemovalProgress,
37
+ ): BackgroundRemovalResult | Error {
38
+ if (!progress.outputPath) return new Error("Background removal finished without an output path");
39
+ return {
40
+ outputPath: progress.outputPath,
41
+ backgroundOutputPath: progress.backgroundOutputPath,
42
+ provider: progress.provider,
43
+ };
44
+ }
45
+
46
+ function getTerminalProgressResult(
47
+ progress: BackgroundRemovalProgress,
48
+ ): BackgroundRemovalResult | Error | null {
49
+ switch (progress.status) {
50
+ case "complete":
51
+ return getCompleteProgressResult(progress);
52
+ case "failed":
53
+ return new Error(progress.error || "Background removal failed");
54
+ default:
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function waitForMediaJob(
60
+ jobId: string,
61
+ onProgress?: (progress: BackgroundRemovalProgress) => void,
62
+ signal?: AbortSignal,
63
+ ): Promise<BackgroundRemovalResult> {
64
+ return new Promise((resolve, reject) => {
65
+ if (signal?.aborted) {
66
+ reject(new DOMException("Background removal was cancelled", "AbortError"));
67
+ return;
68
+ }
69
+ const events = new EventSource(`/api/media-jobs/${encodeURIComponent(jobId)}/progress`);
70
+ let settled = false;
71
+ let reconnectTimer: number | null = null;
72
+
73
+ const clearReconnectTimer = () => {
74
+ if (reconnectTimer === null) return;
75
+ window.clearTimeout(reconnectTimer);
76
+ reconnectTimer = null;
77
+ };
78
+ const finish = (callback: () => void) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ clearReconnectTimer();
82
+ signal?.removeEventListener("abort", handleAbort);
83
+ events.close();
84
+ callback();
85
+ };
86
+ const finishReject = (error: Error) => finish(() => reject(error));
87
+ const finishResolve = (result: BackgroundRemovalResult) => finish(() => resolve(result));
88
+ const handleAbort = () => {
89
+ finishReject(new DOMException("Background removal was cancelled", "AbortError"));
90
+ };
91
+ signal?.addEventListener("abort", handleAbort, { once: true });
92
+
93
+ // fallow-ignore-next-line complexity
94
+ events.addEventListener("progress", (event) => {
95
+ const progress = parseProgressEvent(event);
96
+ if (progress instanceof Error) {
97
+ finishReject(progress);
98
+ return;
99
+ }
100
+ clearReconnectTimer();
101
+ onProgress?.(progress);
102
+ const terminalResult = getTerminalProgressResult(progress);
103
+ if (!terminalResult) return;
104
+ if (terminalResult instanceof Error) {
105
+ finishReject(terminalResult);
106
+ } else {
107
+ finishResolve(terminalResult);
108
+ }
109
+ });
110
+ events.onopen = clearReconnectTimer;
111
+ events.onerror = () => {
112
+ if (events.readyState === EventSource.CLOSED) {
113
+ finishReject(new Error("Lost connection to background-removal job"));
114
+ return;
115
+ }
116
+ if (reconnectTimer === null) {
117
+ reconnectTimer = window.setTimeout(() => {
118
+ finishReject(new Error("Lost connection to background-removal job"));
119
+ }, MEDIA_JOB_RECONNECT_TIMEOUT_MS);
120
+ }
121
+ };
122
+ });
123
+ }
@@ -30,22 +30,24 @@ const variantStyles: Record<ButtonVariant, string> = {
30
30
  primary: [
31
31
  "bg-white text-neutral-950 font-medium",
32
32
  "shadow-btn-primary",
33
- "hover:bg-neutral-200",
34
- "active:scale-[0.97]",
33
+ "enabled:hover:bg-neutral-200",
34
+ "enabled:active:scale-[0.97]",
35
35
  ].join(" "),
36
36
  secondary: [
37
37
  "bg-transparent text-neutral-300 font-medium",
38
38
  "border border-border",
39
- "hover:bg-surface-hover hover:text-white hover:border-border-strong",
40
- "active:scale-[0.98]",
39
+ "enabled:hover:bg-surface-hover enabled:hover:text-white enabled:hover:border-border-strong",
40
+ "enabled:active:scale-[0.98]",
41
+ ].join(" "),
42
+ danger: [
43
+ "bg-accent-red text-white font-medium",
44
+ "enabled:hover:bg-red-600",
45
+ "enabled:active:scale-[0.97]",
41
46
  ].join(" "),
42
- danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(
43
- " ",
44
- ),
45
47
  ghost: [
46
48
  "bg-transparent text-neutral-400",
47
- "hover:bg-surface-hover hover:text-white",
48
- "active:scale-[0.98]",
49
+ "enabled:hover:bg-surface-hover enabled:hover:text-white",
50
+ "enabled:active:scale-[0.98]",
49
51
  ].join(" "),
50
52
  };
51
53
 
@@ -55,6 +57,8 @@ const sizeStyles: Record<ButtonSize, string> = {
55
57
  lg: "h-9 px-4 text-base gap-2 rounded-button",
56
58
  };
57
59
 
60
+ // Imported by the shell/renders PRs later in this stack.
61
+ // fallow-ignore-next-line unused-export
58
62
  export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
59
63
  (
60
64
  {
@@ -76,8 +80,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
76
80
  className={[
77
81
  "inline-flex items-center justify-center",
78
82
  "transition-all duration-press ease-standard",
79
- "disabled:opacity-40 disabled:pointer-events-none",
83
+ // No pointer-events-none: disabled buttons must still receive hover
84
+ // so a wrapping Tooltip can explain WHY they're disabled (A5).
85
+ "disabled:opacity-40 disabled:cursor-not-allowed",
80
86
  "select-none cursor-pointer",
87
+ "outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
81
88
  variantStyles[variant],
82
89
  sizeStyles[size],
83
90
  className,
@@ -126,6 +133,7 @@ const iconSizeStyles: Record<ButtonSize, string> = {
126
133
  lg: "min-w-9 min-h-9 rounded-button", // 36px
127
134
  };
128
135
 
136
+ // fallow-ignore-next-line unused-export
129
137
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
130
138
  ({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
131
139
  return (
@@ -134,8 +142,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
134
142
  className={[
135
143
  "inline-flex items-center justify-center",
136
144
  "transition-all duration-press ease-standard",
137
- "disabled:opacity-40 disabled:pointer-events-none",
145
+ "disabled:opacity-40 disabled:cursor-not-allowed",
138
146
  "select-none cursor-pointer",
147
+ "outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
139
148
  variantStyles[variant],
140
149
  iconSizeStyles[size],
141
150
  className,
@@ -25,7 +25,7 @@ export function HyperframesLoader({
25
25
  const markFrameSize = Math.round(size * 1.16);
26
26
 
27
27
  return (
28
- <div className="hf-loader" draggable={false}>
28
+ <div className="hf-loader" role="status" draggable={false}>
29
29
  <div
30
30
  className="hf-loader-mark-frame"
31
31
  style={{ width: markFrameSize, height: markFrameSize }}
@@ -83,7 +83,13 @@ export function HyperframesLoader({
83
83
  <div className="hf-loader-title">{title}</div>
84
84
  {detail && <div className="hf-loader-detail">{detail}</div>}
85
85
  {boundedProgress !== undefined && (
86
- <div className="hf-loader-progress" aria-hidden="true">
86
+ <div
87
+ className="hf-loader-progress"
88
+ role="progressbar"
89
+ aria-valuemin={0}
90
+ aria-valuemax={100}
91
+ aria-valuenow={Math.round(boundedProgress * 100)}
92
+ >
87
93
  <div
88
94
  className="hf-loader-progress__fill"
89
95
  style={{ transform: `scaleX(${boundedProgress})` }}
@@ -95,6 +101,7 @@ export function HyperframesLoader({
95
101
  );
96
102
  }
97
103
 
104
+ // fallow-ignore-next-line unused-export
98
105
  export function StatusFrame(props: HyperframesLoaderProps) {
99
106
  return (
100
107
  <div className="hf-frame">
@@ -0,0 +1,53 @@
1
+ // fallow-ignore-file unused-file
2
+ // (consumers land in the sidebar/panels PR later in this stack)
3
+ import { type InputHTMLAttributes } from "react";
4
+
5
+ interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
6
+ /** Accessible name — placeholder alone is not one. */
7
+ "aria-label": string;
8
+ }
9
+
10
+ /**
11
+ * Shared search input — one visual system (panel-input tokens) for every
12
+ * panel search box, with a required accessible name.
13
+ */
14
+ export function SearchInput({ className = "", ...props }: SearchInputProps) {
15
+ return (
16
+ <div
17
+ className={`flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] ${className}`}
18
+ >
19
+ <svg
20
+ width="12"
21
+ height="12"
22
+ viewBox="0 0 256 256"
23
+ fill="none"
24
+ className="flex-shrink-0"
25
+ aria-hidden="true"
26
+ >
27
+ <circle
28
+ cx="116"
29
+ cy="116"
30
+ r="76"
31
+ stroke="currentColor"
32
+ strokeWidth="22"
33
+ className="text-panel-text-5"
34
+ />
35
+ <line
36
+ x1="170"
37
+ y1="170"
38
+ x2="232"
39
+ y2="232"
40
+ stroke="currentColor"
41
+ strokeWidth="22"
42
+ strokeLinecap="round"
43
+ className="text-panel-text-5"
44
+ />
45
+ </svg>
46
+ <input
47
+ type="text"
48
+ className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
49
+ {...props}
50
+ />
51
+ </div>
52
+ );
53
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useRef, useCallback, type ReactNode } from "react";
1
+ import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
 
4
4
  interface TooltipProps {
@@ -8,22 +8,46 @@ interface TooltipProps {
8
8
  side?: "top" | "bottom";
9
9
  }
10
10
 
11
+ // Rough bubble height (padding + one text line) used to decide flipping
12
+ // before the bubble has rendered; exact height isn't needed for the guard.
13
+ const APPROX_BUBBLE_H = 28;
14
+ const VIEWPORT_MARGIN = 8;
15
+
11
16
  export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
12
17
  const [visible, setVisible] = useState(false);
13
18
  const [pos, setPos] = useState({ x: 0, y: 0 });
19
+ const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
14
20
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
15
21
  const triggerRef = useRef<HTMLSpanElement>(null);
22
+ // WCAG 4.1.2: programmatically associate the bubble with its trigger.
23
+ const tooltipId = useId();
16
24
 
17
25
  const show = useCallback(() => {
26
+ if (timerRef.current) clearTimeout(timerRef.current);
18
27
  timerRef.current = setTimeout(() => {
19
28
  const el = triggerRef.current;
20
29
  if (!el) return;
21
30
  const child = el.firstElementChild as HTMLElement | null;
22
31
  const rect = (child ?? el).getBoundingClientRect();
23
32
  if (rect.width === 0 && rect.height === 0) return;
33
+ // Flip when the preferred side would clip the viewport edge.
34
+ let nextSide = side;
35
+ if (side === "top" && rect.top - APPROX_BUBBLE_H - 6 < VIEWPORT_MARGIN) {
36
+ nextSide = "bottom";
37
+ } else if (
38
+ side === "bottom" &&
39
+ rect.bottom + APPROX_BUBBLE_H + 6 > window.innerHeight - VIEWPORT_MARGIN
40
+ ) {
41
+ nextSide = "top";
42
+ }
43
+ const x = Math.min(
44
+ Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
45
+ window.innerWidth - VIEWPORT_MARGIN,
46
+ );
47
+ setResolvedSide(nextSide);
24
48
  setPos({
25
- x: rect.left + rect.width / 2,
26
- y: side === "top" ? rect.top - 6 : rect.bottom + 6,
49
+ x,
50
+ y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
27
51
  });
28
52
  setVisible(true);
29
53
  }, delay);
@@ -37,9 +61,27 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
37
61
  setVisible(false);
38
62
  }, []);
39
63
 
64
+ // WCAG 1.4.13: tooltip content must be dismissible with Escape.
65
+ useEffect(() => {
66
+ if (!visible) return;
67
+ const onKeyDown = (e: KeyboardEvent) => {
68
+ if (e.key === "Escape") hide();
69
+ };
70
+ document.addEventListener("keydown", onKeyDown);
71
+ return () => document.removeEventListener("keydown", onKeyDown);
72
+ }, [visible, hide]);
73
+
40
74
  return (
41
75
  <>
42
- <span ref={triggerRef} onPointerEnter={show} onPointerLeave={hide} className="contents">
76
+ <span
77
+ ref={triggerRef}
78
+ onPointerEnter={show}
79
+ onPointerLeave={hide}
80
+ onFocus={show}
81
+ onBlur={hide}
82
+ aria-describedby={visible ? tooltipId : undefined}
83
+ className="contents"
84
+ >
43
85
  {children}
44
86
  </span>
45
87
  {visible &&
@@ -49,10 +91,14 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
49
91
  style={{
50
92
  left: pos.x,
51
93
  top: pos.y,
52
- transform: side === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
94
+ transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
53
95
  }}
54
96
  >
55
- <div className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg">
97
+ <div
98
+ role="tooltip"
99
+ id={tooltipId}
100
+ className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
101
+ >
56
102
  {label}
57
103
  </div>
58
104
  </div>,