@copilotkit/react-core 1.56.3 → 1.56.4-canary.1777529757

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.56.3",
3
+ "version": "1.56.4-canary.1777529757",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -52,8 +52,8 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@ag-ui/client": "0.0.52",
56
- "@ag-ui/core": "0.0.52",
55
+ "@ag-ui/client": "0.0.53",
56
+ "@ag-ui/core": "0.0.53",
57
57
  "@jetbrains/websandbox": "^1.1.3",
58
58
  "@lit-labs/react": "^2.0.2",
59
59
  "@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -73,11 +73,11 @@
73
73
  "untruncate-json": "^0.0.1",
74
74
  "use-stick-to-bottom": "^1.1.1",
75
75
  "zod-to-json-schema": "^3.24.5",
76
- "@copilotkit/core": "1.56.3",
77
- "@copilotkit/a2ui-renderer": "1.56.3",
78
- "@copilotkit/runtime-client-gql": "1.56.3",
79
- "@copilotkit/web-inspector": "1.56.3",
80
- "@copilotkit/shared": "1.56.3"
76
+ "@copilotkit/a2ui-renderer": "1.56.4-canary.1777529757",
77
+ "@copilotkit/shared": "1.56.4-canary.1777529757",
78
+ "@copilotkit/web-inspector": "1.56.4-canary.1777529757",
79
+ "@copilotkit/runtime-client-gql": "1.56.4-canary.1777529757",
80
+ "@copilotkit/core": "1.56.4-canary.1777529757"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@tailwindcss/cli": "^4.1.11",
@@ -1,13 +1,13 @@
1
- import React, { useCallback, useEffect, useId, useRef, useState } from "react";
2
- import { createPortal, flushSync } from "react-dom";
1
+ import React, { useEffect, useState } from "react";
3
2
  import type { Attachment } from "@copilotkit/shared";
4
3
  import {
5
4
  formatFileSize,
6
5
  getSourceUrl,
7
6
  getDocumentIcon,
8
7
  } from "@copilotkit/shared";
9
- import { Play, X } from "lucide-react";
8
+ import { Play } from "lucide-react";
10
9
  import { cn } from "../../lib/utils";
10
+ import { Lightbox, useLightbox } from "./Lightbox";
11
11
 
12
12
  interface CopilotChatAttachmentQueueProps {
13
13
  attachments: Attachment[];
@@ -21,7 +21,10 @@ export const CopilotChatAttachmentQueue: React.FC<
21
21
  if (attachments.length === 0) return null;
22
22
 
23
23
  return (
24
- <div className={cn("cpk:flex cpk:flex-wrap cpk:gap-2 cpk:p-2", className)}>
24
+ <div
25
+ data-testid="copilot-attachment-queue"
26
+ className={cn("cpk:flex cpk:flex-wrap cpk:gap-2 cpk:p-2", className)}
27
+ >
25
28
  {attachments.map((attachment) => {
26
29
  const isMedia =
27
30
  attachment.type === "image" || attachment.type === "video";
@@ -85,116 +88,6 @@ function AttachmentPreview({ attachment }: { attachment: Attachment }) {
85
88
  }
86
89
  }
87
90
 
88
- // ---------------------------------------------------------------------------
89
- // Lightbox – fullscreen overlay for images and videos (portal to body)
90
- // Uses the View Transition API when available for a smooth thumbnail-to-
91
- // fullscreen morph; falls back to a simple opacity fade.
92
- // ---------------------------------------------------------------------------
93
-
94
- interface LightboxProps {
95
- onClose: () => void;
96
- children: React.ReactNode;
97
- }
98
-
99
- function Lightbox({ onClose, children }: LightboxProps) {
100
- useEffect(() => {
101
- const handleKey = (e: KeyboardEvent) => {
102
- if (e.key === "Escape") onClose();
103
- };
104
- document.addEventListener("keydown", handleKey);
105
- return () => document.removeEventListener("keydown", handleKey);
106
- }, [onClose]);
107
-
108
- if (typeof document === "undefined") return null;
109
-
110
- return createPortal(
111
- <div
112
- className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
113
- onClick={onClose}
114
- >
115
- <button
116
- onClick={onClose}
117
- className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
118
- aria-label="Close preview"
119
- >
120
- <X className="cpk:w-5 cpk:h-5" />
121
- </button>
122
-
123
- <div onClick={(e) => e.stopPropagation()}>{children}</div>
124
- </div>,
125
- document.body,
126
- );
127
- }
128
-
129
- type DocWithVT = Document & {
130
- startViewTransition?: (cb: () => void) => { finished: Promise<void> };
131
- };
132
-
133
- /**
134
- * Hook that manages lightbox open/close and uses the View Transition API to
135
- * morph the thumbnail into fullscreen content.
136
- *
137
- * The trick: `view-transition-name` must live on exactly ONE element at a time.
138
- * - Old state (thumbnail visible): name is on the thumbnail.
139
- * - New state (lightbox visible): name moves to the lightbox content.
140
- * `flushSync` ensures React commits the DOM change synchronously inside the
141
- * `startViewTransition` callback so the API can snapshot old → new correctly.
142
- */
143
- function useLightbox() {
144
- const thumbnailRef = useRef<HTMLElement>(null);
145
- const [open, setOpen] = useState(false);
146
- const vtName = useId();
147
-
148
- const openLightbox = useCallback(() => {
149
- const thumb = thumbnailRef.current;
150
- const doc = document as DocWithVT;
151
-
152
- if (doc.startViewTransition && thumb) {
153
- // Old snapshot: name on the thumbnail
154
- thumb.style.viewTransitionName = vtName;
155
-
156
- doc.startViewTransition(() => {
157
- // New snapshot: remove from thumb (lightbox content will have it)
158
- thumb.style.viewTransitionName = "";
159
- flushSync(() => setOpen(true));
160
- });
161
- } else {
162
- setOpen(true);
163
- }
164
- }, []);
165
-
166
- const closeLightbox = useCallback(() => {
167
- const thumb = thumbnailRef.current;
168
- const doc = document as DocWithVT;
169
-
170
- if (doc.startViewTransition && thumb) {
171
- const transition = doc.startViewTransition(() => {
172
- // New snapshot: name back on thumbnail
173
- flushSync(() => setOpen(false));
174
- thumb.style.viewTransitionName = vtName;
175
- });
176
- // Clean up the name after animation finishes (or fails)
177
- transition.finished
178
- .then(() => {
179
- thumb.style.viewTransitionName = "";
180
- })
181
- .catch(() => {
182
- thumb.style.viewTransitionName = "";
183
- });
184
- } else {
185
- setOpen(false);
186
- }
187
- }, []);
188
-
189
- return {
190
- thumbnailRef,
191
- vtName,
192
- open,
193
- openLightbox,
194
- closeLightbox,
195
- };
196
- }
197
-
198
91
  // ---------------------------------------------------------------------------
199
92
  // Image
200
93
  // ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@ import React, { memo, useState } from "react";
2
2
  import type { InputContentSource } from "@copilotkit/shared";
3
3
  import { getSourceUrl, getDocumentIcon } from "@copilotkit/shared";
4
4
  import { cn } from "../../lib/utils";
5
+ import { Lightbox, useLightbox } from "./Lightbox";
5
6
 
6
7
  interface CopilotChatAttachmentRendererProps {
7
8
  type: "image" | "audio" | "video" | "document";
@@ -18,6 +19,8 @@ const ImageAttachment = memo(function ImageAttachment({
18
19
  className?: string;
19
20
  }) {
20
21
  const [error, setError] = useState(false);
22
+ const { thumbnailRef, vtName, open, openLightbox, closeLightbox } =
23
+ useLightbox();
21
24
 
22
25
  if (error) {
23
26
  return (
@@ -33,12 +36,29 @@ const ImageAttachment = memo(function ImageAttachment({
33
36
  }
34
37
 
35
38
  return (
36
- <img
37
- src={src}
38
- alt="Image attachment"
39
- className={cn("cpk:max-w-full cpk:h-auto cpk:rounded-lg", className)}
40
- onError={() => setError(true)}
41
- />
39
+ <>
40
+ <img
41
+ ref={thumbnailRef as React.Ref<HTMLImageElement>}
42
+ src={src}
43
+ alt="Image attachment"
44
+ className={cn(
45
+ "cpk:max-w-[80px] cpk:max-h-[80px] cpk:w-auto cpk:h-auto cpk:rounded-xl cpk:object-cover cpk:cursor-pointer cpk:bg-muted",
46
+ className,
47
+ )}
48
+ onClick={openLightbox}
49
+ onError={() => setError(true)}
50
+ />
51
+ {open && (
52
+ <Lightbox onClose={closeLightbox}>
53
+ <img
54
+ style={{ viewTransitionName: vtName }}
55
+ src={src}
56
+ alt="Image attachment"
57
+ className="cpk:max-w-[90vw] cpk:max-h-[90vh] cpk:object-contain cpk:rounded-lg"
58
+ />
59
+ </Lightbox>
60
+ )}
61
+ </>
42
62
  );
43
63
  });
44
64
 
@@ -217,9 +217,8 @@ export function CopilotChatUserMessage({
217
217
  data-message-id={message.id}
218
218
  {...props}
219
219
  >
220
- {BoundMessageRenderer}
221
220
  {mediaParts.length > 0 && (
222
- <div className="cpk:flex cpk:flex-col cpk:items-end cpk:gap-2 cpk:mt-2">
221
+ <div className="cpk:flex cpk:flex-row cpk:flex-wrap cpk:justify-end cpk:gap-2 cpk:mb-2">
223
222
  {mediaParts.map((part, index) => (
224
223
  <CopilotChatAttachmentRenderer
225
224
  key={index}
@@ -230,6 +229,7 @@ export function CopilotChatUserMessage({
230
229
  ))}
231
230
  </div>
232
231
  )}
232
+ {BoundMessageRenderer}
233
233
  {BoundToolbar}
234
234
  </div>
235
235
  );
@@ -6,17 +6,19 @@ import React, {
6
6
  useLayoutEffect,
7
7
  } from "react";
8
8
  import { ScrollElementContext } from "./scroll-element-context";
9
- import { WithSlots, SlotValue, renderSlot } from "../../lib/slots";
9
+ import type { WithSlots, SlotValue } from "../../lib/slots";
10
+ import { renderSlot } from "../../lib/slots";
10
11
  import CopilotChatMessageView from "./CopilotChatMessageView";
11
- import CopilotChatInput, {
12
+ import type {
12
13
  CopilotChatInputProps,
13
14
  CopilotChatInputMode,
14
15
  } from "./CopilotChatInput";
16
+ import CopilotChatInput from "./CopilotChatInput";
15
17
  import CopilotChatSuggestionView, {
16
18
  CopilotChatSuggestionViewProps,
17
19
  } from "./CopilotChatSuggestionView";
18
- import { Suggestion } from "@copilotkit/core";
19
- import { Message } from "@ag-ui/core";
20
+ import type { Suggestion } from "@copilotkit/core";
21
+ import type { Message } from "@ag-ui/core";
20
22
  import type { Attachment } from "@copilotkit/shared";
21
23
  import { CopilotChatAttachmentQueue } from "./CopilotChatAttachmentQueue";
22
24
  import { twMerge } from "tailwind-merge";
@@ -37,29 +39,8 @@ import { normalizeAutoScroll } from "./normalize-auto-scroll";
37
39
  import type { AutoScrollMode } from "./normalize-auto-scroll";
38
40
  import { usePinToSend } from "../../hooks/use-pin-to-send";
39
41
 
40
- // Height of the feather gradient overlay (h-24 = 6rem = 96px)
41
- const FEATHER_HEIGHT = 96;
42
-
43
- // Pin-to-send uses a softer, shorter feather than pin-to-bottom so readable
44
- // content isn't obscured (h-12 = 3rem = 48px).
45
- const PIN_TO_SEND_FEATHER_HEIGHT = 48;
46
-
47
- const PinToSendSoftFeather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
48
- className,
49
- style,
50
- ...props
51
- }) => (
52
- <div
53
- className={cn(
54
- "cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-12 cpk:pointer-events-none cpk:z-10 cpk:bg-gradient-to-t",
55
- "cpk:from-white cpk:to-transparent",
56
- "cpk:dark:from-[rgb(33,33,33)]",
57
- className,
58
- )}
59
- style={style}
60
- {...props}
61
- />
62
- );
42
+ // Vertical gap between the scroll-to-bottom button and the input container.
43
+ const SCROLL_BUTTON_OFFSET = 16;
63
44
 
64
45
  // Forward declaration for WelcomeScreen component type
65
46
  export type WelcomeScreenProps = WithSlots<
@@ -186,7 +167,17 @@ export function CopilotChatView({
186
167
  className,
187
168
  ...props
188
169
  }: CopilotChatViewProps) {
189
- const inputContainerRef = useRef<HTMLDivElement>(null);
170
+ // Element-as-state via callback ref. The overlay wrapper only renders on the
171
+ // chat-view branch (the welcome-screen branch omits it), so a plain
172
+ // useRef + `[]` useEffect would observe `null` on mount whenever the chat
173
+ // starts on the welcome screen and never re-attach after the user sends
174
+ // their first message — leaving inputContainerHeight at 0 and the scroll
175
+ // content's reserved bottom padding at 32px instead of ~input height. The
176
+ // result is the last messages scrolling underneath the absolute-positioned
177
+ // input pill. Subscribing to element state lets the observer attach (and
178
+ // detach) reactively as the overlay mounts/unmounts.
179
+ const [inputContainerEl, setInputContainerEl] =
180
+ useState<HTMLDivElement | null>(null);
190
181
  const [inputContainerHeight, setInputContainerHeight] = useState(0);
191
182
  const [isResizing, setIsResizing] = useState(false);
192
183
  const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -197,8 +188,14 @@ export function CopilotChatView({
197
188
 
198
189
  // Track input container height changes
199
190
  useEffect(() => {
200
- const element = inputContainerRef.current;
201
- if (!element) return;
191
+ const element = inputContainerEl;
192
+ if (!element) {
193
+ // Reset measured height so the scroll content's paddingBottom doesn't
194
+ // hold a stale value if the overlay unmounts (e.g. messages cleared
195
+ // and the welcome screen returns).
196
+ setInputContainerHeight(0);
197
+ return;
198
+ }
202
199
 
203
200
  const resizeObserver = new ResizeObserver((entries) => {
204
201
  for (const entry of entries) {
@@ -237,7 +234,7 @@ export function CopilotChatView({
237
234
  clearTimeout(resizeTimeoutRef.current);
238
235
  }
239
236
  };
240
- }, []);
237
+ }, [inputContainerEl]);
241
238
 
242
239
  const BoundMessageView = renderSlot(messageView, CopilotChatMessageView, {
243
240
  messages,
@@ -258,24 +255,23 @@ export function CopilotChatView({
258
255
  onAddFile,
259
256
  positioning: "static",
260
257
  keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
261
- containerRef: inputContainerRef,
262
258
  showDisclaimer: true,
263
- // This input is the last flex child of the chat column, so it sits at
264
- // the bottom where the license banner would overlap. The welcome-screen
265
- // input (below) intentionally omits this flag.
259
+ // The parent overlay wrapper handles absolute bottom-0 positioning.
260
+ // `bottomAnchored` still triggers the license-banner offset padding
261
+ // inside CopilotChatInput. The welcome-screen input (below) intentionally
262
+ // omits this flag.
266
263
  bottomAnchored: true,
267
264
  ...(disclaimer !== undefined ? { disclaimer } : {}),
268
265
  } as CopilotChatInputProps);
269
266
 
270
- // Hide suggestions while a thread is connecting or a run is in flight.
271
- // Otherwise, mid-replay (bootstrap stream from /connect) or mid-run, the
272
- // suggestions would render against a still-assembling message tree and
273
- // visibly jump as each final text chunk reflows the layout.
267
+ // Hide suggestions while a thread is connecting (mid-replay would render
268
+ // against a still-assembling message tree and visibly jump as each final
269
+ // text chunk reflows the layout). Run-in-flight is handled by the
270
+ // SuggestionEngine: at run start, non-"always" suggestions are cleared and
271
+ // only "always" ones are restored — so a non-empty `suggestions` array
272
+ // here already means "this is something the user opted to keep visible."
274
273
  const hasSuggestions =
275
- !isConnecting &&
276
- !isRunning &&
277
- Array.isArray(suggestions) &&
278
- suggestions.length > 0;
274
+ !isConnecting && Array.isArray(suggestions) && suggestions.length > 0;
279
275
  const BoundSuggestionView = hasSuggestions
280
276
  ? renderSlot(suggestionView, CopilotChatSuggestionView, {
281
277
  suggestions,
@@ -291,8 +287,9 @@ export function CopilotChatView({
291
287
  isResizing,
292
288
  children: (
293
289
  <div
290
+ data-testid="copilot-scroll-content"
294
291
  style={{
295
- paddingBottom: `${hasSuggestions ? 4 : 32}px`,
292
+ paddingBottom: `${inputContainerHeight + (hasSuggestions ? 4 : 32)}px`,
296
293
  }}
297
294
  >
298
295
  <div className="cpk:max-w-3xl cpk:mx-auto">
@@ -415,17 +412,22 @@ export function CopilotChatView({
415
412
  {dragOver && <DropOverlay />}
416
413
  {BoundScrollView}
417
414
 
418
- <div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full">
415
+ <div
416
+ ref={setInputContainerEl}
417
+ data-testid="copilot-input-overlay"
418
+ className="cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-0 cpk:z-20 cpk:pointer-events-none"
419
+ >
419
420
  {attachments && attachments.length > 0 && (
420
- <CopilotChatAttachmentQueue
421
- attachments={attachments}
422
- onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
423
- className="cpk:px-4"
424
- />
421
+ <div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full cpk:pointer-events-auto">
422
+ <CopilotChatAttachmentQueue
423
+ attachments={attachments}
424
+ onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
425
+ className="cpk:px-4"
426
+ />
427
+ </div>
425
428
  )}
429
+ {BoundInput}
426
430
  </div>
427
-
428
- {BoundInput}
429
431
  </div>
430
432
  );
431
433
  }
@@ -476,7 +478,6 @@ export namespace CopilotChatView {
476
478
  </div>
477
479
  </StickToBottom.Content>
478
480
 
479
- {/* Feather gradient overlay */}
480
481
  {BoundFeather}
481
482
 
482
483
  {/* Scroll to bottom button - hidden during resize */}
@@ -484,7 +485,7 @@ export namespace CopilotChatView {
484
485
  <div
485
486
  className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
486
487
  style={{
487
- bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
488
+ bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
488
489
  }}
489
490
  >
490
491
  {renderSlot(
@@ -541,21 +542,13 @@ export namespace CopilotChatView {
541
542
  topOffset: 16,
542
543
  });
543
544
 
544
- // Pin-to-send uses a SOFTER feather than pin-to-bottom:
545
- // - default: h-24 + from-white via-white to-transparent (fully opaque
546
- // bottom half, aggressive). Good for streaming-to-bottom where
547
- // the edge is always churning.
548
- // - pin-to-send: h-12 + from-white to-transparent (gradual fade,
549
- // no opaque midline). Gives a visual soft edge above the input
550
- // without obscuring otherwise-readable content.
551
- // Consumers can still override with the `feather` slot.
552
- const BoundFeather = renderSlot(feather, PinToSendSoftFeather, {});
553
-
554
- // Feather and scroll-to-bottom button live OUTSIDE the scroll container.
555
- // `position: absolute` children of an `overflow: auto` element are
556
- // positioned relative to the scroll *content*, which means they scroll
557
- // away with it. Placing them as siblings of the scroll container
545
+ // The feather and scroll-to-bottom button live OUTSIDE the scroll
546
+ // container. `position: absolute` children of an `overflow: auto` element
547
+ // are positioned relative to the scroll *content*, which means they
548
+ // scroll away with it. Placing them as siblings of the scroll container
558
549
  // (inside a `relative` wrapper) keeps them pinned to the viewport bottom.
550
+ const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
551
+
559
552
  return (
560
553
  <ScrollElementContext.Provider value={nonAutoScrollEl}>
561
554
  <div
@@ -582,14 +575,13 @@ export namespace CopilotChatView {
582
575
  style={{ height: 0, flex: "0 0 auto" }}
583
576
  />
584
577
  </div>
585
- {/* Soft feather — pinned to wrapper bottom */}
586
578
  {BoundFeather}
587
579
  {/* Scroll to bottom button */}
588
580
  {showScrollButton && !isResizing && (
589
581
  <div
590
582
  className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
591
583
  style={{
592
- bottom: `${inputContainerHeight + PIN_TO_SEND_FEATHER_HEIGHT + 16}px`,
584
+ bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
593
585
  }}
594
586
  >
595
587
  {renderSlot(
@@ -722,7 +714,6 @@ export namespace CopilotChatView {
722
714
  {children}
723
715
  </div>
724
716
 
725
- {/* Feather gradient overlay */}
726
717
  {BoundFeather}
727
718
 
728
719
  {/* Scroll to bottom button for manual mode */}
@@ -730,7 +721,7 @@ export namespace CopilotChatView {
730
721
  <div
731
722
  className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
732
723
  style={{
733
- bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
724
+ bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
734
725
  }}
735
726
  >
736
727
  {renderSlot(
@@ -812,22 +803,14 @@ export namespace CopilotChatView {
812
803
  </Button>
813
804
  );
814
805
 
806
+ // Default renders an empty div — no visual, but the element is still in the
807
+ // tree so a slot override of the form `scrollView={{ feather: "my-class" }}`
808
+ // can apply classes (and any consumer with a full component override gets
809
+ // the className/style forwarding they expect).
815
810
  export const Feather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
816
811
  className,
817
- style,
818
812
  ...props
819
- }) => (
820
- <div
821
- className={cn(
822
- "cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-24 cpk:pointer-events-none cpk:z-10 cpk:bg-gradient-to-t",
823
- "cpk:from-white cpk:via-white cpk:to-transparent",
824
- "cpk:dark:from-[rgb(33,33,33)] cpk:dark:via-[rgb(33,33,33)]",
825
- className,
826
- )}
827
- style={style}
828
- {...props}
829
- />
830
- );
813
+ }) => <div className={className} {...props} />;
831
814
 
832
815
  export const WelcomeMessage: React.FC<
833
816
  React.HTMLAttributes<HTMLDivElement>
@@ -0,0 +1,103 @@
1
+ import React, { useCallback, useEffect, useId, useRef, useState } from "react";
2
+ import { createPortal, flushSync } from "react-dom";
3
+ import { X } from "lucide-react";
4
+
5
+ interface LightboxProps {
6
+ onClose: () => void;
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export function Lightbox({ onClose, children }: LightboxProps) {
11
+ useEffect(() => {
12
+ const handleKey = (e: KeyboardEvent) => {
13
+ if (e.key === "Escape") onClose();
14
+ };
15
+ document.addEventListener("keydown", handleKey);
16
+ return () => document.removeEventListener("keydown", handleKey);
17
+ }, [onClose]);
18
+
19
+ if (typeof document === "undefined") return null;
20
+
21
+ return createPortal(
22
+ <div
23
+ className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
24
+ onClick={onClose}
25
+ >
26
+ <button
27
+ onClick={onClose}
28
+ className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
29
+ aria-label="Close preview"
30
+ >
31
+ <X className="cpk:w-5 cpk:h-5" />
32
+ </button>
33
+
34
+ <div onClick={(e) => e.stopPropagation()}>{children}</div>
35
+ </div>,
36
+ document.body,
37
+ );
38
+ }
39
+
40
+ type DocWithVT = Document & {
41
+ startViewTransition?: (cb: () => void) => { finished: Promise<void> };
42
+ };
43
+
44
+ /**
45
+ * Hook that manages lightbox open/close and uses the View Transition API to
46
+ * morph the thumbnail into fullscreen content.
47
+ *
48
+ * The trick: `view-transition-name` must live on exactly ONE element at a time.
49
+ * - Old state (thumbnail visible): name is on the thumbnail.
50
+ * - New state (lightbox visible): name moves to the lightbox content.
51
+ * `flushSync` ensures React commits the DOM change synchronously inside the
52
+ * `startViewTransition` callback so the API can snapshot old → new correctly.
53
+ */
54
+ export function useLightbox() {
55
+ const thumbnailRef = useRef<HTMLElement>(null);
56
+ const [open, setOpen] = useState(false);
57
+ const vtName = useId();
58
+
59
+ const openLightbox = useCallback(() => {
60
+ const thumb = thumbnailRef.current;
61
+ const doc = document as DocWithVT;
62
+
63
+ if (doc.startViewTransition && thumb) {
64
+ thumb.style.viewTransitionName = vtName;
65
+
66
+ doc.startViewTransition(() => {
67
+ thumb.style.viewTransitionName = "";
68
+ flushSync(() => setOpen(true));
69
+ });
70
+ } else {
71
+ setOpen(true);
72
+ }
73
+ }, [vtName]);
74
+
75
+ const closeLightbox = useCallback(() => {
76
+ const thumb = thumbnailRef.current;
77
+ const doc = document as DocWithVT;
78
+
79
+ if (doc.startViewTransition && thumb) {
80
+ const transition = doc.startViewTransition(() => {
81
+ flushSync(() => setOpen(false));
82
+ thumb.style.viewTransitionName = vtName;
83
+ });
84
+ transition.finished
85
+ .then(() => {
86
+ thumb.style.viewTransitionName = "";
87
+ })
88
+ .catch(() => {
89
+ thumb.style.viewTransitionName = "";
90
+ });
91
+ } else {
92
+ setOpen(false);
93
+ }
94
+ }, [vtName]);
95
+
96
+ return {
97
+ thumbnailRef,
98
+ vtName,
99
+ open,
100
+ openLightbox,
101
+ closeLightbox,
102
+ };
103
+ }