ai-design-system 0.1.99 → 0.1.101

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.
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ScenePlayer, type SceneItem } from "./ScenePlayer";
3
+ import { fn } from "@storybook/test";
4
+
5
+ /**
6
+ * ScenePlayer Composite Stories
7
+ *
8
+ * Full-screen segmented video player for scene-by-scene review with progress tracks,
9
+ * mute toggle, step zones, and publish action.
10
+ */
11
+ const meta = {
12
+ title: "Composites/ScenePlayer",
13
+ component: ScenePlayer,
14
+ tags: ["autodocs"],
15
+ parameters: { layout: "fullscreen" },
16
+ args: {
17
+ onBack: fn(),
18
+ onClose: fn(),
19
+ onPublish: fn(),
20
+ onSceneChange: fn(),
21
+ },
22
+ } satisfies Meta<typeof ScenePlayer>;
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ const mockScenes: SceneItem[] = [
28
+ {
29
+ id: "001",
30
+ sceneNumber: 1,
31
+ totalScenes: 3,
32
+ start_sec: 0,
33
+ duration_sec: 1.6,
34
+ templateId: "grid2_compare_visual",
35
+ caption: "iOS apps used to mean one",
36
+ scene_clip_url:
37
+ "https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene001/clip.mp4",
38
+ },
39
+ {
40
+ id: "002",
41
+ sceneNumber: 2,
42
+ totalScenes: 3,
43
+ start_sec: 1.6,
44
+ duration_sec: 1.5,
45
+ templateId: "full_visual_loop",
46
+ caption: "thing you needed a Mac. And nobody",
47
+ scene_clip_url:
48
+ "https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene002/clip.mp4",
49
+ },
50
+ {
51
+ id: "003",
52
+ sceneNumber: 3,
53
+ totalScenes: 3,
54
+ start_sec: 3.1,
55
+ duration_sec: 1.5,
56
+ templateId: "full_talking_head",
57
+ caption: "told the AI, every AI",
58
+ scene_clip_url:
59
+ "https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene003/clip.mp4",
60
+ },
61
+ ];
62
+
63
+ /**
64
+ * Default usage with multiple scenes
65
+ */
66
+ export const Default: Story = {
67
+ args: {
68
+ scenes: mockScenes,
69
+ title: "Devin for iOS",
70
+ avatarInitials: "D",
71
+ },
72
+ };
73
+
74
+ /**
75
+ * Empty scenes fallback
76
+ */
77
+ export const EmptyState: Story = {
78
+ args: {
79
+ scenes: [],
80
+ title: "No Scenes",
81
+ },
82
+ };
@@ -0,0 +1,293 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Button } from "@/components/primitives/Button";
5
+ import { Icon } from "@/components/primitives/Icon";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ export interface SceneItem {
9
+ id: string;
10
+ sceneNumber?: number;
11
+ totalScenes?: number;
12
+ start_sec?: number;
13
+ duration_sec?: number;
14
+ templateId?: string;
15
+ caption?: string;
16
+ scene_clip_url: string;
17
+ scene_audio_clip_url?: string;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ export interface ScenePlayerProps extends React.HTMLAttributes<HTMLDivElement> {
22
+ scenes: SceneItem[];
23
+ title?: string;
24
+ avatarInitials?: string;
25
+ avatarColor?: string;
26
+ initialSceneIndex?: number;
27
+ isMuted?: boolean;
28
+ onBack?: () => void;
29
+ onClose?: () => void;
30
+ onPublish?: (currentScene: SceneItem) => void;
31
+ publishPlaceholder?: string;
32
+ onSceneChange?: (sceneIndex: number, scene: SceneItem) => void;
33
+ }
34
+
35
+ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
36
+ (
37
+ {
38
+ scenes = [],
39
+ title = "Devin for iOS",
40
+ avatarInitials = "D",
41
+ avatarColor = "bg-indigo-600",
42
+ initialSceneIndex = 0,
43
+ isMuted: initialMuted = false,
44
+ onBack,
45
+ onClose,
46
+ onPublish,
47
+ publishPlaceholder = "Ready to publish...",
48
+ onSceneChange,
49
+ className,
50
+ ...props
51
+ },
52
+ ref
53
+ ) => {
54
+ const handleBack = onBack || onClose;
55
+
56
+ const [currentSceneIndex, setCurrentSceneIndex] = React.useState<number>(() => {
57
+ if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
58
+ return initialSceneIndex;
59
+ }
60
+ return 0;
61
+ });
62
+
63
+ const [isMuted, setIsMuted] = React.useState<boolean>(initialMuted);
64
+ const videoRef = React.useRef<HTMLVideoElement | null>(null);
65
+
66
+ const activeScene = scenes[currentSceneIndex] || scenes[0];
67
+
68
+ const handlePrev = React.useCallback(() => {
69
+ if (!scenes.length) return;
70
+ setCurrentSceneIndex((prev) => {
71
+ const nextIdx = prev > 0 ? prev - 1 : scenes.length - 1;
72
+ onSceneChange?.(nextIdx, scenes[nextIdx]);
73
+ return nextIdx;
74
+ });
75
+ }, [scenes, onSceneChange]);
76
+
77
+ const handleNext = React.useCallback(() => {
78
+ if (!scenes.length) return;
79
+ setCurrentSceneIndex((prev) => {
80
+ const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
81
+ onSceneChange?.(nextIdx, scenes[nextIdx]);
82
+ return nextIdx;
83
+ });
84
+ }, [scenes, onSceneChange]);
85
+
86
+ const handleSceneEnded = React.useCallback(() => {
87
+ if (!scenes.length) return;
88
+ setCurrentSceneIndex((prev) => {
89
+ const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
90
+ onSceneChange?.(nextIdx, scenes[nextIdx]);
91
+ return nextIdx;
92
+ });
93
+ }, [scenes, onSceneChange]);
94
+
95
+ // Autoplay & mute handling
96
+ React.useEffect(() => {
97
+ const video = videoRef.current;
98
+ if (!video) return;
99
+
100
+ video.muted = isMuted;
101
+ const playPromise = video.play();
102
+ if (playPromise !== undefined) {
103
+ playPromise.catch(() => {
104
+ if (!isMuted) {
105
+ video.muted = true;
106
+ setIsMuted(true);
107
+ video.play().catch(() => {});
108
+ }
109
+ });
110
+ }
111
+ }, [activeScene?.scene_clip_url, isMuted]);
112
+
113
+ // Keyboard navigation
114
+ React.useEffect(() => {
115
+ const handleKeyDown = (e: KeyboardEvent) => {
116
+ if (e.key === "ArrowLeft") {
117
+ e.preventDefault();
118
+ handlePrev();
119
+ } else if (e.key === "ArrowRight" || e.key === " ") {
120
+ e.preventDefault();
121
+ handleNext();
122
+ } else if (e.key === "Escape") {
123
+ e.preventDefault();
124
+ handleBack?.();
125
+ } else if (e.key === "m" || e.key === "M") {
126
+ e.preventDefault();
127
+ setIsMuted((prev) => !prev);
128
+ }
129
+ };
130
+
131
+ window.addEventListener("keydown", handleKeyDown);
132
+ return () => window.removeEventListener("keydown", handleKeyDown);
133
+ }, [handlePrev, handleNext, handleBack]);
134
+
135
+ if (!scenes || scenes.length === 0) {
136
+ return (
137
+ <div
138
+ ref={ref}
139
+ className={cn("flex flex-col items-center justify-center w-full h-full bg-black text-white p-6", className)}
140
+ data-testid="screen-scenes"
141
+ {...props}
142
+ >
143
+ <p className="text-white/70 text-sm">No scenes available</p>
144
+ {handleBack && (
145
+ <Button
146
+ variant="ghost"
147
+ onClick={handleBack}
148
+ className="mt-4 text-white hover:bg-white/20"
149
+ data-testid="btn-back"
150
+ >
151
+ Back
152
+ </Button>
153
+ )}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ return (
159
+ <div
160
+ ref={ref}
161
+ className={cn(
162
+ "flex flex-col w-full h-full min-h-full bg-black text-white relative select-none overflow-hidden",
163
+ className
164
+ )}
165
+ data-testid="screen-scenes"
166
+ {...props}
167
+ >
168
+ {/* Top Segmented Progress Bar */}
169
+ <div className="flex items-center gap-[3px] px-2 pt-3 pb-1.5 z-20 shrink-0">
170
+ {scenes.map((scene, idx) => {
171
+ const isCompleted = idx < currentSceneIndex;
172
+ const isCurrent = idx === currentSceneIndex;
173
+ return (
174
+ <div
175
+ key={scene.id || `scene-${idx}`}
176
+ className="flex-1 h-[2.5px] rounded-[2px] bg-white/35 overflow-hidden"
177
+ >
178
+ <div
179
+ className={cn(
180
+ "h-full w-full transition-all duration-150",
181
+ isCompleted || isCurrent ? "bg-white" : "bg-transparent"
182
+ )}
183
+ />
184
+ </div>
185
+ );
186
+ })}
187
+ </div>
188
+
189
+ {/* Story Header Overlay */}
190
+ <div className="flex items-center justify-between px-3 py-2.5 z-20 shrink-0">
191
+ <div className="flex items-center gap-2 min-w-0">
192
+ {/* Back Button */}
193
+ {handleBack && (
194
+ <Button
195
+ variant="ghost"
196
+ size="icon"
197
+ data-testid="btn-back"
198
+ aria-label="Back"
199
+ onClick={handleBack}
200
+ className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer shrink-0"
201
+ >
202
+ <Icon name="arrow-left" className="w-5 h-5 text-white" />
203
+ </Button>
204
+ )}
205
+
206
+ <div
207
+ className={cn(
208
+ "w-9 h-9 rounded-full flex items-center justify-center font-bold text-sm text-white shrink-0 ring-1.5 ring-white/80 overflow-hidden",
209
+ avatarColor
210
+ )}
211
+ >
212
+ {avatarInitials}
213
+ </div>
214
+ <div className="flex flex-col min-w-0 justify-center">
215
+ <span className="font-bold text-sm text-white truncate max-w-[200px]">
216
+ {title}
217
+ </span>
218
+ <span className="text-xs text-white/75 truncate">
219
+ Scene {activeScene?.sceneNumber ?? currentSceneIndex + 1} of {scenes.length}
220
+ </span>
221
+ </div>
222
+ </div>
223
+
224
+ <div className="flex items-center gap-2 shrink-0">
225
+ {/* Mute/Unmute Toggle Button */}
226
+ <Button
227
+ variant="ghost"
228
+ size="icon"
229
+ data-testid="btn-toggle-mute"
230
+ aria-label={isMuted ? "Unmute audio" : "Mute audio"}
231
+ onClick={() => setIsMuted((prev) => !prev)}
232
+ className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
233
+ >
234
+ <Icon name={isMuted ? "volume-x" : "volume-2"} className="w-4 h-4" />
235
+ </Button>
236
+ </div>
237
+ </div>
238
+
239
+ {/* Main Scene Video Player Stage */}
240
+ <div className="flex-1 relative flex items-center justify-center bg-black overflow-hidden min-h-0">
241
+ <video
242
+ ref={videoRef}
243
+ key={activeScene?.scene_clip_url}
244
+ src={activeScene?.scene_clip_url}
245
+ autoPlay
246
+ playsInline
247
+ loop={false}
248
+ muted={isMuted}
249
+ onEnded={handleSceneEnded}
250
+ className="w-full h-full object-contain bg-black"
251
+ />
252
+
253
+ {/* Invisible Interactive Tap Zones for Stepping Scenes */}
254
+ <div className="absolute inset-x-0 top-[60px] bottom-[70px] flex z-10">
255
+ <Button
256
+ variant="ghost"
257
+ data-testid="btn-prev-scene"
258
+ aria-label="Previous scene"
259
+ onClick={handlePrev}
260
+ className="flex-1 h-full cursor-pointer bg-transparent hover:bg-transparent p-0 rounded-none border-none focus-visible:ring-0 focus-visible:outline-none"
261
+ />
262
+ <Button
263
+ variant="ghost"
264
+ data-testid="btn-next-scene"
265
+ aria-label="Next scene"
266
+ onClick={handleNext}
267
+ className="flex-1 h-full cursor-pointer bg-transparent hover:bg-transparent p-0 rounded-none border-none focus-visible:ring-0 focus-visible:outline-none"
268
+ />
269
+ </div>
270
+ </div>
271
+
272
+ {/* Bottom Action Bar */}
273
+ <div className="flex items-center gap-3 px-4 py-3.5 bg-black z-20 shrink-0">
274
+ <div className="flex-1 h-11 rounded-full bg-white/15 border border-white/25 px-4 flex items-center text-sm text-white/65 select-none truncate">
275
+ {publishPlaceholder}
276
+ </div>
277
+ <Button
278
+ variant="ghost"
279
+ size="icon"
280
+ data-testid="btn-publish-scene"
281
+ aria-label="Publish scene"
282
+ onClick={() => onPublish?.(activeScene)}
283
+ className="w-11 h-11 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white shrink-0 p-0 shadow-md cursor-pointer transition-transform active:scale-95"
284
+ >
285
+ <Icon name="send" className="w-4 h-4 text-white" />
286
+ </Button>
287
+ </div>
288
+ </div>
289
+ );
290
+ }
291
+ );
292
+
293
+ ScenePlayer.displayName = "ScenePlayer";
@@ -0,0 +1,2 @@
1
+ export { ScenePlayer } from "./ScenePlayer";
2
+ export type { ScenePlayerProps, SceneItem } from "./ScenePlayer";
@@ -285,3 +285,7 @@ export type { WorkspaceEmptyStateProps } from './WorkspaceEmptyState'
285
285
  export { StoriesBar } from './StoriesBar'
286
286
  export type { StoriesBarProps, StoryItem } from './StoriesBar'
287
287
 
288
+ // ScenePlayer Composite
289
+ export { ScenePlayer } from './ScenePlayer'
290
+ export type { ScenePlayerProps, SceneItem } from './ScenePlayer'
291
+
@@ -34,10 +34,13 @@ export {
34
34
  TaskQueue,
35
35
  SpeechInput,
36
36
  StoriesBar,
37
+ ScenePlayer,
37
38
  } from './composites';
38
39
  export type {
39
40
  StoriesBarProps,
40
41
  StoryItem,
42
+ ScenePlayerProps,
43
+ SceneItem,
41
44
  SpeechInputProps,
42
45
  ApprovalCardProps,
43
46
  ActionRequest,