@djangocfg/ui-tools 2.1.381 → 2.1.382

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 (183) hide show
  1. package/README.md +132 -899
  2. package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
  3. package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
  4. package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
  5. package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
  6. package/dist/DictationField-2ZLQWLYV.mjs +4 -0
  7. package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
  8. package/dist/DictationField-IPPJ54CU.cjs +13 -0
  9. package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
  10. package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
  11. package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
  12. package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
  13. package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
  14. package/dist/chunk-4LXG3NBV.mjs +833 -0
  15. package/dist/chunk-4LXG3NBV.mjs.map +1 -0
  16. package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
  17. package/dist/chunk-FIRK5CEH.cjs.map +1 -0
  18. package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
  19. package/dist/chunk-HIK6BPL7.mjs.map +1 -0
  20. package/dist/chunk-KMSBGNVC.cjs +835 -0
  21. package/dist/chunk-KMSBGNVC.cjs.map +1 -0
  22. package/dist/chunk-OZAU3QWD.cjs +2493 -0
  23. package/dist/chunk-OZAU3QWD.cjs.map +1 -0
  24. package/dist/chunk-UWVP6LCW.mjs +2447 -0
  25. package/dist/chunk-UWVP6LCW.mjs.map +1 -0
  26. package/dist/index.cjs +1532 -100
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +1148 -107
  29. package/dist/index.d.ts +1148 -107
  30. package/dist/index.mjs +1421 -51
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +16 -8
  33. package/src/audio-assets.d.ts +8 -0
  34. package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
  35. package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
  36. package/src/stories/index.ts +32 -2
  37. package/src/tools/Chat/README.md +347 -530
  38. package/src/tools/Chat/components/Attachments.tsx +6 -1
  39. package/src/tools/Chat/components/ChatRoot.tsx +30 -2
  40. package/src/tools/Chat/components/Composer.tsx +20 -3
  41. package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
  42. package/src/tools/Chat/components/MessageActions.tsx +3 -1
  43. package/src/tools/Chat/components/MessageBubble.tsx +6 -5
  44. package/src/tools/Chat/components/MessageList.tsx +87 -1
  45. package/src/tools/Chat/components/ToolCalls.tsx +21 -3
  46. package/src/tools/Chat/context/ChatProvider.tsx +21 -3
  47. package/src/tools/Chat/core/audio/audioBus.ts +10 -163
  48. package/src/tools/Chat/core/audio/defaults.ts +43 -0
  49. package/src/tools/Chat/core/audio/index.ts +1 -0
  50. package/src/tools/Chat/core/audio/preferences.ts +5 -59
  51. package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
  52. package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
  53. package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
  54. package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
  55. package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
  56. package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
  57. package/src/tools/Chat/core/audio/types.ts +28 -0
  58. package/src/tools/Chat/core/reducer.ts +33 -0
  59. package/src/tools/Chat/core/transport/index.ts +13 -0
  60. package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
  61. package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
  62. package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
  63. package/src/tools/Chat/core/transport/sse.ts +18 -5
  64. package/src/tools/Chat/hooks/index.ts +25 -0
  65. package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
  66. package/src/tools/Chat/hooks/useChat.ts +28 -0
  67. package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
  68. package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
  69. package/src/tools/Chat/hooks/useChatReset.ts +70 -0
  70. package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
  71. package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
  72. package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
  73. package/src/tools/Chat/index.ts +69 -1
  74. package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
  75. package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
  76. package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
  77. package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
  78. package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
  79. package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
  80. package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
  81. package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
  82. package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
  83. package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
  84. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
  85. package/src/tools/Chat/launcher/index.ts +46 -0
  86. package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
  87. package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
  88. package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
  89. package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
  90. package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
  91. package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
  92. package/src/tools/Chat/stories/06-header.story.tsx +147 -0
  93. package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
  94. package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
  95. package/src/tools/Chat/stories/shared/index.ts +5 -0
  96. package/src/tools/Chat/stories/shared/messages.ts +39 -0
  97. package/src/tools/Chat/stories/shared/personas.ts +13 -0
  98. package/src/tools/Chat/stories/shared/seeds.ts +92 -0
  99. package/src/tools/Chat/stories/shared/transports.ts +36 -0
  100. package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
  101. package/src/tools/Chat/styles/index.ts +16 -0
  102. package/src/tools/Chat/styles/useChatStyles.ts +101 -0
  103. package/src/tools/Chat/types/attachment.ts +25 -0
  104. package/src/tools/Chat/types/config.ts +48 -0
  105. package/src/tools/Chat/types/events.ts +35 -0
  106. package/src/tools/Chat/types/index.ts +34 -0
  107. package/src/tools/Chat/types/labels.ts +38 -0
  108. package/src/tools/Chat/types/message.ts +32 -0
  109. package/src/tools/Chat/types/persona.ts +31 -0
  110. package/src/tools/Chat/types/session.ts +43 -0
  111. package/src/tools/Chat/types/tool-call.ts +17 -0
  112. package/src/tools/Chat/types/transport.ts +28 -0
  113. package/src/tools/Chat/types.ts +5 -240
  114. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
  115. package/src/tools/MarkdownEditor/index.ts +1 -1
  116. package/src/tools/SpeechRecognition/README.md +336 -0
  117. package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
  118. package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
  119. package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
  120. package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
  121. package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
  122. package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
  123. package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
  124. package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
  125. package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
  126. package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
  127. package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
  128. package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
  129. package/src/tools/SpeechRecognition/components/index.ts +16 -0
  130. package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
  131. package/src/tools/SpeechRecognition/context/index.ts +6 -0
  132. package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
  133. package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
  134. package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
  135. package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
  136. package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
  137. package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
  138. package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
  139. package/src/tools/SpeechRecognition/core/ids.ts +11 -0
  140. package/src/tools/SpeechRecognition/core/index.ts +14 -0
  141. package/src/tools/SpeechRecognition/core/language.ts +78 -0
  142. package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
  143. package/src/tools/SpeechRecognition/core/logger.ts +3 -0
  144. package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
  145. package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
  146. package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
  147. package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
  148. package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
  149. package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
  150. package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
  151. package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
  152. package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
  153. package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
  154. package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
  155. package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
  156. package/src/tools/SpeechRecognition/index.ts +82 -0
  157. package/src/tools/SpeechRecognition/lazy.tsx +19 -0
  158. package/src/tools/SpeechRecognition/store/index.ts +2 -0
  159. package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
  160. package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
  161. package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
  162. package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
  163. package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
  164. package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
  165. package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
  166. package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
  167. package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
  168. package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
  169. package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
  170. package/src/tools/SpeechRecognition/types.ts +133 -0
  171. package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
  172. package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
  173. package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
  174. package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
  175. package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
  176. package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
  177. package/dist/chunk-NWUT327A.mjs.map +0 -1
  178. package/dist/chunk-QLMKCSR6.mjs +0 -2420
  179. package/dist/chunk-QLMKCSR6.mjs.map +0 -1
  180. package/dist/chunk-SI5RD2GD.cjs +0 -2460
  181. package/dist/chunk-SI5RD2GD.cjs.map +0 -1
  182. package/dist/chunk-XACCHZH2.cjs.map +0 -1
  183. package/src/tools/Chat/Chat.story.tsx +0 -1457
@@ -0,0 +1,349 @@
1
+ 'use client';
2
+
3
+ import { Bot } from 'lucide-react';
4
+ import type { CSSProperties, ReactNode } from 'react';
5
+
6
+ import { useIsPhone, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ export type ChatFABPosition =
10
+ | 'bottom-right'
11
+ | 'bottom-left'
12
+ | 'top-right'
13
+ | 'top-left';
14
+
15
+ export type ChatFABVariant = 'simple' | 'animated' | 'glass';
16
+ export type ChatFABSize = 'sm' | 'md' | 'lg' | 'responsive';
17
+
18
+ export interface ChatFABProps {
19
+ /** Click handler — typically toggles a `ChatDock`. */
20
+ onClick: () => void;
21
+ /** Accessible label. */
22
+ ariaLabel?: string;
23
+ /** Icon inside the FAB. Defaults to a bot glyph. */
24
+ icon?: ReactNode;
25
+ /** Visual style. @default 'simple' */
26
+ variant?: ChatFABVariant;
27
+ /** Button size. @default 'md' */
28
+ size?: ChatFABSize;
29
+ /** Fixed-screen position. @default 'bottom-right' */
30
+ position?: ChatFABPosition;
31
+ /** Pixel offset from screen edges. @default 24 */
32
+ offset?: number;
33
+ /** z-index for the button. @default 9999 */
34
+ zIndex?: number;
35
+ /** Show a small attention dot (unread / new). */
36
+ pulse?: boolean;
37
+ /**
38
+ * Numeric badge — unread count. Numbers > 9 render as "9+".
39
+ * Overrides `pulse` when both set.
40
+ */
41
+ badge?: number;
42
+ /** Hover tooltip text. Shows next to the FAB on hover/focus. */
43
+ tooltip?: string;
44
+ /**
45
+ * Render in-place (no fixed positioning) so the FAB sits inline in the
46
+ * normal document flow. Useful for stories, screenshots, and previews
47
+ * inside a contained playground panel. @default false
48
+ */
49
+ inline?: boolean;
50
+ /** Override classes on the button itself. */
51
+ className?: string;
52
+ /** Extra style on the button (caller-controlled overrides). */
53
+ style?: CSSProperties;
54
+ }
55
+
56
+ type ChatFABFixedSize = Exclude<ChatFABSize, 'responsive'>;
57
+
58
+ const SIZE_PX: Record<ChatFABFixedSize, number> = { sm: 44, md: 56, lg: 64 };
59
+ const ICON_PX: Record<ChatFABFixedSize, number> = { sm: 18, md: 22, lg: 26 };
60
+
61
+ /**
62
+ * Resolve `size='responsive'` to a concrete fixed size based on the
63
+ * viewport. Phone → `sm`, tablet → `md`, desktop → `lg`. `inline`
64
+ * previews always collapse to `md` so stories stay stable.
65
+ */
66
+ function useEffectiveFABSize(size: ChatFABSize, inline: boolean): ChatFABFixedSize {
67
+ const isPhone = useIsPhone();
68
+ const isBelowDesktop = useIsTabletOrBelow();
69
+ if (size !== 'responsive') return size;
70
+ if (inline) return 'md';
71
+ if (isPhone) return 'sm';
72
+ if (isBelowDesktop) return 'md';
73
+ return 'lg';
74
+ }
75
+
76
+ function positionStyle(position: ChatFABPosition, offset: number): CSSProperties {
77
+ const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left'];
78
+ return { [vert]: offset, [horiz]: offset } as CSSProperties;
79
+ }
80
+
81
+ function tooltipSideClasses(position: ChatFABPosition): string {
82
+ // Tooltip sits opposite-horizontal to the FAB so it doesn't run off-screen.
83
+ return position.endsWith('right')
84
+ ? 'right-full mr-3 origin-right'
85
+ : 'left-full ml-3 origin-left';
86
+ }
87
+
88
+ function Badge({ value }: { value: number }) {
89
+ const display = value > 9 ? '9+' : String(value);
90
+ return (
91
+ <span
92
+ aria-hidden="true"
93
+ className={cn(
94
+ 'absolute -right-1 -top-1 inline-flex min-w-[18px] h-[18px] items-center justify-center',
95
+ 'rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground',
96
+ 'ring-2 ring-background',
97
+ )}
98
+ >
99
+ {display}
100
+ </span>
101
+ );
102
+ }
103
+
104
+ function PulseDot() {
105
+ return (
106
+ <span aria-hidden="true" className="absolute right-1 top-1">
107
+ <span className="relative inline-flex h-2.5 w-2.5">
108
+ <span className="absolute inset-0 rounded-full bg-destructive opacity-75 animate-ping" />
109
+ <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-background" />
110
+ </span>
111
+ </span>
112
+ );
113
+ }
114
+
115
+ function Tooltip({ text, side }: { text: string; side: string }) {
116
+ return (
117
+ <span
118
+ role="tooltip"
119
+ className={cn(
120
+ 'pointer-events-none absolute top-1/2 -translate-y-1/2 whitespace-nowrap',
121
+ 'rounded-md bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-md',
122
+ 'border border-border opacity-0 scale-95 transition-all duration-150',
123
+ 'group-hover:opacity-100 group-hover:scale-100',
124
+ 'group-focus-within:opacity-100 group-focus-within:scale-100',
125
+ side,
126
+ )}
127
+ >
128
+ {text}
129
+ </span>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Floating action button for opening a chat dock. Pure presentation — owns
135
+ * no state. Wire it to whatever toggles your dock open.
136
+ *
137
+ * For the common "FAB + dock + hotkey" composition, use `<ChatLauncher>` —
138
+ * this primitive is only useful when you need custom triggering.
139
+ */
140
+ export function ChatFAB({
141
+ onClick,
142
+ ariaLabel = 'Open chat',
143
+ icon,
144
+ variant = 'simple',
145
+ size = 'responsive',
146
+ position = 'bottom-right',
147
+ offset = 24,
148
+ zIndex = 9999,
149
+ pulse = false,
150
+ badge,
151
+ tooltip,
152
+ inline = false,
153
+ className,
154
+ style,
155
+ }: ChatFABProps) {
156
+ const effectiveSize = useEffectiveFABSize(size, inline);
157
+ const px = SIZE_PX[effectiveSize];
158
+ const iconPx = ICON_PX[effectiveSize];
159
+ const renderedIcon = icon ?? <Bot size={iconPx} />;
160
+
161
+ const baseButton = cn(
162
+ 'relative grid place-items-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
163
+ 'transition-transform hover:scale-105',
164
+ );
165
+
166
+ return (
167
+ <div
168
+ className={cn('group', inline ? 'relative inline-flex' : 'fixed')}
169
+ style={
170
+ inline
171
+ ? undefined
172
+ : { ...positionStyle(position, offset), zIndex }
173
+ }
174
+ >
175
+ {variant === 'animated' && (
176
+ <AnimatedFAB
177
+ ariaLabel={ariaLabel}
178
+ onClick={onClick}
179
+ size={px}
180
+ className={className}
181
+ style={style}
182
+ >
183
+ {renderedIcon}
184
+ </AnimatedFAB>
185
+ )}
186
+
187
+ {variant === 'glass' && (
188
+ <button
189
+ type="button"
190
+ aria-label={ariaLabel}
191
+ onClick={onClick}
192
+ className={cn(
193
+ baseButton,
194
+ 'border border-border/40 bg-background/60 text-foreground shadow-lg backdrop-blur-xl',
195
+ 'hover:bg-background/80',
196
+ className,
197
+ )}
198
+ style={{ width: px, height: px, ...style }}
199
+ >
200
+ {renderedIcon}
201
+ {badge !== undefined ? <Badge value={badge} /> : pulse ? <PulseDot /> : null}
202
+ </button>
203
+ )}
204
+
205
+ {variant === 'simple' && (
206
+ <button
207
+ type="button"
208
+ aria-label={ariaLabel}
209
+ onClick={onClick}
210
+ className={cn(
211
+ baseButton,
212
+ 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xl',
213
+ className,
214
+ )}
215
+ style={{ width: px, height: px, ...style }}
216
+ >
217
+ {renderedIcon}
218
+ {badge !== undefined ? <Badge value={badge} /> : pulse ? <PulseDot /> : null}
219
+ </button>
220
+ )}
221
+
222
+ {tooltip && <Tooltip text={tooltip} side={tooltipSideClasses(position)} />}
223
+ </div>
224
+ );
225
+ }
226
+
227
+ // ── Animated variant ──────────────────────────────────────────────────────
228
+ // Orbital gradient ring + glow + entrance bounce. Self-contained CSS via
229
+ // inline <style>, scoped by a unique class name per instance to avoid
230
+ // collisions when multiple FABs mount.
231
+
232
+ interface AnimatedFABProps {
233
+ ariaLabel: string;
234
+ onClick: () => void;
235
+ size: number;
236
+ className?: string;
237
+ style?: CSSProperties;
238
+ children: ReactNode;
239
+ }
240
+
241
+ function AnimatedFAB({ ariaLabel, onClick, size, className, style, children }: AnimatedFABProps) {
242
+ return (
243
+ <>
244
+ <style>{ANIMATED_CSS}</style>
245
+ <div
246
+ className={cn('cmdop-fab-anim', className)}
247
+ style={{ width: size, height: size, ...style }}
248
+ >
249
+ <div className="cmdop-fab-anim-glow">
250
+ <div className="cmdop-fab-anim-wrap">
251
+ <div className="cmdop-fab-anim-grad cmdop-fab-anim-grad-1" />
252
+ <div className="cmdop-fab-anim-grad cmdop-fab-anim-grad-2" />
253
+ <div className="cmdop-fab-anim-inner" />
254
+ <button
255
+ type="button"
256
+ aria-label={ariaLabel}
257
+ onClick={onClick}
258
+ className="cmdop-fab-anim-btn"
259
+ >
260
+ <span className="cmdop-fab-anim-icon">{children}</span>
261
+ </button>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ </>
266
+ );
267
+ }
268
+
269
+ const ANIMATED_CSS = `
270
+ .cmdop-fab-anim {
271
+ position: relative;
272
+ pointer-events: auto;
273
+ }
274
+ .cmdop-fab-anim-glow {
275
+ width: 100%; height: 100%;
276
+ border-radius: 50%;
277
+ overflow: hidden;
278
+ animation:
279
+ cmdop-fab-entrance 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) forwards,
280
+ cmdop-fab-glow-shift 8s ease-in-out 0.6s infinite;
281
+ }
282
+ .cmdop-fab-anim-wrap {
283
+ position: relative; width: 100%; height: 100%;
284
+ border-radius: 50%; overflow: hidden;
285
+ }
286
+ .cmdop-fab-anim-grad { position: absolute; inset: 0; border-radius: 50%; }
287
+ .cmdop-fab-anim-grad-1 {
288
+ background: conic-gradient(
289
+ from 0deg,
290
+ #fbbf24 0%, rgba(251,191,36,0) 15%,
291
+ rgba(168,85,247,0) 20%, #a855f7 35%, rgba(168,85,247,0) 50%,
292
+ rgba(20,184,166,0) 55%, #14b8a6 70%, rgba(20,184,166,0) 85%,
293
+ rgba(236,72,153,0) 88%, #ec4899 97%, #fbbf24 100%
294
+ );
295
+ animation: cmdop-fab-rotate 7s linear infinite;
296
+ filter: blur(1px); opacity: 0.95;
297
+ }
298
+ .cmdop-fab-anim-grad-2 {
299
+ inset: 1px;
300
+ background: conic-gradient(
301
+ from 180deg,
302
+ #a855f7 0%, rgba(168,85,247,0) 20%,
303
+ rgba(20,184,166,0) 30%, #14b8a6 50%, rgba(20,184,166,0) 70%,
304
+ rgba(251,191,36,0) 75%, #fbbf24 95%, #a855f7 100%
305
+ );
306
+ animation: cmdop-fab-rotate-rev 9s linear infinite;
307
+ filter: blur(0.75px); opacity: 0.7;
308
+ }
309
+ .cmdop-fab-anim-inner {
310
+ position: absolute; inset: 3px; border-radius: 50%;
311
+ background: rgba(10, 10, 10, 0.65);
312
+ backdrop-filter: blur(12px) saturate(1.8);
313
+ -webkit-backdrop-filter: blur(12px) saturate(1.8);
314
+ animation: cmdop-fab-inner-glow 5s ease-in-out infinite;
315
+ }
316
+ .cmdop-fab-anim-btn {
317
+ position: absolute; inset: 2px;
318
+ border-radius: 50%; border: none; background: transparent;
319
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
320
+ transition: transform 0.2s;
321
+ }
322
+ .cmdop-fab-anim-btn:hover { transform: scale(1.06); }
323
+ .cmdop-fab-anim-icon {
324
+ color: #fbbf24; display: flex;
325
+ filter: drop-shadow(0 0 6px rgba(251,191,36,0.8));
326
+ animation: cmdop-fab-icon-pulse 2.5s ease-in-out infinite;
327
+ }
328
+ @keyframes cmdop-fab-rotate { to { transform: rotate(360deg); } }
329
+ @keyframes cmdop-fab-rotate-rev { to { transform: rotate(-360deg); } }
330
+ @keyframes cmdop-fab-entrance {
331
+ 0% { transform: scale(0); }
332
+ 50% { transform: scale(1.08); }
333
+ 70% { transform: scale(0.98); }
334
+ 100% { transform: scale(1); }
335
+ }
336
+ @keyframes cmdop-fab-glow-shift {
337
+ 0%, 100% { box-shadow: 0 0 20px rgba(251,191,36,0.5), 0 0 40px rgba(168,85,247,0.3); }
338
+ 33% { box-shadow: 0 0 20px rgba(168,85,247,0.5), 0 0 40px rgba(20,184,166,0.3); }
339
+ 66% { box-shadow: 0 0 20px rgba(20,184,166,0.5), 0 0 40px rgba(236,72,153,0.3); }
340
+ }
341
+ @keyframes cmdop-fab-icon-pulse {
342
+ 0%, 100% { opacity: 1; transform: scale(1); }
343
+ 50% { opacity: 0.85; transform: scale(1.15); }
344
+ }
345
+ @keyframes cmdop-fab-inner-glow {
346
+ 0%, 100% { box-shadow: inset 0 0 20px rgba(251,191,36,0.25), inset 0 0 40px rgba(168,85,247,0.15); }
347
+ 50% { box-shadow: inset 0 0 25px rgba(168,85,247,0.3), inset 0 0 45px rgba(20,184,166,0.2); }
348
+ }
349
+ `;
@@ -0,0 +1,200 @@
1
+ 'use client';
2
+
3
+ import { X } from 'lucide-react';
4
+ import { useEffect, useState } from 'react';
5
+ import type { CSSProperties, ReactNode } from 'react';
6
+
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ import { useChatPresence } from './useChatPresence';
10
+ import type { ChatFABPosition } from './ChatFAB';
11
+
12
+ export interface ChatGreetingProps {
13
+ /** Controlled visibility — usually `!chatOpen && !userDismissed`. */
14
+ open: boolean;
15
+ /** Greeting text. Pass a string for the default bubble, or any ReactNode. */
16
+ children: ReactNode;
17
+ /** Click handler — typically opens the chat. Bubble is clickable when set. */
18
+ onClick?: () => void;
19
+ /** Close (×) button handler — typically marks the greeting as dismissed. */
20
+ onDismiss?: () => void;
21
+ /** Anchor relative to a FAB on the same side. @default 'bottom-right' */
22
+ position?: ChatFABPosition;
23
+ /**
24
+ * Horizontal pixel offset matching the FAB's `offset` prop, so the greeting
25
+ * lines up under the FAB. @default 24
26
+ */
27
+ fabOffset?: number;
28
+ /**
29
+ * Vertical pixel offset above/below the FAB centerline. @default 96
30
+ * (room for an `md` FAB plus a small gap).
31
+ */
32
+ fabClearance?: number;
33
+ /** Delay before the greeting appears, in ms. @default 1500 */
34
+ delayMs?: number;
35
+ /** z-index. @default 9998 (just below the default FAB at 9999). */
36
+ zIndex?: number;
37
+ /** Override classes on the bubble. */
38
+ className?: string;
39
+ /** Override styles on the bubble. */
40
+ style?: CSSProperties;
41
+ /** Optional sender avatar / icon shown on the left. */
42
+ avatar?: ReactNode;
43
+ /** Optional sender label rendered above the text. */
44
+ senderName?: string;
45
+ /** ARIA label for the dismiss button. @default 'Dismiss' */
46
+ dismissLabel?: string;
47
+ /**
48
+ * Render in-place (no fixed positioning). Useful for stories and inline previews.
49
+ * @default false
50
+ */
51
+ inline?: boolean;
52
+ }
53
+
54
+ function anchorStyle(
55
+ position: ChatFABPosition,
56
+ fabOffset: number,
57
+ fabClearance: number,
58
+ ): CSSProperties {
59
+ const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left'];
60
+ return { [vert]: fabClearance, [horiz]: fabOffset } as CSSProperties;
61
+ }
62
+
63
+ function originClass(position: ChatFABPosition): string {
64
+ // Scale-in origin matches the corner the bubble attaches to.
65
+ if (position === 'bottom-right') return 'origin-bottom-right';
66
+ if (position === 'bottom-left') return 'origin-bottom-left';
67
+ if (position === 'top-right') return 'origin-top-right';
68
+ return 'origin-top-left';
69
+ }
70
+
71
+ /**
72
+ * Greeting bubble shown next to a `ChatFAB` to invite the user to start a
73
+ * conversation (LiveChat / Intercom-style proactive prompt).
74
+ *
75
+ * Renders fixed-position, anchored to the same corner as the FAB. Owns its
76
+ * own delayed-mount + presence animation. Hide on chat open and/or after
77
+ * user dismissal.
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * const [open, setOpen] = useState(false);
82
+ * const [dismissed, setDismissed] = useState(false);
83
+ *
84
+ * <ChatLauncher
85
+ * open={open}
86
+ * onOpenChange={setOpen}
87
+ * fab={{ variant: 'animated' }}
88
+ * dock={{ title: 'Support' }}
89
+ * >
90
+ * <SupportChat />
91
+ * </ChatLauncher>
92
+ *
93
+ * <ChatGreeting
94
+ * open={!open && !dismissed}
95
+ * onClick={() => setOpen(true)}
96
+ * onDismiss={() => setDismissed(true)}
97
+ * senderName="Anna from Support"
98
+ * delayMs={2000}
99
+ * >
100
+ * Hi! 👋 Got a question? I'm here to help.
101
+ * </ChatGreeting>
102
+ * ```
103
+ */
104
+ export function ChatGreeting({
105
+ open,
106
+ children,
107
+ onClick,
108
+ onDismiss,
109
+ position = 'bottom-right',
110
+ fabOffset = 24,
111
+ fabClearance = 96,
112
+ delayMs = 1500,
113
+ zIndex = 9998,
114
+ className,
115
+ style,
116
+ avatar,
117
+ senderName,
118
+ dismissLabel = 'Dismiss',
119
+ inline = false,
120
+ }: ChatGreetingProps) {
121
+ const [delayed, setDelayed] = useState(delayMs <= 0);
122
+
123
+ useEffect(() => {
124
+ if (!open || delayMs <= 0) return;
125
+ const t = setTimeout(() => setDelayed(true), delayMs);
126
+ return () => clearTimeout(t);
127
+ }, [open, delayMs]);
128
+
129
+ const shouldShow = open && delayed;
130
+ const phase = useChatPresence(shouldShow, 220);
131
+
132
+ if (phase === 'hidden') return null;
133
+
134
+ const animating = phase === 'entering' || phase === 'leaving';
135
+ const clickable = !!onClick;
136
+
137
+ return (
138
+ <div
139
+ role={clickable ? 'button' : 'status'}
140
+ aria-live="polite"
141
+ tabIndex={clickable ? 0 : -1}
142
+ onClick={clickable ? onClick : undefined}
143
+ onKeyDown={
144
+ clickable
145
+ ? (e) => {
146
+ if (e.key === 'Enter' || e.key === ' ') {
147
+ e.preventDefault();
148
+ onClick?.();
149
+ }
150
+ }
151
+ : undefined
152
+ }
153
+ className={cn(
154
+ inline ? 'relative inline-flex' : 'fixed',
155
+ 'flex items-start gap-2.5 max-w-[280px]',
156
+ 'rounded-2xl border border-border bg-popover text-popover-foreground',
157
+ 'px-3.5 py-2.5 shadow-2xl transition-all duration-200 ease-out',
158
+ clickable && 'cursor-pointer hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
159
+ originClass(position),
160
+ animating ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0',
161
+ className,
162
+ )}
163
+ style={{
164
+ ...(inline ? {} : anchorStyle(position, fabOffset, fabClearance)),
165
+ ...(inline ? {} : { zIndex }),
166
+ pointerEvents: phase === 'visible' ? 'auto' : 'none',
167
+ ...style,
168
+ }}
169
+ >
170
+ {avatar && <div className="mt-0.5 shrink-0">{avatar}</div>}
171
+
172
+ <div className="min-w-0 flex-1 text-sm leading-snug">
173
+ {senderName && (
174
+ <div className="mb-0.5 text-[11px] font-medium text-muted-foreground">
175
+ {senderName}
176
+ </div>
177
+ )}
178
+ <div className="text-foreground">{children}</div>
179
+ </div>
180
+
181
+ {onDismiss && (
182
+ <button
183
+ type="button"
184
+ aria-label={dismissLabel}
185
+ onClick={(e) => {
186
+ e.stopPropagation();
187
+ onDismiss();
188
+ }}
189
+ className={cn(
190
+ '-mr-1 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full',
191
+ 'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
192
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
193
+ )}
194
+ >
195
+ <X className="h-3.5 w-3.5" />
196
+ </button>
197
+ )}
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { Bot, X } from 'lucide-react';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import { Button } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ export interface ChatHeaderProps {
10
+ /** Window title text. */
11
+ title?: ReactNode;
12
+ /** Icon next to the title. Defaults to a bot glyph. */
13
+ icon?: ReactNode;
14
+ /**
15
+ * Action slot — appears to the right of the title, before the close button.
16
+ * Use for reset / settings / minimize / etc. Compose with `ChatHeaderActionButton`.
17
+ */
18
+ actions?: ReactNode;
19
+ /** Show the close (×) button. @default true */
20
+ showClose?: boolean;
21
+ /** Close click handler. */
22
+ onClose?: () => void;
23
+ /** ARIA label for the close button. @default 'Close' */
24
+ closeLabel?: string;
25
+ /** Replace the close button entirely (rare — most hosts want the default). */
26
+ closeSlot?: ReactNode;
27
+ /** Extra classes on the `<header>` element. */
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * Standalone chat header — title + icon + action slot + close button.
33
+ *
34
+ * Used by `<ChatDock>` automatically, but can be rendered standalone when
35
+ * the host owns the chat container (e.g. embedded inline in a page).
36
+ */
37
+ export function ChatHeader({
38
+ title,
39
+ icon,
40
+ actions,
41
+ showClose = true,
42
+ onClose,
43
+ closeLabel = 'Close',
44
+ closeSlot,
45
+ className,
46
+ }: ChatHeaderProps) {
47
+ return (
48
+ <header
49
+ className={cn(
50
+ 'border-border bg-muted/30 flex shrink-0 items-center justify-between border-b px-4 py-2.5',
51
+ className,
52
+ )}
53
+ >
54
+ <div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
55
+ {icon ?? <Bot className="text-primary h-4 w-4 shrink-0" />}
56
+ <span className="truncate">{title}</span>
57
+ </div>
58
+
59
+ <div className="flex items-center gap-0.5">
60
+ {actions}
61
+ {closeSlot ??
62
+ (showClose && onClose && (
63
+ <Button
64
+ variant="ghost"
65
+ size="sm"
66
+ onClick={onClose}
67
+ aria-label={closeLabel}
68
+ className="-mr-1 h-7 w-7 p-0"
69
+ >
70
+ <X className="h-4 w-4" />
71
+ </Button>
72
+ ))}
73
+ </div>
74
+ </header>
75
+ );
76
+ }