@smooai/chat-widget 0.3.0 → 0.5.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/README.md +76 -19
- package/dist/chat-widget-loader.global.js +157 -0
- package/dist/chat-widget-loader.global.js.map +1 -0
- package/dist/chat-widget.global.js +162 -1
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +49 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +299 -2
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
- package/src/element.ts +111 -1
- package/src/index.ts +4 -0
- package/src/loader-core.ts +161 -0
- package/src/loader.ts +17 -0
- package/src/styles.ts +57 -0
package/src/element.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
|
|
18
18
|
import { needsUserInfo, resolveConfig } from './config.js';
|
|
19
|
-
import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController } from './conversation.js';
|
|
19
|
+
import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type Interrupt } from './conversation.js';
|
|
20
20
|
import { SMOOTH_LOGO_SVG } from './logo.js';
|
|
21
21
|
import { buildStyles } from './styles.js';
|
|
22
22
|
|
|
@@ -39,6 +39,10 @@ const ICON = {
|
|
|
39
39
|
send: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V6M12 6l-5.5 5.5M12 6l5.5 5.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
|
40
40
|
/** Sources disclosure caret. */
|
|
41
41
|
chev: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="m9 6 6 6-6 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
|
42
|
+
/** OTP interrupt — a padlock. */
|
|
43
|
+
lock: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="5" y="10.5" width="14" height="9.5" rx="2.2" stroke="currentColor" stroke-width="1.7"/><path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" stroke="currentColor" stroke-width="1.7"/></svg>`,
|
|
44
|
+
/** Tool-confirmation interrupt — a shield. */
|
|
45
|
+
shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
|
42
46
|
} as const;
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -78,6 +82,9 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
78
82
|
private hasSent = false;
|
|
79
83
|
/** Starter prompts shown as chips in the empty state. */
|
|
80
84
|
private examplePrompts: string[] = [];
|
|
85
|
+
/** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
|
|
86
|
+
private interrupt: Interrupt | null = null;
|
|
87
|
+
private interruptEl: HTMLElement | null = null;
|
|
81
88
|
|
|
82
89
|
// Cached DOM refs (populated in render()).
|
|
83
90
|
private panelEl: HTMLElement | null = null;
|
|
@@ -187,6 +194,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
187
194
|
this.renderStatus();
|
|
188
195
|
this.renderComposerState();
|
|
189
196
|
},
|
|
197
|
+
onInterrupt: (interrupt) => {
|
|
198
|
+
this.interrupt = interrupt;
|
|
199
|
+
this.renderInterrupt();
|
|
200
|
+
},
|
|
190
201
|
});
|
|
191
202
|
if (resolved.startOpen) this.open = true;
|
|
192
203
|
}
|
|
@@ -243,6 +254,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
243
254
|
</div>`;
|
|
244
255
|
const chatHtml = `
|
|
245
256
|
<div class="messages"></div>
|
|
257
|
+
<div class="interrupt hidden"></div>
|
|
246
258
|
<div class="composer-wrap">
|
|
247
259
|
<div class="composer">
|
|
248
260
|
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
@@ -274,6 +286,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
274
286
|
this.dotEl = container.querySelector('.dot');
|
|
275
287
|
this.inputEl = container.querySelector('textarea');
|
|
276
288
|
this.sendBtn = container.querySelector('.send');
|
|
289
|
+
this.interruptEl = container.querySelector('.interrupt');
|
|
277
290
|
|
|
278
291
|
this.launcherEl?.addEventListener('click', () => this.openChat());
|
|
279
292
|
container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
|
|
@@ -300,6 +313,103 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
300
313
|
if (!gating) this.renderMessages(resolved.greeting);
|
|
301
314
|
this.renderStatus();
|
|
302
315
|
this.renderComposerState();
|
|
316
|
+
this.renderInterrupt();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Render (or clear) the mid-turn interrupt overlay above the composer:
|
|
321
|
+
* an OTP code prompt or a tool-write confirmation. Server-supplied text is
|
|
322
|
+
* set via `textContent` (never innerHTML); only static icons use innerHTML.
|
|
323
|
+
*/
|
|
324
|
+
private renderInterrupt(): void {
|
|
325
|
+
const el = this.interruptEl;
|
|
326
|
+
if (!el) return;
|
|
327
|
+
el.replaceChildren();
|
|
328
|
+
const it = this.interrupt;
|
|
329
|
+
if (!it) {
|
|
330
|
+
el.classList.add('hidden');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
el.classList.remove('hidden');
|
|
334
|
+
|
|
335
|
+
const card = document.createElement('div');
|
|
336
|
+
card.className = 'int-card';
|
|
337
|
+
|
|
338
|
+
const head = document.createElement('div');
|
|
339
|
+
head.className = 'int-head';
|
|
340
|
+
const ico = document.createElement('span');
|
|
341
|
+
ico.className = 'int-ico';
|
|
342
|
+
ico.innerHTML = it.kind === 'otp' ? ICON.lock : ICON.shield; // static, trusted
|
|
343
|
+
const title = document.createElement('span');
|
|
344
|
+
title.className = 'int-title';
|
|
345
|
+
title.textContent = it.kind === 'otp' ? 'Verification required' : 'Confirm this action';
|
|
346
|
+
head.append(ico, title);
|
|
347
|
+
card.appendChild(head);
|
|
348
|
+
|
|
349
|
+
if (it.actionDescription) {
|
|
350
|
+
const desc = document.createElement('div');
|
|
351
|
+
desc.className = 'int-desc';
|
|
352
|
+
desc.textContent = it.actionDescription;
|
|
353
|
+
card.appendChild(desc);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (it.kind === 'otp') {
|
|
357
|
+
if (it.sent?.maskedDestination) {
|
|
358
|
+
const sent = document.createElement('div');
|
|
359
|
+
sent.className = 'int-sent';
|
|
360
|
+
sent.textContent = `Code sent to ${it.sent.maskedDestination}${it.sent.channel ? ` via ${it.sent.channel}` : ''}.`;
|
|
361
|
+
card.appendChild(sent);
|
|
362
|
+
}
|
|
363
|
+
const row = document.createElement('div');
|
|
364
|
+
row.className = 'int-row';
|
|
365
|
+
const input = document.createElement('input');
|
|
366
|
+
input.className = 'int-input';
|
|
367
|
+
input.type = 'text';
|
|
368
|
+
input.inputMode = 'numeric';
|
|
369
|
+
input.autocomplete = 'one-time-code';
|
|
370
|
+
input.placeholder = 'Enter code';
|
|
371
|
+
const submit = () => {
|
|
372
|
+
const code = input.value.trim();
|
|
373
|
+
if (code) this.controller?.verifyOtp(code);
|
|
374
|
+
};
|
|
375
|
+
input.addEventListener('keydown', (ev) => {
|
|
376
|
+
if (ev.key === 'Enter') {
|
|
377
|
+
ev.preventDefault();
|
|
378
|
+
submit();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
const verify = document.createElement('button');
|
|
382
|
+
verify.className = 'int-btn primary';
|
|
383
|
+
verify.type = 'button';
|
|
384
|
+
verify.textContent = 'Verify';
|
|
385
|
+
verify.addEventListener('click', submit);
|
|
386
|
+
row.append(input, verify);
|
|
387
|
+
card.appendChild(row);
|
|
388
|
+
if (it.error) {
|
|
389
|
+
const err = document.createElement('div');
|
|
390
|
+
err.className = 'int-error';
|
|
391
|
+
err.textContent = it.attemptsRemaining != null ? `${it.error} (${it.attemptsRemaining} left)` : it.error;
|
|
392
|
+
card.appendChild(err);
|
|
393
|
+
}
|
|
394
|
+
queueMicrotask(() => input.focus());
|
|
395
|
+
} else {
|
|
396
|
+
const row = document.createElement('div');
|
|
397
|
+
row.className = 'int-row';
|
|
398
|
+
const decline = document.createElement('button');
|
|
399
|
+
decline.className = 'int-btn';
|
|
400
|
+
decline.type = 'button';
|
|
401
|
+
decline.textContent = 'Decline';
|
|
402
|
+
decline.addEventListener('click', () => this.controller?.confirmTool(false));
|
|
403
|
+
const approve = document.createElement('button');
|
|
404
|
+
approve.className = 'int-btn primary';
|
|
405
|
+
approve.type = 'button';
|
|
406
|
+
approve.textContent = 'Approve';
|
|
407
|
+
approve.addEventListener('click', () => this.controller?.confirmTool(true));
|
|
408
|
+
row.append(decline, approve);
|
|
409
|
+
card.appendChild(row);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
el.appendChild(card);
|
|
303
413
|
}
|
|
304
414
|
|
|
305
415
|
/** Collect identity from the pre-chat form, then drop into the chat view. */
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,10 @@ export {
|
|
|
27
27
|
mountFullPageChat,
|
|
28
28
|
} from './element.js';
|
|
29
29
|
export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
|
|
30
|
+
// The deferred loader (also shipped as the `./loader` IIFE for `<script>` embeds):
|
|
31
|
+
// installs the idle/intent/fallback scheduler that lazily injects the widget
|
|
32
|
+
// module so it never competes with the host page's LCP/TBT.
|
|
33
|
+
export { initChatWidgetLoader } from './loader-core.js';
|
|
30
34
|
export {
|
|
31
35
|
ConversationController,
|
|
32
36
|
type ChatMessage,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny, dependency-free deferred loader for `@smooai/chat-widget`.
|
|
3
|
+
*
|
|
4
|
+
* The recommended embed: a host includes this *small* script eagerly, and it
|
|
5
|
+
* defers injecting the real (heavier) `chat-widget.global.js` module until the
|
|
6
|
+
* page is past its critical render — so the widget never competes with the host
|
|
7
|
+
* page's LCP/TBT. It triggers the real load on the **first** of:
|
|
8
|
+
*
|
|
9
|
+
* - `window` `load` → `requestIdleCallback` (idle time), or
|
|
10
|
+
* - user intent (`pointerdown` / `keydown` / `scroll`), or
|
|
11
|
+
* - an 8s fallback.
|
|
12
|
+
*
|
|
13
|
+
* After the module loads (which registers `<smooth-agent-chat>` and exposes
|
|
14
|
+
* `window.SmoothAgentChat`), it mounts the widget with the host's config.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately imports nothing from the widget itself — this file must stay a
|
|
17
|
+
* few hundred bytes so including it eagerly is free.
|
|
18
|
+
*
|
|
19
|
+
* ## Config
|
|
20
|
+
* Set a global before/with the loader (any `ChatWidgetConfig`, plus an optional
|
|
21
|
+
* `src` pointing at the module bundle):
|
|
22
|
+
*
|
|
23
|
+
* ```html
|
|
24
|
+
* <script>window.SmoothAgentChatConfig = { endpoint: 'wss://…/ws', agentId: '…' };</script>
|
|
25
|
+
* <script src="https://cdn/…/chat-widget-loader.global.js" async></script>
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Or, for the simplest installs, `data-*` attributes on the loader's own tag
|
|
29
|
+
* (`data-endpoint`, `data-agent-id`, `data-primary`, `data-mode`, `data-src`).
|
|
30
|
+
* If no config is found, the module is still loaded (registering the element) so
|
|
31
|
+
* a `<smooth-agent-chat …>` placed directly in markup upgrades itself.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Must match `ELEMENT_TAG` in `element.ts`; inlined to avoid importing it. */
|
|
35
|
+
const ELEMENT_TAG = 'smooth-agent-chat';
|
|
36
|
+
/** Default module filename, resolved as a sibling of the loader script. */
|
|
37
|
+
const DEFAULT_MODULE = 'chat-widget.global.js';
|
|
38
|
+
|
|
39
|
+
type MountFn = (config: Record<string, unknown>, target?: HTMLElement) => unknown;
|
|
40
|
+
|
|
41
|
+
interface LoaderWindow extends Window {
|
|
42
|
+
SmoothAgentChat?: { mount: MountFn };
|
|
43
|
+
SmoothAgentChatConfig?: Record<string, unknown> & { src?: string };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the module URL: explicit `src` (config or `data-src`) wins, else a
|
|
48
|
+
* sibling of the loader script, else the bare default filename.
|
|
49
|
+
*/
|
|
50
|
+
function resolveModuleSrc(win: LoaderWindow, script: HTMLScriptElement | null): string {
|
|
51
|
+
const fromConfig = win.SmoothAgentChatConfig?.src;
|
|
52
|
+
if (fromConfig) return fromConfig;
|
|
53
|
+
const fromAttr = script?.getAttribute('data-src');
|
|
54
|
+
if (fromAttr) return fromAttr;
|
|
55
|
+
const selfSrc = script?.src;
|
|
56
|
+
if (selfSrc) return selfSrc.replace(/[^/]*$/, DEFAULT_MODULE);
|
|
57
|
+
return DEFAULT_MODULE;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The widget mount config: the `SmoothAgentChatConfig` global (minus the
|
|
62
|
+
* loader-only `src`), or `data-*` attributes. `undefined` ⇒ no programmatic
|
|
63
|
+
* mount (a markup element will upgrade itself once the module registers it).
|
|
64
|
+
*/
|
|
65
|
+
function resolveMountConfig(win: LoaderWindow, script: HTMLScriptElement | null): Record<string, unknown> | undefined {
|
|
66
|
+
if (win.SmoothAgentChatConfig) {
|
|
67
|
+
const { src: _src, ...config } = win.SmoothAgentChatConfig;
|
|
68
|
+
// Only `src` (no real fields) ⇒ no programmatic mount; a markup element
|
|
69
|
+
// upgrades itself once the module registers it.
|
|
70
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
71
|
+
}
|
|
72
|
+
if (!script) return undefined;
|
|
73
|
+
const endpoint = script.getAttribute('data-endpoint');
|
|
74
|
+
const agentId = script.getAttribute('data-agent-id');
|
|
75
|
+
if (!endpoint && !agentId) return undefined;
|
|
76
|
+
const config: Record<string, unknown> = {};
|
|
77
|
+
if (endpoint) config.endpoint = endpoint;
|
|
78
|
+
if (agentId) config.agentId = agentId;
|
|
79
|
+
const primary = script.getAttribute('data-primary');
|
|
80
|
+
if (primary) config.theme = { primary };
|
|
81
|
+
const mode = script.getAttribute('data-mode');
|
|
82
|
+
if (mode) config.mode = mode;
|
|
83
|
+
return config;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Install the deferred loader. Idempotent per call; the IIFE entry
|
|
88
|
+
* (`loader.ts`) invokes it once on script execution.
|
|
89
|
+
*/
|
|
90
|
+
export function initChatWidgetLoader(): void {
|
|
91
|
+
const win = window as LoaderWindow;
|
|
92
|
+
const script = (document.currentScript as HTMLScriptElement | null) ?? null;
|
|
93
|
+
const moduleSrc = resolveModuleSrc(win, script);
|
|
94
|
+
|
|
95
|
+
let loaded = false;
|
|
96
|
+
let idleId: number | undefined;
|
|
97
|
+
let fallbackId: number | undefined;
|
|
98
|
+
|
|
99
|
+
function mountWhenReady(): void {
|
|
100
|
+
const config = resolveMountConfig(win, script);
|
|
101
|
+
if (!config) return; // markup element upgrades itself; nothing to mount
|
|
102
|
+
const apply = () => {
|
|
103
|
+
try {
|
|
104
|
+
win.SmoothAgentChat?.mount(config);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('[chat-widget loader] mount failed', error);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
if (win.SmoothAgentChat) {
|
|
110
|
+
apply();
|
|
111
|
+
} else if (window.customElements?.whenDefined) {
|
|
112
|
+
window.customElements.whenDefined(ELEMENT_TAG).then(apply, apply);
|
|
113
|
+
} else {
|
|
114
|
+
apply();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function teardown(): void {
|
|
119
|
+
if (idleId !== undefined) {
|
|
120
|
+
// The id is either an idle handle or (fallback path) a timeout id;
|
|
121
|
+
// both cancels are no-ops on the wrong kind, so calling both is safe.
|
|
122
|
+
window.cancelIdleCallback?.(idleId);
|
|
123
|
+
window.clearTimeout(idleId);
|
|
124
|
+
}
|
|
125
|
+
if (fallbackId !== undefined) window.clearTimeout(fallbackId);
|
|
126
|
+
window.removeEventListener('pointerdown', load);
|
|
127
|
+
window.removeEventListener('keydown', load);
|
|
128
|
+
window.removeEventListener('scroll', load);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function load(): void {
|
|
132
|
+
if (loaded) return;
|
|
133
|
+
loaded = true;
|
|
134
|
+
teardown();
|
|
135
|
+
const tag = document.createElement('script');
|
|
136
|
+
tag.src = moduleSrc;
|
|
137
|
+
tag.onload = mountWhenReady;
|
|
138
|
+
tag.onerror = () => console.error('[chat-widget loader] failed to load', moduleSrc);
|
|
139
|
+
document.head.appendChild(tag);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function schedule(): void {
|
|
143
|
+
if (window.requestIdleCallback) {
|
|
144
|
+
idleId = window.requestIdleCallback(() => load(), { timeout: 5000 });
|
|
145
|
+
} else {
|
|
146
|
+
idleId = window.setTimeout(load, 2500) as unknown as number;
|
|
147
|
+
}
|
|
148
|
+
fallbackId = window.setTimeout(load, 8000) as unknown as number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// User intent loads immediately (they're about to want the widget).
|
|
152
|
+
window.addEventListener('pointerdown', load, { once: true, passive: true });
|
|
153
|
+
window.addEventListener('keydown', load, { once: true });
|
|
154
|
+
window.addEventListener('scroll', load, { once: true, passive: true });
|
|
155
|
+
|
|
156
|
+
if (document.readyState === 'complete') {
|
|
157
|
+
schedule();
|
|
158
|
+
} else {
|
|
159
|
+
window.addEventListener('load', schedule, { once: true });
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone IIFE entry for the **deferred loader** — built to
|
|
3
|
+
* `dist/chat-widget-loader.global.js`.
|
|
4
|
+
*
|
|
5
|
+
* This is the recommended embed: include it eagerly (it's tiny) and it lazily
|
|
6
|
+
* injects the real `chat-widget.global.js` module only once the host page is
|
|
7
|
+
* past its critical render (idle / user intent / 8s fallback), so the widget
|
|
8
|
+
* never competes with the host's LCP/TBT. See {@link initChatWidgetLoader}.
|
|
9
|
+
*
|
|
10
|
+
* ```html
|
|
11
|
+
* <script>window.SmoothAgentChatConfig = { endpoint: 'wss://…/ws', agentId: '…' };</script>
|
|
12
|
+
* <script src="https://cdn/…/chat-widget-loader.global.js" async></script>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import { initChatWidgetLoader } from './loader-core.js';
|
|
16
|
+
|
|
17
|
+
initChatWidgetLoader();
|
package/src/styles.ts
CHANGED
|
@@ -508,6 +508,63 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
|
|
|
508
508
|
transform: translateY(-1px);
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
/* ─────────────── OTP / tool-confirmation interrupt ────────────────── */
|
|
512
|
+
.interrupt { padding: 0 14px; }
|
|
513
|
+
.int-card {
|
|
514
|
+
border: 1px solid color-mix(in srgb, var(--sac-primary) 35%, var(--sac-border));
|
|
515
|
+
background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-surface-2));
|
|
516
|
+
border-radius: 14px;
|
|
517
|
+
padding: 12px 13px;
|
|
518
|
+
animation: sac-msg-in .3s var(--sac-ease) both;
|
|
519
|
+
}
|
|
520
|
+
.int-head { display: flex; align-items: center; gap: 8px; }
|
|
521
|
+
.int-ico { display: flex; color: var(--sac-primary); }
|
|
522
|
+
.int-ico svg { width: 17px; height: 17px; }
|
|
523
|
+
.int-title { font-size: 13.5px; font-weight: 650; }
|
|
524
|
+
.int-desc { margin-top: 5px; font-size: 12.5px; line-height: 1.45; color: color-mix(in srgb, var(--sac-text) 80%, transparent); }
|
|
525
|
+
.int-sent { margin-top: 6px; font-size: 11.5px; color: color-mix(in srgb, var(--sac-text) 60%, transparent); }
|
|
526
|
+
.int-row { display: flex; gap: 8px; margin-top: 10px; }
|
|
527
|
+
.int-input {
|
|
528
|
+
flex: 1;
|
|
529
|
+
min-width: 0;
|
|
530
|
+
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
531
|
+
background: var(--sac-bg);
|
|
532
|
+
color: var(--sac-text);
|
|
533
|
+
border-radius: 10px;
|
|
534
|
+
padding: 9px 11px;
|
|
535
|
+
font-family: inherit;
|
|
536
|
+
font-size: 14px;
|
|
537
|
+
letter-spacing: .14em;
|
|
538
|
+
outline: none;
|
|
539
|
+
transition: border-color .2s ease, box-shadow .2s ease;
|
|
540
|
+
}
|
|
541
|
+
.int-input:focus {
|
|
542
|
+
border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
|
|
543
|
+
box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
|
|
544
|
+
}
|
|
545
|
+
.int-btn {
|
|
546
|
+
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
547
|
+
background: var(--sac-surface-2);
|
|
548
|
+
color: var(--sac-text);
|
|
549
|
+
border-radius: 10px;
|
|
550
|
+
padding: 9px 14px;
|
|
551
|
+
font-family: inherit;
|
|
552
|
+
font-size: 13px;
|
|
553
|
+
font-weight: 600;
|
|
554
|
+
cursor: pointer;
|
|
555
|
+
transition: transform .2s var(--sac-ease), background .2s ease, border-color .2s ease;
|
|
556
|
+
}
|
|
557
|
+
.int-btn:hover { transform: translateY(-1px); }
|
|
558
|
+
.int-btn.primary {
|
|
559
|
+
border: none;
|
|
560
|
+
background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
|
|
561
|
+
color: var(--sac-primary-text);
|
|
562
|
+
box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent);
|
|
563
|
+
}
|
|
564
|
+
.int-row .int-btn { flex: 1; }
|
|
565
|
+
.int-row .int-input + .int-btn { flex: 0 0 auto; }
|
|
566
|
+
.int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
|
|
567
|
+
|
|
511
568
|
.hidden { display: none !important; }
|
|
512
569
|
|
|
513
570
|
@media (prefers-reduced-motion: reduce) {
|