@netbirdio/explain 0.1.0

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 (57) hide show
  1. package/README.md +402 -0
  2. package/dist/client/AIAssistantProvider.d.ts +21 -0
  3. package/dist/client/AIAssistantProvider.d.ts.map +1 -0
  4. package/dist/client/AIAssistantProvider.js +198 -0
  5. package/dist/client/AIAssistantProvider.js.map +1 -0
  6. package/dist/client/AIChatBot.d.ts +10 -0
  7. package/dist/client/AIChatBot.d.ts.map +1 -0
  8. package/dist/client/AIChatBot.js +139 -0
  9. package/dist/client/AIChatBot.js.map +1 -0
  10. package/dist/client/AIFloatingButton.d.ts +7 -0
  11. package/dist/client/AIFloatingButton.d.ts.map +1 -0
  12. package/dist/client/AIFloatingButton.js +16 -0
  13. package/dist/client/AIFloatingButton.js.map +1 -0
  14. package/dist/client/index.d.ts +5 -0
  15. package/dist/client/index.d.ts.map +1 -0
  16. package/dist/client/index.js +4 -0
  17. package/dist/client/index.js.map +1 -0
  18. package/dist/client/styles.d.ts +51 -0
  19. package/dist/client/styles.d.ts.map +1 -0
  20. package/dist/client/styles.js +317 -0
  21. package/dist/client/styles.js.map +1 -0
  22. package/dist/server/handler.d.ts +38 -0
  23. package/dist/server/handler.d.ts.map +1 -0
  24. package/dist/server/handler.js +117 -0
  25. package/dist/server/handler.js.map +1 -0
  26. package/dist/server/index.d.ts +6 -0
  27. package/dist/server/index.d.ts.map +1 -0
  28. package/dist/server/index.js +4 -0
  29. package/dist/server/index.js.map +1 -0
  30. package/dist/server/providers/anthropic.d.ts +9 -0
  31. package/dist/server/providers/anthropic.d.ts.map +1 -0
  32. package/dist/server/providers/anthropic.js +40 -0
  33. package/dist/server/providers/anthropic.js.map +1 -0
  34. package/dist/server/providers/openai.d.ts +9 -0
  35. package/dist/server/providers/openai.d.ts.map +1 -0
  36. package/dist/server/providers/openai.js +46 -0
  37. package/dist/server/providers/openai.js.map +1 -0
  38. package/dist/server/providers/types.d.ts +9 -0
  39. package/dist/server/providers/types.d.ts.map +1 -0
  40. package/dist/server/providers/types.js +2 -0
  41. package/dist/server/providers/types.js.map +1 -0
  42. package/dist/types.d.ts +11 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +2 -0
  45. package/dist/types.js.map +1 -0
  46. package/package.json +45 -0
  47. package/src/client/AIAssistantProvider.tsx +288 -0
  48. package/src/client/AIChatBot.tsx +309 -0
  49. package/src/client/AIFloatingButton.tsx +34 -0
  50. package/src/client/index.ts +4 -0
  51. package/src/client/styles.ts +353 -0
  52. package/src/server/handler.ts +158 -0
  53. package/src/server/index.ts +5 -0
  54. package/src/server/providers/anthropic.ts +53 -0
  55. package/src/server/providers/openai.ts +55 -0
  56. package/src/server/providers/types.ts +10 -0
  57. package/src/types.ts +11 -0
@@ -0,0 +1,309 @@
1
+ "use client";
2
+
3
+ import {
4
+ Bot,
5
+ MessageCircleQuestion,
6
+ Send,
7
+ Sparkles,
8
+ User,
9
+ X,
10
+ } from "lucide-react";
11
+ import React, { useCallback, useEffect, useRef, useState } from "react";
12
+ import type { Message } from "../types";
13
+ import * as S from "./styles";
14
+
15
+ type Props = {
16
+ open: boolean;
17
+ onClose: () => void;
18
+ initialQuery: string;
19
+ endpoint: string;
20
+ apiKey?: string;
21
+ };
22
+
23
+ async function fetchAIResponse(
24
+ endpoint: string,
25
+ apiKey: string | undefined,
26
+ messages: Message[],
27
+ ): Promise<string> {
28
+ const headers: Record<string, string> = {
29
+ "Content-Type": "application/json",
30
+ };
31
+ if (apiKey) {
32
+ headers["Authorization"] = `Bearer ${apiKey}`;
33
+ }
34
+
35
+ const response = await fetch(endpoint, {
36
+ method: "POST",
37
+ headers,
38
+ body: JSON.stringify({
39
+ messages: messages.map((m) => ({
40
+ role: m.role,
41
+ content: m.content,
42
+ })),
43
+ }),
44
+ });
45
+
46
+ if (!response.ok) {
47
+ const text = await response.text();
48
+ throw new Error(`${response.status}: ${text}`);
49
+ }
50
+
51
+ const data = await response.json();
52
+ return data.reply;
53
+ }
54
+
55
+ export default function AIChatBot({
56
+ open,
57
+ onClose,
58
+ initialQuery,
59
+ endpoint,
60
+ apiKey,
61
+ }: Props) {
62
+ const [messages, setMessages] = useState<Message[]>([]);
63
+ const [input, setInput] = useState("");
64
+ const [isTyping, setIsTyping] = useState(false);
65
+ const messagesEndRef = useRef<HTMLDivElement>(null);
66
+ const inputRef = useRef<HTMLInputElement>(null);
67
+ const hasProcessedInitialQuery = useRef(false);
68
+
69
+ const scrollToBottom = useCallback(() => {
70
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
71
+ }, []);
72
+
73
+ useEffect(() => {
74
+ scrollToBottom();
75
+ }, [messages, isTyping, scrollToBottom]);
76
+
77
+ const getAIResponse = useCallback(
78
+ async (allMessages: Message[]) => {
79
+ setIsTyping(true);
80
+ try {
81
+ const reply = await fetchAIResponse(endpoint, apiKey, allMessages);
82
+ const response: Message = {
83
+ id: Date.now().toString(),
84
+ role: "assistant",
85
+ content: reply,
86
+ };
87
+ setMessages((prev) => [...prev, response]);
88
+ } catch (err) {
89
+ const errorMsg =
90
+ err instanceof Error ? err.message : "Unknown error";
91
+ const response: Message = {
92
+ id: Date.now().toString(),
93
+ role: "assistant",
94
+ content: `Sorry, I couldn't get a response. Error: ${errorMsg}`,
95
+ };
96
+ setMessages((prev) => [...prev, response]);
97
+ } finally {
98
+ setIsTyping(false);
99
+ }
100
+ },
101
+ [endpoint, apiKey],
102
+ );
103
+
104
+ // Handle initial query from explain mode
105
+ useEffect(() => {
106
+ if (open && initialQuery && !hasProcessedInitialQuery.current) {
107
+ hasProcessedInitialQuery.current = true;
108
+
109
+ const lines = initialQuery.split("\n");
110
+ const userMessage = lines[0];
111
+ const docsLine = lines.find((l) => l.startsWith("Docs: "));
112
+
113
+ const msgs: Message[] = [];
114
+
115
+ if (docsLine) {
116
+ msgs.push({
117
+ id: Date.now().toString() + "-ctx",
118
+ role: "context",
119
+ content: docsLine,
120
+ });
121
+ }
122
+
123
+ msgs.push({
124
+ id: Date.now().toString(),
125
+ role: "user",
126
+ content: userMessage,
127
+ });
128
+
129
+ setMessages(msgs);
130
+ getAIResponse(msgs);
131
+ }
132
+ }, [open, initialQuery, getAIResponse]);
133
+
134
+ // Reset when closed
135
+ useEffect(() => {
136
+ if (!open) {
137
+ hasProcessedInitialQuery.current = false;
138
+ setMessages([]);
139
+ setInput("");
140
+ setIsTyping(false);
141
+ }
142
+ }, [open]);
143
+
144
+ // Focus input when opened
145
+ useEffect(() => {
146
+ if (open) {
147
+ setTimeout(() => inputRef.current?.focus(), 200);
148
+ }
149
+ }, [open]);
150
+
151
+ const sendMessage = useCallback(() => {
152
+ const text = input.trim();
153
+ if (!text || isTyping) return;
154
+
155
+ const userMsg: Message = {
156
+ id: Date.now().toString(),
157
+ role: "user",
158
+ content: text,
159
+ };
160
+ const updatedMessages = [...messages, userMsg];
161
+ setMessages(updatedMessages);
162
+ setInput("");
163
+ getAIResponse(updatedMessages);
164
+ }, [input, isTyping, messages, getAIResponse]);
165
+
166
+ const handleKeyDown = (e: React.KeyboardEvent) => {
167
+ if (e.key === "Enter" && !e.shiftKey) {
168
+ e.preventDefault();
169
+ sendMessage();
170
+ }
171
+ };
172
+
173
+ if (!open) return null;
174
+
175
+ const active = !!input.trim() && !isTyping;
176
+
177
+ return (
178
+ <div style={S.chatPanel}>
179
+ {/* Header */}
180
+ <div style={S.chatHeader}>
181
+ <div style={S.chatHeaderLeft}>
182
+ <div style={S.chatHeaderIcon}>
183
+ <Sparkles size={15} style={{ color: "var(--nb-explain-accent)" }} />
184
+ </div>
185
+ <div>
186
+ <h3 style={S.chatHeaderTitle}>AI Assistant</h3>
187
+ <span style={S.chatHeaderSubtitle}>Ask anything</span>
188
+ </div>
189
+ </div>
190
+ <button
191
+ onClick={onClose}
192
+ style={S.chatCloseBtn}
193
+ onMouseEnter={(e) => {
194
+ e.currentTarget.style.color = "var(--nb-explain-text)";
195
+ e.currentTarget.style.background = "var(--nb-explain-bg-hover)";
196
+ }}
197
+ onMouseLeave={(e) => {
198
+ e.currentTarget.style.color = "var(--nb-explain-text-dim)";
199
+ e.currentTarget.style.background = "none";
200
+ }}
201
+ >
202
+ <X size={16} />
203
+ </button>
204
+ </div>
205
+
206
+ {/* Messages */}
207
+ <div style={S.messagesArea}>
208
+ {messages.length === 0 && !isTyping && (
209
+ <div style={S.emptyState}>
210
+ <MessageCircleQuestion
211
+ size={40}
212
+ style={{ color: "var(--nb-explain-text-dim)" }}
213
+ />
214
+ <div>
215
+ <p style={S.emptyStateTitle}>How can I help?</p>
216
+ <p style={S.emptyStateHint}>
217
+ Use the Explain button to click on any element, or ask a
218
+ question below.
219
+ </p>
220
+ </div>
221
+ </div>
222
+ )}
223
+
224
+ {messages.map((msg) =>
225
+ msg.role === "context" ? (
226
+ <div key={msg.id} style={S.contextBadge}>
227
+ <div style={S.contextBadgeInner}>
228
+ <Sparkles
229
+ size={10}
230
+ style={{ color: "var(--nb-explain-accent)", opacity: 0.6 }}
231
+ />
232
+ {msg.content}
233
+ </div>
234
+ </div>
235
+ ) : (
236
+ <div key={msg.id} style={S.messageRow(msg.role === "user")}>
237
+ {msg.role === "assistant" && (
238
+ <div style={S.messageAvatar(false)}>
239
+ <Bot size={13} style={{ color: "var(--nb-explain-accent)" }} />
240
+ </div>
241
+ )}
242
+ <div style={S.messageBubble(msg.role === "user")}>
243
+ {msg.content.split("\n").map((line, i) => (
244
+ <React.Fragment key={i}>
245
+ {line
246
+ .split(/(\*\*[^*]+\*\*)/)
247
+ .map((part, j) =>
248
+ part.startsWith("**") && part.endsWith("**") ? (
249
+ <strong key={j} style={S.messageBold}>
250
+ {part.slice(2, -2)}
251
+ </strong>
252
+ ) : (
253
+ <React.Fragment key={j}>{part}</React.Fragment>
254
+ ),
255
+ )}
256
+ {i < msg.content.split("\n").length - 1 && <br />}
257
+ </React.Fragment>
258
+ ))}
259
+ </div>
260
+ {msg.role === "user" && (
261
+ <div style={S.messageAvatar(true)}>
262
+ <User size={13} style={{ color: "var(--nb-explain-user-text)" }} />
263
+ </div>
264
+ )}
265
+ </div>
266
+ ),
267
+ )}
268
+
269
+ {isTyping && (
270
+ <div style={S.typingRow}>
271
+ <div style={S.messageAvatar(false)}>
272
+ <Bot size={13} style={{ color: "var(--nb-explain-accent)" }} />
273
+ </div>
274
+ <div style={S.typingBubble}>
275
+ <span className="nb-explain-dot-1" style={S.typingDot} />
276
+ <span className="nb-explain-dot-2" style={S.typingDot} />
277
+ <span className="nb-explain-dot-3" style={S.typingDot} />
278
+ </div>
279
+ </div>
280
+ )}
281
+
282
+ <div ref={messagesEndRef} />
283
+ </div>
284
+
285
+ {/* Input */}
286
+ <div style={S.inputArea}>
287
+ <div style={S.inputRow}>
288
+ <input
289
+ ref={inputRef}
290
+ type="text"
291
+ value={input}
292
+ onChange={(e) => setInput(e.target.value)}
293
+ onKeyDown={handleKeyDown}
294
+ placeholder="Ask a follow-up question..."
295
+ style={S.inputField}
296
+ />
297
+ <button
298
+ onClick={sendMessage}
299
+ disabled={!active}
300
+ style={S.sendBtn(active)}
301
+ >
302
+ <Send size={15} />
303
+ </button>
304
+ </div>
305
+ <p style={S.inputFooter}>AI-powered assistant</p>
306
+ </div>
307
+ </div>
308
+ );
309
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { MessageCircleQuestion, X } from "lucide-react";
4
+ import React from "react";
5
+ import * as S from "./styles";
6
+
7
+ type Props = {
8
+ isOpen: boolean;
9
+ onClick: () => void;
10
+ };
11
+
12
+ export default function AIFloatingButton({ isOpen, onClick }: Props) {
13
+ return (
14
+ <button
15
+ onClick={onClick}
16
+ data-nb-explain-ignore
17
+ style={S.fab(isOpen)}
18
+ onMouseEnter={(e) => {
19
+ e.currentTarget.style.transform = "scale(1.05)";
20
+ }}
21
+ onMouseLeave={(e) => {
22
+ e.currentTarget.style.transform = "scale(1)";
23
+ }}
24
+ onMouseDown={(e) => {
25
+ e.currentTarget.style.transform = "scale(0.95)";
26
+ }}
27
+ onMouseUp={(e) => {
28
+ e.currentTarget.style.transform = "scale(1.05)";
29
+ }}
30
+ >
31
+ {isOpen ? <X size={20} /> : <MessageCircleQuestion size={22} />}
32
+ </button>
33
+ );
34
+ }
@@ -0,0 +1,4 @@
1
+ export { default as AIAssistantProvider, useAIAssistant } from "./AIAssistantProvider";
2
+ export { default as AIChatBot } from "./AIChatBot";
3
+ export { default as AIFloatingButton } from "./AIFloatingButton";
4
+ export type { Message, ExplainContext } from "../types";
@@ -0,0 +1,353 @@
1
+ /**
2
+ * All styles for netbird-explain components.
3
+ * Uses inline styles + CSS custom properties so the package
4
+ * works without any external CSS framework.
5
+ *
6
+ * Consumers can override via CSS custom properties:
7
+ * --nb-explain-bg: panel background
8
+ * --nb-explain-bg-subtle: input/message background
9
+ * --nb-explain-border: border color
10
+ * --nb-explain-text: primary text
11
+ * --nb-explain-text-muted: secondary text
12
+ * --nb-explain-text-dim: tertiary/placeholder text
13
+ * --nb-explain-accent: accent color (yellow)
14
+ * --nb-explain-accent-hover: accent hover
15
+ * --nb-explain-user-bg: user message background
16
+ * --nb-explain-user-text: user message text
17
+ * --nb-explain-radius: border radius
18
+ * --nb-explain-font: font family
19
+ */
20
+
21
+ export const CSS_VARS = `
22
+ :root {
23
+ --nb-explain-bg: #0a0a0f;
24
+ --nb-explain-bg-subtle: rgba(255,255,255,0.06);
25
+ --nb-explain-bg-hover: rgba(255,255,255,0.08);
26
+ --nb-explain-border: rgba(255,255,255,0.1);
27
+ --nb-explain-text: #f0f0f5;
28
+ --nb-explain-text-muted: #9ca3af;
29
+ --nb-explain-text-dim: #6b7280;
30
+ --nb-explain-accent: #eab308;
31
+ --nb-explain-accent-hover: #facc15;
32
+ --nb-explain-accent-glow: rgba(234,179,8,0.15);
33
+ --nb-explain-user-bg: #4f46e5;
34
+ --nb-explain-user-text: #ffffff;
35
+ --nb-explain-user-glow: rgba(79,70,229,0.25);
36
+ --nb-explain-radius: 12px;
37
+ --nb-explain-radius-sm: 8px;
38
+ --nb-explain-radius-xs: 6px;
39
+ --nb-explain-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
40
+ --nb-explain-shadow: 0 25px 50px -12px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05);
41
+ --nb-explain-banner-bg: rgba(234,179,8,0.92);
42
+ --nb-explain-banner-text: #000000;
43
+ --nb-explain-error-text: #f87171;
44
+ }
45
+ `;
46
+
47
+ // --- Chat Panel ---
48
+
49
+ export const chatPanel: React.CSSProperties = {
50
+ position: "fixed",
51
+ bottom: 80,
52
+ right: 20,
53
+ zIndex: 9998,
54
+ width: 420,
55
+ height: 600,
56
+ display: "flex",
57
+ flexDirection: "column",
58
+ borderRadius: "var(--nb-explain-radius)",
59
+ border: "1px solid var(--nb-explain-border)",
60
+ background: "var(--nb-explain-bg)",
61
+ boxShadow: "var(--nb-explain-shadow)",
62
+ fontFamily: "var(--nb-explain-font)",
63
+ overflow: "hidden",
64
+ animation: "nb-explain-slide-up 0.2s ease-out",
65
+ };
66
+
67
+ export const chatHeader: React.CSSProperties = {
68
+ display: "flex",
69
+ alignItems: "center",
70
+ justifyContent: "space-between",
71
+ padding: "12px 16px",
72
+ borderBottom: "1px solid var(--nb-explain-border)",
73
+ };
74
+
75
+ export const chatHeaderLeft: React.CSSProperties = {
76
+ display: "flex",
77
+ alignItems: "center",
78
+ gap: 10,
79
+ };
80
+
81
+ export const chatHeaderIcon: React.CSSProperties = {
82
+ width: 28,
83
+ height: 28,
84
+ borderRadius: "var(--nb-explain-radius-sm)",
85
+ background: "var(--nb-explain-accent-glow)",
86
+ display: "flex",
87
+ alignItems: "center",
88
+ justifyContent: "center",
89
+ };
90
+
91
+ export const chatHeaderTitle: React.CSSProperties = {
92
+ fontSize: 14,
93
+ fontWeight: 600,
94
+ color: "var(--nb-explain-text)",
95
+ lineHeight: 1,
96
+ margin: 0,
97
+ };
98
+
99
+ export const chatHeaderSubtitle: React.CSSProperties = {
100
+ fontSize: 11,
101
+ color: "var(--nb-explain-text-dim)",
102
+ };
103
+
104
+ export const chatCloseBtn: React.CSSProperties = {
105
+ padding: 6,
106
+ borderRadius: "var(--nb-explain-radius-xs)",
107
+ color: "var(--nb-explain-text-dim)",
108
+ background: "none",
109
+ border: "none",
110
+ cursor: "pointer",
111
+ display: "flex",
112
+ alignItems: "center",
113
+ transition: "color 0.15s, background 0.15s",
114
+ };
115
+
116
+ // --- Messages ---
117
+
118
+ export const messagesArea: React.CSSProperties = {
119
+ flex: 1,
120
+ overflowY: "auto",
121
+ padding: "16px",
122
+ display: "flex",
123
+ flexDirection: "column",
124
+ gap: 16,
125
+ };
126
+
127
+ export const emptyState: React.CSSProperties = {
128
+ display: "flex",
129
+ flexDirection: "column",
130
+ alignItems: "center",
131
+ justifyContent: "center",
132
+ height: "100%",
133
+ textAlign: "center",
134
+ gap: 12,
135
+ opacity: 0.5,
136
+ };
137
+
138
+ export const emptyStateTitle: React.CSSProperties = {
139
+ fontSize: 14,
140
+ color: "var(--nb-explain-text-muted)",
141
+ fontWeight: 500,
142
+ margin: 0,
143
+ };
144
+
145
+ export const emptyStateHint: React.CSSProperties = {
146
+ fontSize: 12,
147
+ color: "var(--nb-explain-text-dim)",
148
+ marginTop: 4,
149
+ };
150
+
151
+ // Context badge
152
+ export const contextBadge: React.CSSProperties = {
153
+ display: "flex",
154
+ justifyContent: "center",
155
+ };
156
+
157
+ export const contextBadgeInner: React.CSSProperties = {
158
+ fontSize: 11,
159
+ color: "var(--nb-explain-text-dim)",
160
+ background: "var(--nb-explain-bg-subtle)",
161
+ borderRadius: 20,
162
+ padding: "4px 12px",
163
+ display: "flex",
164
+ alignItems: "center",
165
+ gap: 6,
166
+ };
167
+
168
+ // Message row
169
+ export const messageRow = (isUser: boolean): React.CSSProperties => ({
170
+ display: "flex",
171
+ gap: 10,
172
+ justifyContent: isUser ? "flex-end" : "flex-start",
173
+ });
174
+
175
+ export const messageAvatar = (isUser: boolean): React.CSSProperties => ({
176
+ width: 24,
177
+ height: 24,
178
+ borderRadius: "var(--nb-explain-radius-xs)",
179
+ background: isUser ? "var(--nb-explain-user-glow)" : "var(--nb-explain-accent-glow)",
180
+ display: "flex",
181
+ alignItems: "center",
182
+ justifyContent: "center",
183
+ flexShrink: 0,
184
+ marginTop: 2,
185
+ });
186
+
187
+ export const messageBubble = (isUser: boolean): React.CSSProperties => ({
188
+ maxWidth: "85%",
189
+ borderRadius: "var(--nb-explain-radius-sm)",
190
+ padding: "8px 12px",
191
+ fontSize: 14,
192
+ lineHeight: 1.6,
193
+ background: isUser ? "var(--nb-explain-user-bg)" : "var(--nb-explain-bg-subtle)",
194
+ color: isUser ? "var(--nb-explain-user-text)" : "var(--nb-explain-text)",
195
+ wordBreak: "break-word",
196
+ });
197
+
198
+ export const messageBold: React.CSSProperties = {
199
+ fontWeight: 600,
200
+ color: "var(--nb-explain-text)",
201
+ };
202
+
203
+ // Typing indicator
204
+ export const typingRow: React.CSSProperties = {
205
+ display: "flex",
206
+ gap: 10,
207
+ };
208
+
209
+ export const typingBubble: React.CSSProperties = {
210
+ background: "var(--nb-explain-bg-subtle)",
211
+ borderRadius: "var(--nb-explain-radius-sm)",
212
+ padding: "12px 16px",
213
+ display: "flex",
214
+ gap: 6,
215
+ };
216
+
217
+ export const typingDot: React.CSSProperties = {
218
+ width: 6,
219
+ height: 6,
220
+ borderRadius: "50%",
221
+ background: "var(--nb-explain-text-dim)",
222
+ };
223
+
224
+ // --- Input area ---
225
+
226
+ export const inputArea: React.CSSProperties = {
227
+ padding: "12px 16px",
228
+ borderTop: "1px solid var(--nb-explain-border)",
229
+ };
230
+
231
+ export const inputRow: React.CSSProperties = {
232
+ display: "flex",
233
+ alignItems: "center",
234
+ gap: 8,
235
+ background: "var(--nb-explain-bg-subtle)",
236
+ borderRadius: "var(--nb-explain-radius-sm)",
237
+ padding: "8px 12px",
238
+ };
239
+
240
+ export const inputField: React.CSSProperties = {
241
+ flex: 1,
242
+ background: "none",
243
+ border: "none",
244
+ outline: "none",
245
+ fontSize: 14,
246
+ color: "var(--nb-explain-text)",
247
+ fontFamily: "var(--nb-explain-font)",
248
+ };
249
+
250
+ export const sendBtn = (active: boolean): React.CSSProperties => ({
251
+ padding: 6,
252
+ borderRadius: "var(--nb-explain-radius-xs)",
253
+ background: "none",
254
+ border: "none",
255
+ cursor: active ? "pointer" : "not-allowed",
256
+ color: active ? "var(--nb-explain-accent)" : "var(--nb-explain-text-dim)",
257
+ display: "flex",
258
+ alignItems: "center",
259
+ transition: "color 0.15s",
260
+ opacity: active ? 1 : 0.5,
261
+ });
262
+
263
+ export const inputFooter: React.CSSProperties = {
264
+ fontSize: 10,
265
+ color: "var(--nb-explain-text-dim)",
266
+ textAlign: "center",
267
+ marginTop: 6,
268
+ };
269
+
270
+ // --- Floating button ---
271
+
272
+ export const fab = (isOpen: boolean): React.CSSProperties => ({
273
+ position: "fixed",
274
+ bottom: 20,
275
+ right: 20,
276
+ zIndex: 9997,
277
+ width: 48,
278
+ height: 48,
279
+ borderRadius: "50%",
280
+ display: "flex",
281
+ alignItems: "center",
282
+ justifyContent: "center",
283
+ border: "none",
284
+ cursor: "pointer",
285
+ transition: "transform 0.2s, box-shadow 0.2s",
286
+ boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
287
+ background: isOpen
288
+ ? "var(--nb-explain-bg-hover)"
289
+ : "linear-gradient(135deg, var(--nb-explain-accent), #f97316)",
290
+ color: isOpen ? "var(--nb-explain-text-muted)" : "#fff",
291
+ });
292
+
293
+ // --- Banner ---
294
+
295
+ export const banner: React.CSSProperties = {
296
+ position: "fixed",
297
+ top: 12,
298
+ left: "50%",
299
+ transform: "translateX(-50%)",
300
+ zIndex: 9996,
301
+ background: "var(--nb-explain-banner-bg)",
302
+ color: "var(--nb-explain-banner-text)",
303
+ fontSize: 14,
304
+ fontWeight: 500,
305
+ padding: "6px 16px",
306
+ borderRadius: 20,
307
+ boxShadow: "0 4px 20px rgba(234,179,8,0.3)",
308
+ display: "flex",
309
+ alignItems: "center",
310
+ gap: 8,
311
+ fontFamily: "var(--nb-explain-font)",
312
+ animation: "nb-explain-fade-in 0.2s ease-out",
313
+ };
314
+
315
+ export const bannerCancel: React.CSSProperties = {
316
+ marginLeft: 4,
317
+ color: "rgba(0,0,0,0.5)",
318
+ background: "none",
319
+ border: "none",
320
+ cursor: "pointer",
321
+ textDecoration: "underline",
322
+ fontSize: 12,
323
+ fontFamily: "var(--nb-explain-font)",
324
+ };
325
+
326
+ // --- Animations (injected as <style>) ---
327
+
328
+ export const ANIMATIONS = `
329
+ @keyframes nb-explain-slide-up {
330
+ from { opacity: 0; transform: translateY(8px); }
331
+ to { opacity: 1; transform: translateY(0); }
332
+ }
333
+ @keyframes nb-explain-fade-in {
334
+ from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
335
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
336
+ }
337
+ @keyframes nb-explain-bounce {
338
+ 0%, 80%, 100% { transform: translateY(0); }
339
+ 40% { transform: translateY(-4px); }
340
+ }
341
+ .nb-explain-dot-1 { animation: nb-explain-bounce 1.2s infinite; animation-delay: 0ms; }
342
+ .nb-explain-dot-2 { animation: nb-explain-bounce 1.2s infinite; animation-delay: 150ms; }
343
+ .nb-explain-dot-3 { animation: nb-explain-bounce 1.2s infinite; animation-delay: 300ms; }
344
+ `;
345
+
346
+ export const HIGHLIGHT_STYLES = `
347
+ [data-nb-explain-highlight] {
348
+ outline: 2px solid rgba(234, 179, 8, 0.7) !important;
349
+ border-radius: 6px;
350
+ cursor: help !important;
351
+ transition: outline 0.1s ease;
352
+ }
353
+ `;