ai-design-system 0.1.98 → 0.1.100

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,81 @@
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
+ onClose: fn(),
18
+ onPublish: fn(),
19
+ onSceneChange: fn(),
20
+ },
21
+ } satisfies Meta<typeof ScenePlayer>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ const mockScenes: SceneItem[] = [
27
+ {
28
+ id: "001",
29
+ sceneNumber: 1,
30
+ totalScenes: 3,
31
+ start_sec: 0,
32
+ duration_sec: 1.6,
33
+ templateId: "grid2_compare_visual",
34
+ caption: "iOS apps used to mean one",
35
+ scene_clip_url:
36
+ "https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene001/clip.mp4",
37
+ },
38
+ {
39
+ id: "002",
40
+ sceneNumber: 2,
41
+ totalScenes: 3,
42
+ start_sec: 1.6,
43
+ duration_sec: 1.5,
44
+ templateId: "full_visual_loop",
45
+ caption: "thing you needed a Mac. And nobody",
46
+ scene_clip_url:
47
+ "https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene002/clip.mp4",
48
+ },
49
+ {
50
+ id: "003",
51
+ sceneNumber: 3,
52
+ totalScenes: 3,
53
+ start_sec: 3.1,
54
+ duration_sec: 1.5,
55
+ templateId: "full_talking_head",
56
+ caption: "told the AI, every AI",
57
+ scene_clip_url:
58
+ "https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene003/clip.mp4",
59
+ },
60
+ ];
61
+
62
+ /**
63
+ * Default usage with multiple scenes
64
+ */
65
+ export const Default: Story = {
66
+ args: {
67
+ scenes: mockScenes,
68
+ title: "Devin for iOS",
69
+ avatarInitials: "D",
70
+ },
71
+ };
72
+
73
+ /**
74
+ * Empty scenes fallback
75
+ */
76
+ export const EmptyState: Story = {
77
+ args: {
78
+ scenes: [],
79
+ title: "No Scenes",
80
+ },
81
+ };
@@ -0,0 +1,289 @@
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
+ onClose?: () => void;
29
+ onPublish?: (currentScene: SceneItem) => void;
30
+ publishPlaceholder?: string;
31
+ onSceneChange?: (sceneIndex: number, scene: SceneItem) => void;
32
+ }
33
+
34
+ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
35
+ (
36
+ {
37
+ scenes = [],
38
+ title = "Devin for iOS",
39
+ avatarInitials = "D",
40
+ avatarColor = "bg-indigo-600",
41
+ initialSceneIndex = 0,
42
+ isMuted: initialMuted = false,
43
+ onClose,
44
+ onPublish,
45
+ publishPlaceholder = "Ready to publish...",
46
+ onSceneChange,
47
+ className,
48
+ ...props
49
+ },
50
+ ref
51
+ ) => {
52
+ const [currentSceneIndex, setCurrentSceneIndex] = React.useState<number>(() => {
53
+ if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
54
+ return initialSceneIndex;
55
+ }
56
+ return 0;
57
+ });
58
+
59
+ const [isMuted, setIsMuted] = React.useState<boolean>(initialMuted);
60
+ const videoRef = React.useRef<HTMLVideoElement | null>(null);
61
+
62
+ const activeScene = scenes[currentSceneIndex] || scenes[0];
63
+
64
+ const handlePrev = React.useCallback(() => {
65
+ if (!scenes.length) return;
66
+ setCurrentSceneIndex((prev) => {
67
+ const nextIdx = prev > 0 ? prev - 1 : scenes.length - 1;
68
+ onSceneChange?.(nextIdx, scenes[nextIdx]);
69
+ return nextIdx;
70
+ });
71
+ }, [scenes, onSceneChange]);
72
+
73
+ const handleNext = React.useCallback(() => {
74
+ if (!scenes.length) return;
75
+ setCurrentSceneIndex((prev) => {
76
+ const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
77
+ onSceneChange?.(nextIdx, scenes[nextIdx]);
78
+ return nextIdx;
79
+ });
80
+ }, [scenes, onSceneChange]);
81
+
82
+ const handleSceneEnded = React.useCallback(() => {
83
+ if (!scenes.length) return;
84
+ setCurrentSceneIndex((prev) => {
85
+ const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
86
+ onSceneChange?.(nextIdx, scenes[nextIdx]);
87
+ return nextIdx;
88
+ });
89
+ }, [scenes, onSceneChange]);
90
+
91
+ // Autoplay & mute handling
92
+ React.useEffect(() => {
93
+ const video = videoRef.current;
94
+ if (!video) return;
95
+
96
+ video.muted = isMuted;
97
+ const playPromise = video.play();
98
+ if (playPromise !== undefined) {
99
+ playPromise.catch(() => {
100
+ if (!isMuted) {
101
+ video.muted = true;
102
+ setIsMuted(true);
103
+ video.play().catch(() => {});
104
+ }
105
+ });
106
+ }
107
+ }, [activeScene?.scene_clip_url, isMuted]);
108
+
109
+ // Keyboard navigation
110
+ React.useEffect(() => {
111
+ const handleKeyDown = (e: KeyboardEvent) => {
112
+ if (e.key === "ArrowLeft") {
113
+ e.preventDefault();
114
+ handlePrev();
115
+ } else if (e.key === "ArrowRight" || e.key === " ") {
116
+ e.preventDefault();
117
+ handleNext();
118
+ } else if (e.key === "Escape") {
119
+ e.preventDefault();
120
+ onClose?.();
121
+ } else if (e.key === "m" || e.key === "M") {
122
+ e.preventDefault();
123
+ setIsMuted((prev) => !prev);
124
+ }
125
+ };
126
+
127
+ window.addEventListener("keydown", handleKeyDown);
128
+ return () => window.removeEventListener("keydown", handleKeyDown);
129
+ }, [handlePrev, handleNext, onClose]);
130
+
131
+ if (!scenes || scenes.length === 0) {
132
+ return (
133
+ <div
134
+ ref={ref}
135
+ className={cn("flex flex-col items-center justify-center w-full h-full bg-black text-white p-6", className)}
136
+ data-testid="screen-scenes"
137
+ {...props}
138
+ >
139
+ <p className="text-white/70 text-sm">No scenes available</p>
140
+ {onClose && (
141
+ <Button
142
+ variant="ghost"
143
+ onClick={onClose}
144
+ className="mt-4 text-white hover:bg-white/20"
145
+ data-testid="btn-close"
146
+ >
147
+ Close
148
+ </Button>
149
+ )}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ return (
155
+ <div
156
+ ref={ref}
157
+ className={cn(
158
+ "flex flex-col w-full h-full min-h-full bg-black text-white relative select-none overflow-hidden",
159
+ className
160
+ )}
161
+ data-testid="screen-scenes"
162
+ {...props}
163
+ >
164
+ {/* Top Segmented Progress Bar */}
165
+ <div className="flex items-center gap-[3px] px-2 pt-3 pb-1.5 z-20 shrink-0">
166
+ {scenes.map((scene, idx) => {
167
+ const isCompleted = idx < currentSceneIndex;
168
+ const isCurrent = idx === currentSceneIndex;
169
+ return (
170
+ <div
171
+ key={scene.id || `scene-${idx}`}
172
+ className="flex-1 h-[2.5px] rounded-[2px] bg-white/35 overflow-hidden"
173
+ >
174
+ <div
175
+ className={cn(
176
+ "h-full w-full transition-all duration-150",
177
+ isCompleted || isCurrent ? "bg-white" : "bg-transparent"
178
+ )}
179
+ />
180
+ </div>
181
+ );
182
+ })}
183
+ </div>
184
+
185
+ {/* Story Header Overlay */}
186
+ <div className="flex items-center justify-between px-4 py-2.5 z-20 shrink-0">
187
+ <div className="flex items-center gap-2.5 min-w-0">
188
+ <div
189
+ className={cn(
190
+ "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",
191
+ avatarColor
192
+ )}
193
+ >
194
+ {avatarInitials}
195
+ </div>
196
+ <div className="flex flex-col min-w-0 justify-center">
197
+ <span className="font-bold text-sm text-white truncate max-w-[200px]">
198
+ {title}
199
+ </span>
200
+ <span className="text-xs text-white/75 truncate">
201
+ Scene {activeScene?.sceneNumber ?? currentSceneIndex + 1} of {scenes.length}
202
+ </span>
203
+ </div>
204
+ </div>
205
+
206
+ <div className="flex items-center gap-2 shrink-0">
207
+ {/* Mute/Unmute Toggle Button */}
208
+ <Button
209
+ variant="ghost"
210
+ size="icon"
211
+ data-testid="btn-toggle-mute"
212
+ aria-label={isMuted ? "Unmute audio" : "Mute audio"}
213
+ onClick={() => setIsMuted((prev) => !prev)}
214
+ className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
215
+ >
216
+ <Icon name={isMuted ? "volume-x" : "volume-2"} className="w-4 h-4" />
217
+ </Button>
218
+
219
+ {/* Close Button */}
220
+ {onClose && (
221
+ <Button
222
+ variant="ghost"
223
+ size="icon"
224
+ data-testid="btn-close"
225
+ aria-label="Close scenes view"
226
+ onClick={onClose}
227
+ className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
228
+ >
229
+ <Icon name="x" className="w-4 h-4" />
230
+ </Button>
231
+ )}
232
+ </div>
233
+ </div>
234
+
235
+ {/* Main Scene Video Player Stage */}
236
+ <div className="flex-1 relative flex items-center justify-center bg-black overflow-hidden min-h-0">
237
+ <video
238
+ ref={videoRef}
239
+ key={activeScene?.scene_clip_url}
240
+ src={activeScene?.scene_clip_url}
241
+ autoPlay
242
+ playsInline
243
+ loop={false}
244
+ muted={isMuted}
245
+ onEnded={handleSceneEnded}
246
+ className="w-full h-full object-contain bg-black"
247
+ />
248
+
249
+ {/* Invisible Interactive Tap Zones for Stepping Scenes */}
250
+ <div className="absolute inset-x-0 top-[60px] bottom-[70px] flex z-10">
251
+ <Button
252
+ variant="ghost"
253
+ data-testid="btn-prev-scene"
254
+ aria-label="Previous scene"
255
+ onClick={handlePrev}
256
+ 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"
257
+ />
258
+ <Button
259
+ variant="ghost"
260
+ data-testid="btn-next-scene"
261
+ aria-label="Next scene"
262
+ onClick={handleNext}
263
+ 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"
264
+ />
265
+ </div>
266
+ </div>
267
+
268
+ {/* Bottom Action Bar */}
269
+ <div className="flex items-center gap-3 px-4 py-3.5 bg-black z-20 shrink-0">
270
+ <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">
271
+ {publishPlaceholder}
272
+ </div>
273
+ <Button
274
+ variant="ghost"
275
+ size="icon"
276
+ data-testid="btn-publish-scene"
277
+ aria-label="Publish scene"
278
+ onClick={() => onPublish?.(activeScene)}
279
+ 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"
280
+ >
281
+ <Icon name="send" className="w-4 h-4 text-white" />
282
+ </Button>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+ );
288
+
289
+ ScenePlayer.displayName = "ScenePlayer";
@@ -0,0 +1,2 @@
1
+ export { ScenePlayer } from "./ScenePlayer";
2
+ export type { ScenePlayerProps, SceneItem } from "./ScenePlayer";
@@ -49,6 +49,8 @@ function getStoryStatusStyle(status?: StoryItem["status"]) {
49
49
 
50
50
  export const StoriesBar = React.forwardRef<HTMLDivElement, StoriesBarProps>(
51
51
  ({ stories, activeStoryId, onStoryClick, defaultIcon = "clapperboard", className, ...props }, ref) => {
52
+ const hasActiveStory = Boolean(activeStoryId);
53
+
52
54
  return (
53
55
  <div
54
56
  ref={ref}
@@ -68,16 +70,19 @@ export const StoriesBar = React.forwardRef<HTMLDivElement, StoriesBarProps>(
68
70
  key={story.id}
69
71
  variant="ghost"
70
72
  onClick={() => onStoryClick(story)}
71
- className="flex flex-col items-center gap-1 h-auto p-0 hover:bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg cursor-pointer group shrink-0"
73
+ className={cn(
74
+ "flex flex-col items-center gap-1 h-auto p-0 hover:bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg cursor-pointer group shrink-0 transition-all duration-200",
75
+ hasActiveStory && (isActive ? "opacity-100 scale-105" : "opacity-35 hover:opacity-60")
76
+ )}
72
77
  data-testid={`btn-story-${story.id}`}
73
78
  aria-label={`Open story: ${story.title}`}
74
79
  >
75
80
  <div className="relative w-9 h-9 shrink-0 aspect-square rounded-full transition-transform group-hover:scale-105">
76
81
  <div
77
82
  className={cn(
78
- "w-full h-full rounded-full flex items-center justify-center border transition-colors overflow-hidden select-none",
83
+ "w-full h-full rounded-full flex items-center justify-center border transition-all overflow-hidden select-none",
79
84
  isActive
80
- ? "border-primary bg-muted/60"
85
+ ? "border-primary bg-muted/60 ring-2 ring-primary/60 shadow-lg shadow-primary/40"
81
86
  : "border-border/50 bg-muted/40"
82
87
  )}
83
88
  >
@@ -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
+
@@ -411,4 +411,26 @@ export const WithImageAndAudioAttachments: Story = {
411
411
  },
412
412
  };
413
413
 
414
+ /**
415
+ * Sheet Presentation - Floating bottom-sheet modal overlay
416
+ */
417
+ export const SheetPresentation: Story = {
418
+ args: {
419
+ variant: "sheet",
420
+ messages: inputStateMessages,
421
+ sessionTitle: "Video Generation Session",
422
+ placeholder: "Ask a question or describe a task...",
423
+ onSubmit: (msg: PromptInputMessage) => console.log("Submitted:", msg),
424
+ onClose: () => alert("Close sheet triggered"),
425
+ },
426
+ parameters: {
427
+ docs: {
428
+ description: {
429
+ story:
430
+ "Floating bottom-sheet panel presentation with frosted backdrop blur overlay, elevated surface, and grab handle.",
431
+ },
432
+ },
433
+ },
434
+ };
435
+
414
436
 
@@ -213,6 +213,18 @@ export interface ChatPanelProps {
213
213
  * @default true
214
214
  */
215
215
  showSessionActions?: boolean;
216
+ /**
217
+ * Presentation variant for ChatPanel:
218
+ * - "default": standard full flex-1 panel (default)
219
+ * - "sheet": elevated floating bottom-sheet panel with frosted backdrop blur overlay,
220
+ * rounded surface, shadow, grab handle, and dismiss backdrop click.
221
+ * @default "default"
222
+ */
223
+ variant?: "default" | "sheet";
224
+ /**
225
+ * Callback fired when clicking the backdrop overlay or dismissing the sheet (when variant="sheet")
226
+ */
227
+ onClose?: () => void;
216
228
  }
217
229
 
218
230
  /**
@@ -260,6 +272,8 @@ export const ChatPanel = React.memo<ChatPanelProps>(
260
272
  showHeader = true,
261
273
  sessionTitle,
262
274
  showSessionActions = true,
275
+ variant = "default",
276
+ onClose,
263
277
  }) => {
264
278
  // File change queue state
265
279
  const [fileChangeState, setFileChangeState] = React.useState<
@@ -411,8 +425,8 @@ export const ChatPanel = React.memo<ChatPanelProps>(
411
425
  );
412
426
  }, [loading, messages]);
413
427
 
414
- return (
415
- <div className={`relative flex h-full flex-col ${className || ""}`}>
428
+ const chatContent = (
429
+ <div className={cn("relative flex h-full flex-col", variant !== "sheet" && className)}>
416
430
  {/* Chat Session Header */}
417
431
  {showHeader && (
418
432
  <SessionHeader
@@ -470,6 +484,40 @@ export const ChatPanel = React.memo<ChatPanelProps>(
470
484
  </div>
471
485
  </div>
472
486
  );
487
+
488
+ if (variant === "sheet") {
489
+ return (
490
+ <div
491
+ className="absolute inset-0 z-50 flex flex-col justify-end backdrop-blur-md bg-black/60"
492
+ data-testid="story-chat-head-overlay"
493
+ >
494
+ <div
495
+ className="absolute inset-0 cursor-pointer"
496
+ onClick={onClose}
497
+ data-testid="story-chat-head-backdrop"
498
+ aria-label="Close sheet"
499
+ />
500
+ <div
501
+ className={cn(
502
+ "relative z-10 flex flex-col bg-card rounded-2xl border border-border shadow-2xl h-[85%] mx-4 mb-4 overflow-hidden",
503
+ className
504
+ )}
505
+ data-testid="story-chat-head-popup"
506
+ >
507
+ <div
508
+ className="w-9 h-1 rounded-full bg-muted-foreground/30 mx-auto mt-2.5 mb-1 shrink-0"
509
+ data-testid="story-sheet-handle"
510
+ aria-hidden="true"
511
+ />
512
+ <div className="flex-1 flex flex-col min-h-0">
513
+ {chatContent}
514
+ </div>
515
+ </div>
516
+ </div>
517
+ );
518
+ }
519
+
520
+ return chatContent;
473
521
  }
474
522
  );
475
523
 
@@ -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,