@smooai/chat-widget 0.5.2 → 0.7.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/dist/chat-widget.global.js +1633 -71
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +381 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1478 -72
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/config.ts +31 -1
- package/src/conversation.ts +531 -18
- package/src/element.ts +455 -57
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/markdown.ts +368 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +110 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted widget state — the identity / consent / session-pointer client layer
|
|
3
|
+
* (ADR-048, SMOODEV-2129e).
|
|
4
|
+
*
|
|
5
|
+
* Built on Zustand's framework-agnostic `vanilla` store + the `persist`
|
|
6
|
+
* middleware (the user explicitly chose Zustand; it's ~1KB and has no React
|
|
7
|
+
* dependency, so it works inside the web component). The store is keyed per
|
|
8
|
+
* agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
|
|
9
|
+
* on the same origin don't clobber each other.
|
|
10
|
+
*
|
|
11
|
+
* ## What persists (and, deliberately, what does NOT)
|
|
12
|
+
*
|
|
13
|
+
* Only a **pointer** to the conversation plus the visitor's identity + marketing
|
|
14
|
+
* consent + verified email + browser fingerprint. The transcript is **never**
|
|
15
|
+
* persisted: the smooth-operator server is the source of truth and the widget
|
|
16
|
+
* re-hydrates history via `getMessages` on resume. This keeps localStorage small
|
|
17
|
+
* and avoids stale/divergent transcripts.
|
|
18
|
+
*
|
|
19
|
+
* `version` drives `persist.migrate` so future shape changes can upgrade old
|
|
20
|
+
* blobs in place instead of silently dropping them.
|
|
21
|
+
*/
|
|
22
|
+
import { createStore, type StoreApi } from 'zustand/vanilla';
|
|
23
|
+
import { persist, type PersistStorage } from 'zustand/middleware';
|
|
24
|
+
|
|
25
|
+
/** Current persisted-shape version. Bump when the shape below changes incompatibly. */
|
|
26
|
+
export const PERSIST_VERSION = 1 as const;
|
|
27
|
+
|
|
28
|
+
/** Marketing-consent record captured at the pre-chat form. */
|
|
29
|
+
export interface ConsentState {
|
|
30
|
+
emailOptIn: boolean;
|
|
31
|
+
smsOptIn: boolean;
|
|
32
|
+
/** Where the consent was captured. The widget always stamps `chat-widget-prechat`. */
|
|
33
|
+
consentSource?: string;
|
|
34
|
+
/** ISO 8601 timestamp stamped when the visitor ticked a consent box. */
|
|
35
|
+
consentAt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Visitor identity collected from config or the pre-chat form. */
|
|
39
|
+
export interface IdentityState {
|
|
40
|
+
name?: string;
|
|
41
|
+
email?: string;
|
|
42
|
+
phone?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The exact persisted shape (ADR-048 §d). Only the pointer + identity + consent
|
|
47
|
+
* persist — never the transcript.
|
|
48
|
+
*/
|
|
49
|
+
export interface PersistedWidgetState {
|
|
50
|
+
version: typeof PERSIST_VERSION;
|
|
51
|
+
/** Pointer to the active conversation session; cleared on ended/404. */
|
|
52
|
+
sessionId: string | null;
|
|
53
|
+
identity: IdentityState;
|
|
54
|
+
consent: ConsentState;
|
|
55
|
+
/**
|
|
56
|
+
* Email proven via OTP for the CURRENT session. Session-scoped: cleared on
|
|
57
|
+
* `clearSession()` so it can't leak across visitors on a shared browser, and
|
|
58
|
+
* only threaded into session metadata when {@link verifiedEmailSessionId}
|
|
59
|
+
* matches the live session (i.e. on resume of the verified session) — never
|
|
60
|
+
* auto-stamped onto a brand-new session.
|
|
61
|
+
*/
|
|
62
|
+
verifiedEmail: string | null;
|
|
63
|
+
/** The sessionId the {@link verifiedEmail} was proven against (binds the proof to one session). */
|
|
64
|
+
verifiedEmailSessionId: string | null;
|
|
65
|
+
/** Stable per-browser correlation token (see fingerprint.ts). */
|
|
66
|
+
browserFingerprint: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Store actions layered on top of the persisted state. */
|
|
70
|
+
export interface WidgetStoreActions {
|
|
71
|
+
setSessionId: (sessionId: string | null) => void;
|
|
72
|
+
/** Merge identity fields (undefined values don't clobber existing ones). */
|
|
73
|
+
mergeIdentity: (identity: IdentityState) => void;
|
|
74
|
+
/** Replace consent wholesale (the pre-chat form computes the full record). */
|
|
75
|
+
setConsent: (consent: ConsentState) => void;
|
|
76
|
+
/** Record an OTP-proven email bound to a specific session. */
|
|
77
|
+
setVerifiedEmail: (email: string | null, sessionId?: string | null) => void;
|
|
78
|
+
setBrowserFingerprint: (fp: string) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Clear the session pointer (and the session-scoped `verifiedEmail` proof) but
|
|
81
|
+
* KEEP identity / consent / fingerprint — used when a persisted session has
|
|
82
|
+
* ended or 404s so the next turn starts a fresh session for a known visitor.
|
|
83
|
+
* `verifiedEmail` is deliberately cleared here: it is a per-session OTP proof,
|
|
84
|
+
* not a durable identity, so it must NOT survive onto a new session (which on a
|
|
85
|
+
* shared browser could be a different visitor).
|
|
86
|
+
*/
|
|
87
|
+
clearSession: () => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type WidgetStore = PersistedWidgetState & WidgetStoreActions;
|
|
91
|
+
|
|
92
|
+
const EMPTY_CONSENT: ConsentState = { emailOptIn: false, smsOptIn: false };
|
|
93
|
+
|
|
94
|
+
function initialPersisted(): PersistedWidgetState {
|
|
95
|
+
return {
|
|
96
|
+
version: PERSIST_VERSION,
|
|
97
|
+
sessionId: null,
|
|
98
|
+
identity: {},
|
|
99
|
+
consent: { ...EMPTY_CONSENT },
|
|
100
|
+
verifiedEmail: null,
|
|
101
|
+
verifiedEmailSessionId: null,
|
|
102
|
+
browserFingerprint: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** localStorage key for an agent's persisted widget state. */
|
|
107
|
+
export function storageKey(agentId: string): string {
|
|
108
|
+
return `smoo-chat-widget:${agentId}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
|
|
113
|
+
* real localStorage is unavailable.
|
|
114
|
+
*
|
|
115
|
+
* IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
|
|
116
|
+
* zustand v5's `persist` treats a missing `storage` option by falling back to its
|
|
117
|
+
* OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
|
|
118
|
+
* localStorage the guard was trying to avoid (throwing again in privacy mode).
|
|
119
|
+
* Handing it this no-op storage keeps the store working purely in memory and
|
|
120
|
+
* guarantees the fallback can't touch real localStorage.
|
|
121
|
+
*/
|
|
122
|
+
function memoryStorage(): PersistStorage<PersistedWidgetState> {
|
|
123
|
+
const mem = new Map<string, string>();
|
|
124
|
+
return {
|
|
125
|
+
getItem: (name) => {
|
|
126
|
+
const raw = mem.get(name);
|
|
127
|
+
if (!raw) return null;
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(raw) as ReturnType<PersistStorage<PersistedWidgetState>['getItem']>;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
setItem: (name, value) => {
|
|
135
|
+
mem.set(name, JSON.stringify(value));
|
|
136
|
+
},
|
|
137
|
+
removeItem: (name) => {
|
|
138
|
+
mem.delete(name);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A `persist` storage adapter that tolerates the *absence* of localStorage
|
|
145
|
+
* (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
|
|
146
|
+
* unavailable the store still works in-memory; nothing is persisted, but the
|
|
147
|
+
* widget never throws on boot.
|
|
148
|
+
*/
|
|
149
|
+
function safeStorage(): PersistStorage<PersistedWidgetState> {
|
|
150
|
+
let ls: Storage | null = null;
|
|
151
|
+
try {
|
|
152
|
+
ls = typeof localStorage !== 'undefined' ? localStorage : null;
|
|
153
|
+
// Probe: some environments expose localStorage but throw on access.
|
|
154
|
+
if (ls) {
|
|
155
|
+
const probe = '__smoo_probe__';
|
|
156
|
+
ls.setItem(probe, '1');
|
|
157
|
+
ls.removeItem(probe);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
ls = null;
|
|
161
|
+
}
|
|
162
|
+
// Fall back to an explicit in-memory store — NEVER `undefined` (see memoryStorage).
|
|
163
|
+
if (!ls) return memoryStorage();
|
|
164
|
+
const storage = ls;
|
|
165
|
+
return {
|
|
166
|
+
getItem: (name) => {
|
|
167
|
+
try {
|
|
168
|
+
const raw = storage.getItem(name);
|
|
169
|
+
return raw ? (JSON.parse(raw) as ReturnType<PersistStorage<PersistedWidgetState>['getItem']>) : null;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
setItem: (name, value) => {
|
|
175
|
+
try {
|
|
176
|
+
storage.setItem(name, JSON.stringify(value));
|
|
177
|
+
} catch {
|
|
178
|
+
/* quota / privacy-mode write failures are non-fatal */
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
removeItem: (name) => {
|
|
182
|
+
try {
|
|
183
|
+
storage.removeItem(name);
|
|
184
|
+
} catch {
|
|
185
|
+
/* non-fatal */
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Migrate a persisted blob from an older `version` to the current shape. Today
|
|
193
|
+
* v1 is the only version, so this just backfills any missing fields onto an
|
|
194
|
+
* unknown old blob; future versions add `case` branches here.
|
|
195
|
+
*/
|
|
196
|
+
function migrate(persisted: unknown): PersistedWidgetState {
|
|
197
|
+
const base = initialPersisted();
|
|
198
|
+
if (!persisted || typeof persisted !== 'object') return base;
|
|
199
|
+
const p = persisted as Partial<PersistedWidgetState>;
|
|
200
|
+
return {
|
|
201
|
+
version: PERSIST_VERSION,
|
|
202
|
+
sessionId: typeof p.sessionId === 'string' ? p.sessionId : null,
|
|
203
|
+
identity: {
|
|
204
|
+
name: typeof p.identity?.name === 'string' ? p.identity.name : undefined,
|
|
205
|
+
email: typeof p.identity?.email === 'string' ? p.identity.email : undefined,
|
|
206
|
+
phone: typeof p.identity?.phone === 'string' ? p.identity.phone : undefined,
|
|
207
|
+
},
|
|
208
|
+
consent: {
|
|
209
|
+
emailOptIn: p.consent?.emailOptIn === true,
|
|
210
|
+
smsOptIn: p.consent?.smsOptIn === true,
|
|
211
|
+
consentSource: typeof p.consent?.consentSource === 'string' ? p.consent.consentSource : undefined,
|
|
212
|
+
consentAt: typeof p.consent?.consentAt === 'string' ? p.consent.consentAt : undefined,
|
|
213
|
+
},
|
|
214
|
+
verifiedEmail: typeof p.verifiedEmail === 'string' ? p.verifiedEmail : null,
|
|
215
|
+
verifiedEmailSessionId: typeof p.verifiedEmailSessionId === 'string' ? p.verifiedEmailSessionId : null,
|
|
216
|
+
browserFingerprint: typeof p.browserFingerprint === 'string' ? p.browserFingerprint : null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create the per-agent persisted Zustand store. The `partialize` step ensures
|
|
222
|
+
* only the persisted shape (never any future transient action/UI state) is
|
|
223
|
+
* written to localStorage.
|
|
224
|
+
*/
|
|
225
|
+
export function createWidgetStore(agentId: string): StoreApi<WidgetStore> {
|
|
226
|
+
return createStore<WidgetStore>()(
|
|
227
|
+
persist(
|
|
228
|
+
(set) => ({
|
|
229
|
+
...initialPersisted(),
|
|
230
|
+
setSessionId: (sessionId) => set({ sessionId }),
|
|
231
|
+
mergeIdentity: (identity) =>
|
|
232
|
+
set((state) => ({
|
|
233
|
+
identity: {
|
|
234
|
+
...state.identity,
|
|
235
|
+
...(identity.name !== undefined ? { name: identity.name } : {}),
|
|
236
|
+
...(identity.email !== undefined ? { email: identity.email } : {}),
|
|
237
|
+
...(identity.phone !== undefined ? { phone: identity.phone } : {}),
|
|
238
|
+
},
|
|
239
|
+
})),
|
|
240
|
+
setConsent: (consent) => set({ consent: { ...consent } }),
|
|
241
|
+
setVerifiedEmail: (verifiedEmail, sessionId) =>
|
|
242
|
+
set((state) => ({
|
|
243
|
+
verifiedEmail,
|
|
244
|
+
// Bind the proof to the session it was verified against. When the
|
|
245
|
+
// caller omits sessionId, fall back to the live pointer so the
|
|
246
|
+
// proof is never left unbound.
|
|
247
|
+
verifiedEmailSessionId: verifiedEmail === null ? null : (sessionId ?? state.sessionId),
|
|
248
|
+
})),
|
|
249
|
+
setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
|
|
250
|
+
// Drop the pointer AND the session-scoped OTP proof; keep durable identity.
|
|
251
|
+
clearSession: () => set({ sessionId: null, verifiedEmail: null, verifiedEmailSessionId: null }),
|
|
252
|
+
}),
|
|
253
|
+
{
|
|
254
|
+
name: storageKey(agentId),
|
|
255
|
+
version: PERSIST_VERSION,
|
|
256
|
+
storage: safeStorage(),
|
|
257
|
+
migrate,
|
|
258
|
+
// Persist ONLY the data shape — never the action functions.
|
|
259
|
+
partialize: (state): PersistedWidgetState => ({
|
|
260
|
+
version: PERSIST_VERSION,
|
|
261
|
+
sessionId: state.sessionId,
|
|
262
|
+
identity: state.identity,
|
|
263
|
+
consent: state.consent,
|
|
264
|
+
verifiedEmail: state.verifiedEmail,
|
|
265
|
+
verifiedEmailSessionId: state.verifiedEmailSessionId,
|
|
266
|
+
browserFingerprint: state.browserFingerprint,
|
|
267
|
+
}),
|
|
268
|
+
},
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
}
|
package/src/styles.ts
CHANGED
|
@@ -327,6 +327,50 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
|
|
|
327
327
|
}
|
|
328
328
|
@keyframes sac-blink { to { opacity: 0 } }
|
|
329
329
|
|
|
330
|
+
/* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
|
|
331
|
+
/* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
|
|
332
|
+
keep them legible inside the tight Aurora-Glass bubble + citation card. */
|
|
333
|
+
/* Block-level markdown drives its own spacing/wrapping, so opt out of the
|
|
334
|
+
bubble's pre-wrap (which would otherwise add stray blank lines). */
|
|
335
|
+
.bubble.md { white-space: normal; }
|
|
336
|
+
.md > :first-child { margin-top: 0; }
|
|
337
|
+
.md > :last-child { margin-bottom: 0; }
|
|
338
|
+
.md p { margin: 0 0 8px; }
|
|
339
|
+
.md ul, .md ol { margin: 6px 0 8px; padding-left: 20px; }
|
|
340
|
+
.md li { margin: 2px 0; }
|
|
341
|
+
.md li::marker { color: color-mix(in srgb, var(--sac-primary) 75%, transparent); }
|
|
342
|
+
.md a {
|
|
343
|
+
color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
|
|
344
|
+
text-decoration: underline;
|
|
345
|
+
text-underline-offset: 2px;
|
|
346
|
+
word-break: break-word;
|
|
347
|
+
}
|
|
348
|
+
.md a:hover { text-decoration: none; }
|
|
349
|
+
.md strong { font-weight: 700; }
|
|
350
|
+
.md em { font-style: italic; }
|
|
351
|
+
.md code {
|
|
352
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
353
|
+
font-size: .9em;
|
|
354
|
+
padding: 1px 5px;
|
|
355
|
+
border-radius: 5px;
|
|
356
|
+
background: color-mix(in srgb, var(--sac-text) 10%, transparent);
|
|
357
|
+
}
|
|
358
|
+
.md pre {
|
|
359
|
+
margin: 6px 0 8px;
|
|
360
|
+
padding: 9px 11px;
|
|
361
|
+
border-radius: 9px;
|
|
362
|
+
overflow-x: auto;
|
|
363
|
+
background: color-mix(in srgb, var(--sac-text) 9%, transparent);
|
|
364
|
+
border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
|
|
365
|
+
}
|
|
366
|
+
.md pre code { padding: 0; background: none; font-size: 12px; line-height: 1.45; }
|
|
367
|
+
.md blockquote {
|
|
368
|
+
margin: 6px 0;
|
|
369
|
+
padding: 2px 0 2px 11px;
|
|
370
|
+
border-left: 2px solid color-mix(in srgb, var(--sac-primary) 55%, transparent);
|
|
371
|
+
color: color-mix(in srgb, var(--sac-text) 78%, transparent);
|
|
372
|
+
}
|
|
373
|
+
|
|
330
374
|
/* Full-page: center the conversation in a readable column. */
|
|
331
375
|
.panel.fullpage .messages { padding: 26px 20px; }
|
|
332
376
|
.panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
|
|
@@ -486,6 +530,17 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
|
|
|
486
530
|
}
|
|
487
531
|
.pc-submit:hover { transform: translateY(-1px); }
|
|
488
532
|
.pc-submit:active { transform: scale(.98); }
|
|
533
|
+
.pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
|
|
534
|
+
.pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
|
|
535
|
+
.pc-consent input {
|
|
536
|
+
margin-top: 2px;
|
|
537
|
+
width: 16px;
|
|
538
|
+
height: 16px;
|
|
539
|
+
flex: 0 0 auto;
|
|
540
|
+
accent-color: var(--sac-primary);
|
|
541
|
+
cursor: pointer;
|
|
542
|
+
}
|
|
543
|
+
.pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); }
|
|
489
544
|
|
|
490
545
|
/* ─────────────────── Starter-prompt chips ─────────────────────────── */
|
|
491
546
|
.prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
|
|
@@ -564,6 +619,61 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
|
|
|
564
619
|
.int-row .int-btn { flex: 1; }
|
|
565
620
|
.int-row .int-input + .int-btn { flex: 0 0 auto; }
|
|
566
621
|
.int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
|
|
622
|
+
.int-card { position: relative; }
|
|
623
|
+
.int-close {
|
|
624
|
+
position: absolute;
|
|
625
|
+
top: 8px;
|
|
626
|
+
right: 9px;
|
|
627
|
+
border: none;
|
|
628
|
+
background: transparent;
|
|
629
|
+
color: color-mix(in srgb, var(--sac-text) 55%, transparent);
|
|
630
|
+
font-size: 18px;
|
|
631
|
+
line-height: 1;
|
|
632
|
+
cursor: pointer;
|
|
633
|
+
padding: 2px 4px;
|
|
634
|
+
border-radius: 6px;
|
|
635
|
+
transition: color .2s ease, background .2s ease;
|
|
636
|
+
}
|
|
637
|
+
.int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); }
|
|
638
|
+
|
|
639
|
+
/* ─────────────── Cross-device "Restore my chats" ──────────────────── */
|
|
640
|
+
.restore-link {
|
|
641
|
+
border: none;
|
|
642
|
+
background: none;
|
|
643
|
+
padding: 0;
|
|
644
|
+
font: inherit;
|
|
645
|
+
font-size: 10.5px;
|
|
646
|
+
letter-spacing: .04em;
|
|
647
|
+
color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text));
|
|
648
|
+
cursor: pointer;
|
|
649
|
+
text-decoration: underline;
|
|
650
|
+
text-underline-offset: 2px;
|
|
651
|
+
}
|
|
652
|
+
.restore-link:hover { color: var(--sac-primary); }
|
|
653
|
+
.restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; }
|
|
654
|
+
.restore-item {
|
|
655
|
+
display: flex;
|
|
656
|
+
align-items: center;
|
|
657
|
+
justify-content: space-between;
|
|
658
|
+
gap: 10px;
|
|
659
|
+
text-align: left;
|
|
660
|
+
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
661
|
+
background: var(--sac-bg);
|
|
662
|
+
color: var(--sac-text);
|
|
663
|
+
border-radius: 10px;
|
|
664
|
+
padding: 9px 11px;
|
|
665
|
+
font-family: inherit;
|
|
666
|
+
font-size: 12.5px;
|
|
667
|
+
cursor: pointer;
|
|
668
|
+
transition: border-color .2s ease, background .2s ease, transform .2s ease;
|
|
669
|
+
}
|
|
670
|
+
.restore-item:hover {
|
|
671
|
+
border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
|
|
672
|
+
background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg));
|
|
673
|
+
transform: translateY(-1px);
|
|
674
|
+
}
|
|
675
|
+
.restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
676
|
+
.restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
|
|
567
677
|
|
|
568
678
|
.hidden { display: none !important; }
|
|
569
679
|
|