polymorph-sdk 0.1.0 → 0.2.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.
package/package.json CHANGED
@@ -1,47 +1,47 @@
1
1
  {
2
2
  "name": "polymorph-sdk",
3
- "version": "0.1.0",
4
- "description": "Tool logging wrapper for Vercel AI SDK",
5
- "main": "dist/index.js",
6
- "module": "dist/index.mjs",
7
- "types": "dist/index.d.ts",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
8
  "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.mjs",
12
- "require": "./dist/index.js"
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": ["dist", "src", "README.md"],
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ }
13
22
  }
14
23
  },
15
- "files": [
16
- "dist"
17
- ],
18
24
  "scripts": {
19
- "build": "tsup src/index.ts --format cjs,esm --dts --clean",
20
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
21
- "lint": "tsc --noEmit",
22
- "test": "vitest run",
23
- "test:watch": "vitest"
25
+ "build": "vite build",
26
+ "dev": "vite build --watch",
27
+ "prepublishOnly": "bun run build"
24
28
  },
25
- "keywords": [
26
- "ai",
27
- "llm",
28
- "vercel",
29
- "ai-sdk",
30
- "polymorph",
31
- "tool-logging"
32
- ],
33
- "author": "",
34
- "license": "MIT",
35
29
  "dependencies": {
36
- "ai": "^6.0.0"
37
- },
38
- "devDependencies": {
39
- "@types/node": "^22.0.0",
40
- "tsup": "^8.0.0",
41
- "typescript": "^5.0.0",
42
- "vitest": "^2.0.0"
30
+ "@livekit/components-react": "^2.9.19",
31
+ "@mantine/core": "^8.3.7",
32
+ "@mantine/hooks": "^8.3.7",
33
+ "livekit-client": "^2.17.0"
43
34
  },
44
35
  "peerDependencies": {
45
- "ai": ">=4.0.0"
36
+ "react": "^18.0.0 || ^19.0.0",
37
+ "react-dom": "^18.0.0 || ^19.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/react": "^19.2.2",
41
+ "@types/react-dom": "^19.2.2",
42
+ "@vitejs/plugin-react": "^5.1.0",
43
+ "typescript": "~5.9.3",
44
+ "vite": "npm:rolldown-vite@7.2.2",
45
+ "vite-plugin-dts": "^4.5.4"
46
46
  }
47
47
  }
@@ -0,0 +1,26 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { ChatMessage } from "./types";
3
+ import styles from "./styles.module.css";
4
+
5
+ export function ChatThread({ messages, primaryColor }: { messages: ChatMessage[]; primaryColor: string }) {
6
+ const scrollRef = useRef<HTMLDivElement>(null);
7
+
8
+ useEffect(() => {
9
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
10
+ }, [messages]);
11
+
12
+ return (
13
+ <div ref={scrollRef} className={styles.chatThread}>
14
+ {messages.map((msg) => (
15
+ <div
16
+ key={msg.id}
17
+ className={`${styles.messageBubble} ${msg.role === "user" ? styles.userMessage : styles.agentMessage}`}
18
+ style={msg.role === "user" ? { backgroundColor: primaryColor } : undefined}
19
+ >
20
+ {msg.text}
21
+ {msg.source === "voice" && <div className={styles.voiceLabel}>voice</div>}
22
+ </div>
23
+ ))}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,41 @@
1
+ import { MantineProvider } from "@mantine/core";
2
+ import "@mantine/core/styles.css";
3
+ import { useState } from "react";
4
+ import styles from "./styles.module.css";
5
+ import { buildWidgetTheme } from "./theme";
6
+ import type { WidgetConfig } from "./types";
7
+ import { usePolymorphSession } from "./usePolymorphSession";
8
+ import { WidgetPanel } from "./WidgetPanel";
9
+
10
+ // Chat bubble icon for FAB
11
+ function ChatIcon() { return <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>; }
12
+
13
+ export function PolymorphWidget(props: WidgetConfig) {
14
+ const { position = "bottom-right", branding } = props;
15
+ const [open, setOpen] = useState(false);
16
+ const session = usePolymorphSession(props);
17
+ const primaryColor = branding?.primaryColor || "#171717";
18
+ const theme = buildWidgetTheme(primaryColor);
19
+
20
+ return (
21
+ <MantineProvider theme={theme} defaultColorScheme="light">
22
+ <div className={`${styles.widgetRoot} ${position === "bottom-left" ? styles.bottomLeft : styles.bottomRight}`}>
23
+ {open && (
24
+ <WidgetPanel
25
+ config={props}
26
+ session={session}
27
+ onClose={() => setOpen(false)}
28
+ />
29
+ )}
30
+ <button
31
+ type="button"
32
+ className={styles.fab}
33
+ style={{ backgroundColor: primaryColor }}
34
+ onClick={() => setOpen(prev => !prev)}
35
+ >
36
+ <ChatIcon />
37
+ </button>
38
+ </div>
39
+ </MantineProvider>
40
+ );
41
+ }
@@ -0,0 +1,65 @@
1
+ import styles from "./styles.module.css";
2
+ import type { SessionStatus } from "./types";
3
+
4
+ function VolumeIcon() {
5
+ return (
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
7
+ <path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
8
+ <path d="M16 9a5 5 0 0 1 0 6" />
9
+ <path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
10
+ </svg>
11
+ );
12
+ }
13
+
14
+ function VolumeOffIcon() {
15
+ return (
16
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
17
+ <path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
18
+ <line x1="22" x2="16" y1="9" y2="15" />
19
+ <line x1="16" x2="22" y1="9" y2="15" />
20
+ </svg>
21
+ );
22
+ }
23
+
24
+ interface VoiceOverlayProps {
25
+ isVoiceEnabled: boolean;
26
+ isMicActive: boolean;
27
+ status: SessionStatus;
28
+ onToggleVoice: () => void;
29
+ }
30
+
31
+ export function VoiceOverlay({
32
+ isVoiceEnabled,
33
+ isMicActive,
34
+ status,
35
+ onToggleVoice,
36
+ }: VoiceOverlayProps) {
37
+ if (status !== "connected") return null;
38
+
39
+ return (
40
+ <div className={styles.voiceOverlay}>
41
+ {isVoiceEnabled ? (
42
+ <>
43
+ <span className={styles.voiceBars}>
44
+ <span className={styles.voiceBar} />
45
+ <span className={styles.voiceBar} />
46
+ <span className={styles.voiceBar} />
47
+ </span>
48
+ <span className={styles.voiceLabel}>
49
+ {isMicActive ? "Voice active" : "Mic muted"}
50
+ </span>
51
+ </>
52
+ ) : (
53
+ <span className={styles.voiceLabel}>Chat only</span>
54
+ )}
55
+ <button
56
+ type="button"
57
+ className={`${styles.voiceToggle} ${isVoiceEnabled ? styles.voiceToggleActive : ""}`}
58
+ onClick={onToggleVoice}
59
+ title={isVoiceEnabled ? "Turn off voice" : "Turn on voice"}
60
+ >
61
+ {isVoiceEnabled ? <VolumeIcon /> : <VolumeOffIcon />}
62
+ </button>
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,194 @@
1
+ import { LiveKitRoom, RoomAudioRenderer, useRoomContext } from "@livekit/components-react";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import type { Room } from "livekit-client";
4
+ import { type DataPacket_Kind, type Participant, ParticipantKind, RoomEvent, type TranscriptionSegment } from "livekit-client";
5
+ import { ChatThread } from "./ChatThread";
6
+ import styles from "./styles.module.css";
7
+ import type { ChatMessage, SessionStatus, WidgetConfig } from "./types";
8
+ import { VoiceOverlay } from "./VoiceOverlay";
9
+
10
+ // Internal SVG icons (no @tabler dependency)
11
+ function MicIcon() { return <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>; }
12
+ function MicOffIcon() { return <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/></svg>; }
13
+ function SendIcon() { return <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>; }
14
+ function CloseIcon() { return <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>; }
15
+
16
+ function RoomHandler({
17
+ setRoom,
18
+ addMessage,
19
+ }: {
20
+ setRoom: (room: Room | null) => void;
21
+ addMessage: (role: "user" | "agent", text: string, source: "chat" | "voice") => void;
22
+ }) {
23
+ const room = useRoomContext();
24
+
25
+ useEffect(() => {
26
+ setRoom(room);
27
+ return () => { setRoom(null); };
28
+ }, [room, setRoom]);
29
+
30
+ useEffect(() => {
31
+ const handleDataReceived = (
32
+ payload: Uint8Array,
33
+ _participant?: Participant,
34
+ _kind?: DataPacket_Kind,
35
+ topic?: string,
36
+ ) => {
37
+ if (topic === "chat_message") {
38
+ try {
39
+ const data = JSON.parse(new TextDecoder().decode(payload));
40
+ if (data.type === "chat_response") {
41
+ addMessage("agent", data.text, "chat");
42
+ }
43
+ } catch { /* ignore malformed */ }
44
+ }
45
+ };
46
+
47
+ const handleTranscription = (
48
+ segments: TranscriptionSegment[],
49
+ participant?: Participant,
50
+ ) => {
51
+ const finalSegments = segments.filter(s => s.final);
52
+ if (finalSegments.length > 0) {
53
+ const text = finalSegments.map(s => s.text).join(" ");
54
+ const isAgent = participant?.kind === ParticipantKind.AGENT ||
55
+ participant?.identity?.includes("agent");
56
+ addMessage(isAgent ? "agent" : "user", text, "voice");
57
+ }
58
+ };
59
+
60
+ room.on(RoomEvent.DataReceived, handleDataReceived);
61
+ room.on(RoomEvent.TranscriptionReceived, handleTranscription);
62
+ return () => {
63
+ room.off(RoomEvent.DataReceived, handleDataReceived);
64
+ room.off(RoomEvent.TranscriptionReceived, handleTranscription);
65
+ };
66
+ }, [room, addMessage]);
67
+
68
+ return <RoomAudioRenderer />;
69
+ }
70
+
71
+ interface WidgetPanelProps {
72
+ config: WidgetConfig;
73
+ session: {
74
+ status: SessionStatus;
75
+ roomConnection: { token: string; livekitUrl: string } | null;
76
+ messages: ChatMessage[];
77
+ isVoiceEnabled: boolean;
78
+ isMicActive: boolean;
79
+ error: string | null;
80
+ connect: () => Promise<void>;
81
+ disconnect: () => void;
82
+ addMessage: (role: "user" | "agent", text: string, source: "chat" | "voice") => void;
83
+ sendMessage: (text: string) => void;
84
+ toggleMic: () => void;
85
+ toggleVoice: () => void;
86
+ setRoom: (room: Room | null) => void;
87
+ };
88
+ onClose: () => void;
89
+ }
90
+
91
+ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
92
+ const [inputValue, setInputValue] = useState("");
93
+ const primaryColor = config.branding?.primaryColor || "#171717";
94
+
95
+ const handleSend = useCallback(() => {
96
+ if (!inputValue.trim()) return;
97
+ session.sendMessage(inputValue);
98
+ setInputValue("");
99
+ }, [inputValue, session]);
100
+
101
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
102
+ if (e.key === "Enter" && !e.shiftKey) {
103
+ e.preventDefault();
104
+ handleSend();
105
+ }
106
+ }, [handleSend]);
107
+
108
+ return (
109
+ <div className={styles.panel}>
110
+ {/* Header */}
111
+ <div className={styles.header}>
112
+ <div>
113
+ <div style={{ fontWeight: 500, fontSize: 16 }}>{config.branding?.title || "Hi there"}</div>
114
+ {config.branding?.subtitle && (
115
+ <div style={{ fontSize: 13, color: "#737373", marginTop: 2 }}>{config.branding.subtitle}</div>
116
+ )}
117
+ </div>
118
+ <button type="button" className={styles.iconButton} onClick={onClose}><CloseIcon /></button>
119
+ </div>
120
+
121
+ {/* Chat Thread */}
122
+ <ChatThread messages={session.messages} primaryColor={primaryColor} />
123
+
124
+ {/* Voice Overlay */}
125
+ <VoiceOverlay
126
+ isVoiceEnabled={session.isVoiceEnabled}
127
+ isMicActive={session.isMicActive}
128
+ status={session.status}
129
+ onToggleVoice={session.toggleVoice}
130
+ />
131
+
132
+ {/* Error */}
133
+ {session.error && <div className={styles.errorText}>{session.error}</div>}
134
+
135
+ {/* Connect button or Input bar */}
136
+ {session.status === "idle" || session.status === "error" ? (
137
+ <button
138
+ type="button"
139
+ className={styles.connectButton}
140
+ style={{ backgroundColor: primaryColor }}
141
+ onClick={session.connect}
142
+ >
143
+ Start conversation
144
+ </button>
145
+ ) : session.status === "connecting" ? (
146
+ <button type="button" className={styles.connectButton} style={{ backgroundColor: primaryColor }} disabled>
147
+ Connecting...
148
+ </button>
149
+ ) : (
150
+ <div className={styles.inputBar}>
151
+ <input
152
+ className={styles.inputField}
153
+ placeholder="Type a message..."
154
+ value={inputValue}
155
+ onChange={(e) => setInputValue(e.target.value)}
156
+ onKeyDown={handleKeyDown}
157
+ />
158
+ <button
159
+ type="button"
160
+ className={styles.iconButton}
161
+ onClick={handleSend}
162
+ disabled={!inputValue.trim()}
163
+ >
164
+ <SendIcon />
165
+ </button>
166
+ {session.isVoiceEnabled && (
167
+ <button
168
+ type="button"
169
+ className={`${styles.iconButton} ${session.isMicActive ? styles.iconButtonActive : ""}`}
170
+ onClick={session.toggleMic}
171
+ >
172
+ {session.isMicActive ? <MicIcon /> : <MicOffIcon />}
173
+ </button>
174
+ )}
175
+ </div>
176
+ )}
177
+
178
+ {/* Hidden LiveKit Room */}
179
+ {session.roomConnection && (
180
+ <LiveKitRoom
181
+ token={session.roomConnection.token}
182
+ serverUrl={session.roomConnection.livekitUrl}
183
+ connect={true}
184
+ audio={session.isVoiceEnabled ? { echoCancellation: true, noiseSuppression: true, autoGainControl: true } : false}
185
+ video={false}
186
+ style={{ display: "none" }}
187
+ onDisconnected={session.disconnect}
188
+ >
189
+ <RoomHandler setRoom={session.setRoom} addMessage={session.addMessage} />
190
+ </LiveKitRoom>
191
+ )}
192
+ </div>
193
+ );
194
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "*.module.css" {
4
+ const classes: Record<string, string>;
5
+ export default classes;
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { PolymorphWidget } from "./PolymorphWidget";
2
+ export { usePolymorphSession } from "./usePolymorphSession";
3
+ export type { ChatMessage, SessionStatus, WidgetBranding, WidgetConfig } from "./types";
@@ -0,0 +1,240 @@
1
+ .widgetRoot {
2
+ position: fixed;
3
+ z-index: 9999;
4
+ display: flex;
5
+ flex-direction: column;
6
+ align-items: flex-end;
7
+ gap: 12px;
8
+ }
9
+
10
+ .bottomRight {
11
+ bottom: 24px;
12
+ right: 24px;
13
+ }
14
+
15
+ .bottomLeft {
16
+ bottom: 24px;
17
+ left: 24px;
18
+ align-items: flex-start;
19
+ }
20
+
21
+ .fab {
22
+ width: 56px;
23
+ height: 56px;
24
+ border-radius: 28px;
25
+ border: none;
26
+ cursor: pointer;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
31
+ transition: transform 150ms ease, box-shadow 150ms ease;
32
+ color: white;
33
+ }
34
+
35
+ .fab:hover {
36
+ transform: scale(1.05);
37
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
38
+ }
39
+
40
+ .panel {
41
+ width: 380px;
42
+ max-height: 600px;
43
+ display: flex;
44
+ flex-direction: column;
45
+ border-radius: 16px;
46
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
47
+ overflow: hidden;
48
+ background: white;
49
+ border: 1px solid #e5e5e5;
50
+ }
51
+
52
+ .header {
53
+ padding: 16px 16px 12px;
54
+ border-bottom: 1px solid #f0f0f0;
55
+ display: flex;
56
+ justify-content: space-between;
57
+ align-items: flex-start;
58
+ }
59
+
60
+ .chatThread {
61
+ flex: 1;
62
+ overflow-y: auto;
63
+ padding: 16px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 8px;
67
+ min-height: 200px;
68
+ max-height: 400px;
69
+ }
70
+
71
+ .messageBubble {
72
+ max-width: 80%;
73
+ padding: 8px 12px;
74
+ border-radius: 12px;
75
+ font-size: 14px;
76
+ line-height: 1.4;
77
+ word-wrap: break-word;
78
+ }
79
+
80
+ .agentMessage {
81
+ align-self: flex-start;
82
+ background: #f5f5f5;
83
+ color: #171717;
84
+ }
85
+
86
+ .userMessage {
87
+ align-self: flex-end;
88
+ color: white;
89
+ }
90
+
91
+ .voiceLabel {
92
+ font-size: 10px;
93
+ opacity: 0.6;
94
+ margin-top: 2px;
95
+ }
96
+
97
+ .voiceOverlay {
98
+ padding: 8px 16px;
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 8px;
102
+ font-size: 13px;
103
+ color: #737373;
104
+ border-top: 1px solid #f0f0f0;
105
+ }
106
+
107
+ .voiceBars {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 2px;
111
+ height: 16px;
112
+ }
113
+
114
+ .voiceBar {
115
+ width: 3px;
116
+ border-radius: 2px;
117
+ background: #22c55e;
118
+ animation: voiceBar 1.2s ease-in-out infinite;
119
+ }
120
+
121
+ .voiceBar:nth-child(1) { height: 6px; animation-delay: 0s; }
122
+ .voiceBar:nth-child(2) { height: 12px; animation-delay: 0.2s; }
123
+ .voiceBar:nth-child(3) { height: 8px; animation-delay: 0.4s; }
124
+
125
+ @keyframes voiceBar {
126
+ 0%, 100% { transform: scaleY(1); }
127
+ 50% { transform: scaleY(0.4); }
128
+ }
129
+
130
+ .voiceToggle {
131
+ margin-left: auto;
132
+ width: 28px;
133
+ height: 28px;
134
+ border-radius: 14px;
135
+ border: 1px solid #e5e5e5;
136
+ cursor: pointer;
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ background: #f5f5f5;
141
+ color: #737373;
142
+ transition: all 150ms ease;
143
+ }
144
+
145
+ .voiceToggle:hover {
146
+ background: #e5e5e5;
147
+ }
148
+
149
+ .voiceToggleActive {
150
+ background: #dcfce7;
151
+ border-color: #bbf7d0;
152
+ color: #16a34a;
153
+ }
154
+
155
+ .voiceToggleActive:hover {
156
+ background: #bbf7d0;
157
+ }
158
+
159
+ .inputBar {
160
+ padding: 12px 16px;
161
+ border-top: 1px solid #f0f0f0;
162
+ display: flex;
163
+ gap: 8px;
164
+ align-items: center;
165
+ }
166
+
167
+ .inputField {
168
+ flex: 1;
169
+ border: 1px solid #e5e5e5;
170
+ border-radius: 8px;
171
+ padding: 8px 12px;
172
+ font-size: 14px;
173
+ outline: none;
174
+ font-family: inherit;
175
+ }
176
+
177
+ .inputField:focus {
178
+ border-color: #a3a3a3;
179
+ }
180
+
181
+ .iconButton {
182
+ width: 36px;
183
+ height: 36px;
184
+ border-radius: 8px;
185
+ border: none;
186
+ cursor: pointer;
187
+ display: flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ background: transparent;
191
+ color: #737373;
192
+ transition: background 150ms ease, color 150ms ease;
193
+ }
194
+
195
+ .iconButton:hover {
196
+ background: #f5f5f5;
197
+ }
198
+
199
+ .iconButton:disabled {
200
+ opacity: 0.4;
201
+ cursor: default;
202
+ }
203
+
204
+ .iconButtonActive {
205
+ color: #22c55e;
206
+ }
207
+
208
+ .connectButton {
209
+ margin: 16px;
210
+ padding: 10px 20px;
211
+ border-radius: 8px;
212
+ border: none;
213
+ cursor: pointer;
214
+ font-size: 14px;
215
+ font-weight: 500;
216
+ color: white;
217
+ transition: opacity 150ms ease;
218
+ }
219
+
220
+ .connectButton:hover {
221
+ opacity: 0.9;
222
+ }
223
+
224
+ .connectButton:disabled {
225
+ opacity: 0.5;
226
+ cursor: default;
227
+ }
228
+
229
+ .statusBadge {
230
+ font-size: 11px;
231
+ padding: 2px 8px;
232
+ border-radius: 10px;
233
+ font-weight: 500;
234
+ }
235
+
236
+ .errorText {
237
+ color: #dc2626;
238
+ font-size: 13px;
239
+ padding: 0 16px;
240
+ }