@fiodos/web-core 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.
- package/LICENSE +77 -0
- package/dist/cjs/adapters/webNavigationAdapter.d.ts +31 -0
- package/dist/cjs/adapters/webNavigationAdapter.js +45 -0
- package/dist/cjs/adapters/webStorageAdapter.d.ts +13 -0
- package/dist/cjs/adapters/webStorageAdapter.js +57 -0
- package/dist/cjs/adapters/webVoiceAdapter.d.ts +16 -0
- package/dist/cjs/adapters/webVoiceAdapter.js +477 -0
- package/dist/cjs/api/backendClient.d.ts +31 -0
- package/dist/cjs/api/backendClient.js +182 -0
- package/dist/cjs/api/backendTelemetry.d.ts +23 -0
- package/dist/cjs/api/backendTelemetry.js +80 -0
- package/dist/cjs/api/clientBootstrap.d.ts +44 -0
- package/dist/cjs/api/clientBootstrap.js +68 -0
- package/dist/cjs/api/errors.d.ts +11 -0
- package/dist/cjs/api/errors.js +16 -0
- package/dist/cjs/bridge/createBridge.d.ts +13 -0
- package/dist/cjs/bridge/createBridge.js +15 -0
- package/dist/cjs/config/types.d.ts +63 -0
- package/dist/cjs/config/types.js +9 -0
- package/dist/cjs/context/screenContextStore.d.ts +26 -0
- package/dist/cjs/context/screenContextStore.js +25 -0
- package/dist/cjs/controller/AgentController.d.ts +115 -0
- package/dist/cjs/controller/AgentController.js +594 -0
- package/dist/cjs/core/turnEngine.d.ts +70 -0
- package/dist/cjs/core/turnEngine.js +96 -0
- package/dist/cjs/dropin/createFiodosAgent.d.ts +60 -0
- package/dist/cjs/dropin/createFiodosAgent.js +143 -0
- package/dist/cjs/dropin/createFyodosAgent.d.ts +60 -0
- package/dist/cjs/dropin/createFyodosAgent.js +143 -0
- package/dist/cjs/index.d.ts +45 -0
- package/dist/cjs/index.js +67 -0
- package/dist/cjs/orb/mountOrb.d.ts +24 -0
- package/dist/cjs/orb/mountOrb.js +694 -0
- package/dist/cjs/orb/orbView.d.ts +56 -0
- package/dist/cjs/orb/orbView.js +426 -0
- package/dist/cjs/orb/publishedConfig.d.ts +32 -0
- package/dist/cjs/orb/publishedConfig.js +70 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/speech/speechSession.d.ts +35 -0
- package/dist/cjs/speech/speechSession.js +60 -0
- package/dist/cjs/ui/messages.d.ts +36 -0
- package/dist/cjs/ui/messages.js +41 -0
- package/dist/esm/adapters/webNavigationAdapter.d.ts +31 -0
- package/dist/esm/adapters/webNavigationAdapter.js +41 -0
- package/dist/esm/adapters/webStorageAdapter.d.ts +13 -0
- package/dist/esm/adapters/webStorageAdapter.js +54 -0
- package/dist/esm/adapters/webVoiceAdapter.d.ts +16 -0
- package/dist/esm/adapters/webVoiceAdapter.js +474 -0
- package/dist/esm/api/backendClient.d.ts +31 -0
- package/dist/esm/api/backendClient.js +178 -0
- package/dist/esm/api/backendTelemetry.d.ts +23 -0
- package/dist/esm/api/backendTelemetry.js +77 -0
- package/dist/esm/api/clientBootstrap.d.ts +44 -0
- package/dist/esm/api/clientBootstrap.js +63 -0
- package/dist/esm/api/errors.d.ts +11 -0
- package/dist/esm/api/errors.js +11 -0
- package/dist/esm/bridge/createBridge.d.ts +13 -0
- package/dist/esm/bridge/createBridge.js +12 -0
- package/dist/esm/config/types.d.ts +63 -0
- package/dist/esm/config/types.js +6 -0
- package/dist/esm/context/screenContextStore.d.ts +26 -0
- package/dist/esm/context/screenContextStore.js +22 -0
- package/dist/esm/controller/AgentController.d.ts +115 -0
- package/dist/esm/controller/AgentController.js +590 -0
- package/dist/esm/core/turnEngine.d.ts +70 -0
- package/dist/esm/core/turnEngine.js +92 -0
- package/dist/esm/dropin/createFiodosAgent.d.ts +60 -0
- package/dist/esm/dropin/createFiodosAgent.js +140 -0
- package/dist/esm/dropin/createFyodosAgent.d.ts +60 -0
- package/dist/esm/dropin/createFyodosAgent.js +140 -0
- package/dist/esm/index.d.ts +45 -0
- package/dist/esm/index.js +38 -0
- package/dist/esm/orb/mountOrb.d.ts +24 -0
- package/dist/esm/orb/mountOrb.js +691 -0
- package/dist/esm/orb/orbView.d.ts +56 -0
- package/dist/esm/orb/orbView.js +421 -0
- package/dist/esm/orb/publishedConfig.d.ts +32 -0
- package/dist/esm/orb/publishedConfig.js +67 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/speech/speechSession.d.ts +35 -0
- package/dist/esm/speech/speechSession.js +57 -0
- package/dist/esm/ui/messages.d.ts +36 -0
- package/dist/esm/ui/messages.js +38 -0
- package/package.json +38 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla DOM orb — the framework-free floating assistant that Vue, Svelte and
|
|
3
|
+
* Angular mount through createFiodosAgent. It is UNIFIED with the reference
|
|
4
|
+
* (React Native QuickBite) and @fiodos/react:
|
|
5
|
+
*
|
|
6
|
+
* - VISUAL: the rich orb (outward double border + glow, solid fill, brand mark /
|
|
7
|
+
* logo, listening waveform, thinking spinner, speaking pulse) via `orbView`,
|
|
8
|
+
* reusing the SAME @fiodos/core math — not a basic circle.
|
|
9
|
+
* - CONFIG: reads the dashboard-published config (color/size/glow/logo/keyboard
|
|
10
|
+
* chip/screen position) in real time via `watchPublishedConfig`, exactly like
|
|
11
|
+
* the reference, instead of ignoring it.
|
|
12
|
+
* - TEXT: a text bubble that STAYS OPEN after sending and shows the agent's
|
|
13
|
+
* reply (the current exchange) with a busy state — fixing the bug where the
|
|
14
|
+
* bar vanished and nothing answered. Typed turns are silent (reply is shown,
|
|
15
|
+
* not spoken), matching the reference.
|
|
16
|
+
* - CONFIRMATIONS: the same security model (manifest decides; strict phrase /
|
|
17
|
+
* deliberate tap for payment-grade; loose "yes" never resolves strict).
|
|
18
|
+
*/
|
|
19
|
+
import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, resolveOrbLayerInsets, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
|
|
20
|
+
import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
|
|
21
|
+
import { watchPublishedConfig } from './publishedConfig.js';
|
|
22
|
+
const STYLE_ID = 'fyodos-orb-styles';
|
|
23
|
+
const CSS = `
|
|
24
|
+
.fyodos-orb-root{position:fixed;display:inline-flex;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;touch-action:none;}
|
|
25
|
+
.fyodos-orb-btn{appearance:none;border:none;background:transparent;padding:0;cursor:pointer;line-height:0;display:inline-flex;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-user-drag:none;}
|
|
26
|
+
.fyodos-orb-btn img{pointer-events:none;-webkit-user-drag:none;user-select:none;}
|
|
27
|
+
.fyodos-orb-btn:focus-visible{outline:2px solid #8ab6ff;outline-offset:4px;border-radius:50%;}
|
|
28
|
+
.fyodos-orb-anchor{position:relative;display:inline-flex;}
|
|
29
|
+
.fyodos-bubble{position:absolute;display:flex;flex-direction:column;width:min(320px,calc(100vw - 48px));max-height:340px;overflow:hidden;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,0.28);border:1px solid rgba(160,180,220,0.22);animation:fyodos-fade-in .14s ease-out;}
|
|
30
|
+
.fyodos-bubble-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;border:none;background:transparent;font-size:18px;line-height:18px;width:28px;height:28px;min-width:28px;min-height:28px;margin-top:-10px;margin-right:-10px;cursor:pointer;padding:0;opacity:.75;}
|
|
31
|
+
.fyodos-bubble-user-row{display:flex;flex-direction:row;align-items:flex-start;gap:6px;}
|
|
32
|
+
.fyodos-bubble-scroll{overflow-y:auto;padding:12px 14px 4px;display:flex;flex-direction:column;gap:6px;}
|
|
33
|
+
.fyodos-bubble-user{flex:1;min-width:0;font-size:13px;line-height:18px;opacity:.75;}
|
|
34
|
+
.fyodos-bubble-reply{font-size:15px;line-height:21px;white-space:pre-wrap;}
|
|
35
|
+
.fyodos-bubble-busy{display:flex;gap:5px;padding:8px 14px;align-items:center;}
|
|
36
|
+
.fyodos-bubble-busy span{width:6px;height:6px;border-radius:50%;opacity:.5;animation:fyodos-blink 1s infinite;}
|
|
37
|
+
.fyodos-bubble-busy span:nth-child(2){animation-delay:.18s;}
|
|
38
|
+
.fyodos-bubble-busy span:nth-child(3){animation-delay:.36s;}
|
|
39
|
+
.fyodos-bubble-row{display:flex;gap:8px;align-items:flex-end;padding:8px 10px;border-top:1px solid rgba(160,180,220,0.18);}
|
|
40
|
+
.fyodos-bubble-row input{flex:1;border:1px solid rgba(15,23,42,0.12);outline:none;border-radius:10px;padding:9px 11px;font-size:15px;}
|
|
41
|
+
.fyodos-bubble-row button{appearance:none;border:none;flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-width:32px;padding:0;font-size:17px;font-weight:700;line-height:1;color:#000;cursor:pointer;}
|
|
42
|
+
@keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
43
|
+
@keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
|
|
44
|
+
@keyframes fy-spin{to{transform:rotate(360deg)}}
|
|
45
|
+
@keyframes fy-opulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.06);opacity:.85}}
|
|
46
|
+
.fyodos-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(4,6,12,0.6);}
|
|
47
|
+
.fyodos-modal{width:min(420px,100%);border-radius:16px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.5);}
|
|
48
|
+
.fyodos-modal h3{margin:0 0 10px;font-size:17px;font-weight:600;}
|
|
49
|
+
.fyodos-modal p{margin:0 0 14px;font-size:14px;line-height:1.5;}
|
|
50
|
+
.fyodos-modal .fyodos-hint{font-size:12.5px;font-style:italic;opacity:.75;margin-bottom:16px;}
|
|
51
|
+
.fyodos-modal-actions{display:flex;gap:10px;justify-content:flex-end;}
|
|
52
|
+
.fyodos-btn-ghost{appearance:none;border:1px solid rgba(160,180,220,0.25);background:transparent;border-radius:999px;padding:10px 16px;font-size:13px;cursor:pointer;}
|
|
53
|
+
.fyodos-btn-primary{appearance:none;border:none;border-radius:999px;padding:10px 18px;font-size:13px;font-weight:600;color:#fff;cursor:pointer;}
|
|
54
|
+
`;
|
|
55
|
+
function ensureStyles() {
|
|
56
|
+
if (typeof document === 'undefined')
|
|
57
|
+
return;
|
|
58
|
+
if (document.getElementById(STYLE_ID))
|
|
59
|
+
return;
|
|
60
|
+
const el = document.createElement('style');
|
|
61
|
+
el.id = STYLE_ID;
|
|
62
|
+
el.textContent = CSS;
|
|
63
|
+
document.head.appendChild(el);
|
|
64
|
+
}
|
|
65
|
+
function cornerToPosition(corner) {
|
|
66
|
+
switch (corner) {
|
|
67
|
+
case 'bottom-left':
|
|
68
|
+
return { x: 0, y: 1 };
|
|
69
|
+
case 'top-right':
|
|
70
|
+
return { x: 1, y: 0 };
|
|
71
|
+
case 'top-left':
|
|
72
|
+
return { x: 0, y: 0 };
|
|
73
|
+
case 'bottom-right':
|
|
74
|
+
default:
|
|
75
|
+
return { x: 1, y: 1 };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const DEFAULT_MODAL_THEME = {
|
|
79
|
+
cardBackgroundColor: '#0b0f1a',
|
|
80
|
+
cardBorderColor: 'rgba(160,180,220,0.2)',
|
|
81
|
+
titleColor: '#e7ecf7',
|
|
82
|
+
bodyColor: '#b9c4dd',
|
|
83
|
+
};
|
|
84
|
+
export function mountOrb(controller, options = {}) {
|
|
85
|
+
if (typeof document === 'undefined') {
|
|
86
|
+
return { unmount() { } };
|
|
87
|
+
}
|
|
88
|
+
ensureStyles();
|
|
89
|
+
const container = options.container ?? document.body;
|
|
90
|
+
const zIndex = options.zIndex ?? 2147483000;
|
|
91
|
+
const enableTextInput = options.enableTextInput !== false;
|
|
92
|
+
const usePublishedPosition = options.usePublishedPosition !== false;
|
|
93
|
+
const ui = controller.ui;
|
|
94
|
+
// Best-effort upfront mic-permission detection: when the browser reports the
|
|
95
|
+
// microphone as DENIED, flag it so the very first orb tap opens the keyboard
|
|
96
|
+
// directly (unified "mic off → keyboard" behaviour). Falls back to the speech
|
|
97
|
+
// error path on browsers without the Permissions API (Firefox/Safari).
|
|
98
|
+
if (controller.config.voice.isRecognitionAvailable()) {
|
|
99
|
+
const perms = navigator.permissions;
|
|
100
|
+
perms
|
|
101
|
+
?.query?.({ name: 'microphone' })
|
|
102
|
+
.then((status) => {
|
|
103
|
+
controller.markVoiceBlocked(status.state === 'denied');
|
|
104
|
+
status.onchange = () => controller.markVoiceBlocked(status.state === 'denied');
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {
|
|
107
|
+
/* Permissions API unsupported — rely on the speech-error fallback */
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Subtle haptic feedback on every relevant orb interaction (silent no-op on
|
|
111
|
+
// browsers without the Vibration API — desktop, iOS Safari).
|
|
112
|
+
const haptics = createWebOrbHaptics();
|
|
113
|
+
// ── Effective appearance: defaults ← published ← explicit option overrides ──
|
|
114
|
+
let publishedAppearance = null;
|
|
115
|
+
let chipTheme = null;
|
|
116
|
+
let modalTheme;
|
|
117
|
+
let screenPosition = options.corner
|
|
118
|
+
? cornerToPosition(options.corner)
|
|
119
|
+
: { ...DEFAULT_ORB_SCREEN_POSITION };
|
|
120
|
+
function effectiveAppearance() {
|
|
121
|
+
const base = { ...DEFAULT_ORB_APPEARANCE, ...(publishedAppearance ?? {}) };
|
|
122
|
+
if (options.accent) {
|
|
123
|
+
base.accentColor = options.accent;
|
|
124
|
+
base.colorKey = 'custom';
|
|
125
|
+
base.backgroundColor = options.accent;
|
|
126
|
+
base.glowColor = options.accent;
|
|
127
|
+
}
|
|
128
|
+
if (options.size)
|
|
129
|
+
base.size = options.size;
|
|
130
|
+
return base;
|
|
131
|
+
}
|
|
132
|
+
// ── DOM scaffold ────────────────────────────────────────────────────────────
|
|
133
|
+
const root = document.createElement('div');
|
|
134
|
+
root.className = 'fyodos-orb-root';
|
|
135
|
+
root.style.zIndex = String(zIndex);
|
|
136
|
+
const anchor = document.createElement('div');
|
|
137
|
+
anchor.className = 'fyodos-orb-anchor';
|
|
138
|
+
const orbVisual = createOrbVisual(effectiveAppearance());
|
|
139
|
+
const btn = document.createElement('button');
|
|
140
|
+
btn.className = 'fyodos-orb-btn';
|
|
141
|
+
btn.type = 'button';
|
|
142
|
+
btn.setAttribute('aria-label', ui.orbLabel);
|
|
143
|
+
btn.appendChild(orbVisual.element);
|
|
144
|
+
anchor.appendChild(btn);
|
|
145
|
+
root.appendChild(anchor);
|
|
146
|
+
let chipEl = null;
|
|
147
|
+
container.appendChild(root);
|
|
148
|
+
// ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
|
|
149
|
+
// The orb ALWAYS starts at the dashboard-published position on every load.
|
|
150
|
+
// `draggedPosition` overrides it only for the current session while the user
|
|
151
|
+
// drags it around; reloading resets to the dashboard position by design.
|
|
152
|
+
let draggedPosition = null;
|
|
153
|
+
function orbDiameterPx() {
|
|
154
|
+
return effectiveAppearance().size ?? DEFAULT_ORB_APPEARANCE.size;
|
|
155
|
+
}
|
|
156
|
+
function effectivePosition() {
|
|
157
|
+
return draggedPosition ?? screenPosition;
|
|
158
|
+
}
|
|
159
|
+
function reposition() {
|
|
160
|
+
const size = orbDiameterPx();
|
|
161
|
+
const insets = resolveOrbLayerInsets({
|
|
162
|
+
viewportWidth: window.innerWidth,
|
|
163
|
+
viewportHeight: window.innerHeight,
|
|
164
|
+
orbDiameter: size,
|
|
165
|
+
position: effectivePosition(),
|
|
166
|
+
});
|
|
167
|
+
const left = Math.max(8, window.innerWidth - insets.right - size);
|
|
168
|
+
const top = Math.max(8, window.innerHeight - insets.bottom - size);
|
|
169
|
+
root.style.left = `${left}px`;
|
|
170
|
+
root.style.top = `${top}px`;
|
|
171
|
+
root.style.right = '';
|
|
172
|
+
root.style.bottom = '';
|
|
173
|
+
applyDeployment();
|
|
174
|
+
}
|
|
175
|
+
// ── Smart deployment: the chip expands toward the FREE side beside the orb ───
|
|
176
|
+
// The conversation bubble is NOT positioned here — it is always anchored to the
|
|
177
|
+
// bottom of the (visual) viewport, centred, above the keyboard (see
|
|
178
|
+
// positionBubble), independent of where the orb sits.
|
|
179
|
+
function applyDeployment() {
|
|
180
|
+
const dep = resolveOrbDeployment(effectivePosition());
|
|
181
|
+
// Keyboard chip (vertically centred beside the orb, on the free side)
|
|
182
|
+
if (chipEl) {
|
|
183
|
+
chipEl.style.position = 'absolute';
|
|
184
|
+
chipEl.style.top = '50%';
|
|
185
|
+
chipEl.style.transform = 'translateY(-50%)';
|
|
186
|
+
if (dep.horizontal === 'left') {
|
|
187
|
+
chipEl.style.right = 'calc(100% + 10px)';
|
|
188
|
+
chipEl.style.left = '';
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
chipEl.style.left = 'calc(100% + 10px)';
|
|
192
|
+
chipEl.style.right = '';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
window.addEventListener('resize', reposition);
|
|
197
|
+
// Keep the conversation bar above the keyboard as the visual viewport resizes
|
|
198
|
+
// (keyboard open/close) or scrolls on mobile browsers.
|
|
199
|
+
const visualViewport = window.visualViewport;
|
|
200
|
+
const onViewportChange = () => {
|
|
201
|
+
if (bubbleOpen)
|
|
202
|
+
positionBubble();
|
|
203
|
+
};
|
|
204
|
+
visualViewport?.addEventListener('resize', onViewportChange);
|
|
205
|
+
visualViewport?.addEventListener('scroll', onViewportChange);
|
|
206
|
+
// ── Drag (mouse / touch via Pointer Events). Tap vs drag by movement gate. ───
|
|
207
|
+
const DRAG_THRESHOLD = 6;
|
|
208
|
+
let dragging = false;
|
|
209
|
+
let suppressClick = false;
|
|
210
|
+
let dragStartX = 0;
|
|
211
|
+
let dragStartY = 0;
|
|
212
|
+
let originLeft = 0;
|
|
213
|
+
let originTop = 0;
|
|
214
|
+
function onPointerDown(e) {
|
|
215
|
+
if (e.button != null && e.button !== 0)
|
|
216
|
+
return;
|
|
217
|
+
const rect = root.getBoundingClientRect();
|
|
218
|
+
originLeft = rect.left;
|
|
219
|
+
originTop = rect.top;
|
|
220
|
+
dragStartX = e.clientX;
|
|
221
|
+
dragStartY = e.clientY;
|
|
222
|
+
dragging = false;
|
|
223
|
+
try {
|
|
224
|
+
btn.setPointerCapture(e.pointerId);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* capture best-effort */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function onPointerMove(e) {
|
|
231
|
+
if (!btn.hasPointerCapture?.(e.pointerId))
|
|
232
|
+
return;
|
|
233
|
+
const dx = e.clientX - dragStartX;
|
|
234
|
+
const dy = e.clientY - dragStartY;
|
|
235
|
+
if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD)
|
|
236
|
+
return;
|
|
237
|
+
dragging = true;
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
const size = orbDiameterPx();
|
|
240
|
+
const margin = 8;
|
|
241
|
+
const maxLeft = window.innerWidth - margin - size;
|
|
242
|
+
const maxTop = window.innerHeight - margin - size;
|
|
243
|
+
const left = Math.min(maxLeft, Math.max(margin, originLeft + dx));
|
|
244
|
+
const top = Math.min(maxTop, Math.max(margin, originTop + dy));
|
|
245
|
+
root.style.left = `${left}px`;
|
|
246
|
+
root.style.top = `${top}px`;
|
|
247
|
+
root.style.right = '';
|
|
248
|
+
root.style.bottom = '';
|
|
249
|
+
draggedPosition = viewportPixelsToOrbScreenPosition({
|
|
250
|
+
centerX: left + size / 2,
|
|
251
|
+
centerY: top + size / 2,
|
|
252
|
+
viewportWidth: window.innerWidth,
|
|
253
|
+
viewportHeight: window.innerHeight,
|
|
254
|
+
orbDiameter: size,
|
|
255
|
+
});
|
|
256
|
+
applyDeployment();
|
|
257
|
+
}
|
|
258
|
+
function onPointerUp(e) {
|
|
259
|
+
try {
|
|
260
|
+
btn.releasePointerCapture(e.pointerId);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
/* release best-effort */
|
|
264
|
+
}
|
|
265
|
+
if (dragging) {
|
|
266
|
+
suppressClick = true;
|
|
267
|
+
dragging = false;
|
|
268
|
+
// AssistiveTouch-style: the orb only rests on the left/right edge. Keep
|
|
269
|
+
// the vertical position the user dropped at, snap x to the nearest side,
|
|
270
|
+
// and glide there smoothly (no hard jump).
|
|
271
|
+
draggedPosition = snapOrbScreenPositionToSide(draggedPosition ?? effectivePosition());
|
|
272
|
+
root.style.transition = 'left 220ms cubic-bezier(0.22, 1, 0.36, 1), top 220ms cubic-bezier(0.22, 1, 0.36, 1)';
|
|
273
|
+
reposition();
|
|
274
|
+
window.setTimeout(() => {
|
|
275
|
+
root.style.transition = '';
|
|
276
|
+
}, 260);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
btn.draggable = false;
|
|
280
|
+
btn.addEventListener('dragstart', (e) => e.preventDefault());
|
|
281
|
+
btn.addEventListener('pointerdown', onPointerDown);
|
|
282
|
+
btn.addEventListener('pointermove', onPointerMove);
|
|
283
|
+
btn.addEventListener('pointerup', onPointerUp);
|
|
284
|
+
btn.addEventListener('pointercancel', onPointerUp);
|
|
285
|
+
// `lostpointercapture` fires when capture is released — including IMPLICITLY
|
|
286
|
+
// when the pointer crosses into the browser chrome (tabs/address bar at the
|
|
287
|
+
// top), where the page never receives `pointerup`. Without this the orb stayed
|
|
288
|
+
// stranded mid-screen after dragging it into the top region (the "snap fails in
|
|
289
|
+
// the upper half" bug). The `if (dragging)` guard inside onPointerUp dedupes
|
|
290
|
+
// the normal release path (which fires both pointerup and lostpointercapture).
|
|
291
|
+
btn.addEventListener('lostpointercapture', onPointerUp);
|
|
292
|
+
// ── Text bubble ─────────────────────────────────────────────────────────────
|
|
293
|
+
let bubbleOpen = false;
|
|
294
|
+
// When the mic is denied/unavailable, tapping the orb still puts it in the
|
|
295
|
+
// SAME "active/listening" visual state and reveals the keyboard chip — the
|
|
296
|
+
// user is never blocked, text is always an open avenue (Change 1).
|
|
297
|
+
let micFallbackActive = false;
|
|
298
|
+
const bubble = document.createElement('div');
|
|
299
|
+
bubble.className = 'fyodos-bubble';
|
|
300
|
+
bubble.style.display = 'none';
|
|
301
|
+
// The conversation bar is always anchored to the bottom-centre of the visual
|
|
302
|
+
// viewport, just above the on-screen keyboard, regardless of the orb position
|
|
303
|
+
// (so it is never off-screen at the top nor skewed to one side). On mobile the
|
|
304
|
+
// visualViewport shrinks when the keyboard opens; we track that to stay above it.
|
|
305
|
+
function viewportBottomInset() {
|
|
306
|
+
const vv = window.visualViewport;
|
|
307
|
+
if (!vv)
|
|
308
|
+
return 0;
|
|
309
|
+
return Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
|
310
|
+
}
|
|
311
|
+
function positionBubble() {
|
|
312
|
+
bubble.style.position = 'fixed';
|
|
313
|
+
bubble.style.left = '50%';
|
|
314
|
+
bubble.style.right = '';
|
|
315
|
+
bubble.style.top = '';
|
|
316
|
+
bubble.style.transform = 'translateX(-50%)';
|
|
317
|
+
bubble.style.bottom = `${viewportBottomInset() + 16}px`;
|
|
318
|
+
bubble.style.zIndex = String(zIndex + 5);
|
|
319
|
+
}
|
|
320
|
+
const exchangeScroll = document.createElement('div');
|
|
321
|
+
exchangeScroll.className = 'fyodos-bubble-scroll';
|
|
322
|
+
const userRow = document.createElement('div');
|
|
323
|
+
userRow.className = 'fyodos-bubble-user-row';
|
|
324
|
+
const userLine = document.createElement('div');
|
|
325
|
+
userLine.className = 'fyodos-bubble-user';
|
|
326
|
+
const closeBtn = document.createElement('button');
|
|
327
|
+
closeBtn.type = 'button';
|
|
328
|
+
closeBtn.className = 'fyodos-bubble-close';
|
|
329
|
+
closeBtn.textContent = '×';
|
|
330
|
+
closeBtn.setAttribute('aria-label', 'Close');
|
|
331
|
+
closeBtn.addEventListener('click', () => setBubble(false));
|
|
332
|
+
userRow.append(userLine, closeBtn);
|
|
333
|
+
const replyLine = document.createElement('div');
|
|
334
|
+
replyLine.className = 'fyodos-bubble-reply';
|
|
335
|
+
exchangeScroll.append(userRow, replyLine);
|
|
336
|
+
const busyRow = document.createElement('div');
|
|
337
|
+
busyRow.className = 'fyodos-bubble-busy';
|
|
338
|
+
busyRow.append(document.createElement('span'), document.createElement('span'), document.createElement('span'));
|
|
339
|
+
const inputRow = document.createElement('div');
|
|
340
|
+
inputRow.className = 'fyodos-bubble-row';
|
|
341
|
+
const textInput = document.createElement('input');
|
|
342
|
+
textInput.type = 'text';
|
|
343
|
+
textInput.placeholder = ui.textPlaceholder;
|
|
344
|
+
const sendBtn = document.createElement('button');
|
|
345
|
+
// Unified send affordance: an up-arrow glyph on the accent square (parity
|
|
346
|
+
// with the dashboard preview and every native SDK), not a text label.
|
|
347
|
+
sendBtn.textContent = '↑';
|
|
348
|
+
sendBtn.setAttribute('aria-label', ui.sendLabel);
|
|
349
|
+
inputRow.append(textInput, sendBtn);
|
|
350
|
+
bubble.append(exchangeScroll, busyRow, inputRow);
|
|
351
|
+
anchor.appendChild(bubble);
|
|
352
|
+
// Initial placement (after the bubble exists, so applyDeployment can lay it out).
|
|
353
|
+
reposition();
|
|
354
|
+
function applyBubbleTheme() {
|
|
355
|
+
const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
|
|
356
|
+
const accent = modalTheme?.primaryButtonColor ?? effectiveAppearance().accentColor;
|
|
357
|
+
bubble.style.background = t.cardBackgroundColor;
|
|
358
|
+
bubble.style.borderColor = t.cardBorderColor;
|
|
359
|
+
if (modalTheme?.borderRadius != null)
|
|
360
|
+
bubble.style.borderRadius = `${modalTheme.borderRadius}px`;
|
|
361
|
+
userLine.style.color = t.bodyColor;
|
|
362
|
+
replyLine.style.color = t.titleColor;
|
|
363
|
+
closeBtn.style.color = t.bodyColor;
|
|
364
|
+
textInput.style.background = '#fff';
|
|
365
|
+
textInput.style.color = '#0f172a';
|
|
366
|
+
sendBtn.style.background = accent;
|
|
367
|
+
sendBtn.style.borderRadius = `${modalTheme?.sendButtonRadius ?? 10}px`;
|
|
368
|
+
busyRow.querySelectorAll('span').forEach((s) => {
|
|
369
|
+
s.style.background = accent;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function setBubble(open) {
|
|
373
|
+
bubbleOpen = open;
|
|
374
|
+
bubble.style.display = open ? 'flex' : 'none';
|
|
375
|
+
if (open) {
|
|
376
|
+
applyBubbleTheme();
|
|
377
|
+
positionBubble();
|
|
378
|
+
textInput.focus();
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// Closing the panel returns the orb to idle (also clears mic fallback).
|
|
382
|
+
micFallbackActive = false;
|
|
383
|
+
}
|
|
384
|
+
syncChip(controller.getState());
|
|
385
|
+
}
|
|
386
|
+
function submitText() {
|
|
387
|
+
// While a turn is in flight the button is a STOP button; never start a new
|
|
388
|
+
// turn from Enter until the current one is cancelled.
|
|
389
|
+
if (controller.getState().isBusy)
|
|
390
|
+
return;
|
|
391
|
+
const value = textInput.value.trim();
|
|
392
|
+
if (!value)
|
|
393
|
+
return;
|
|
394
|
+
haptics.trigger('send');
|
|
395
|
+
textInput.value = '';
|
|
396
|
+
// Silent turn: the reply is SHOWN in the bubble (not spoken), and the bubble
|
|
397
|
+
// STAYS OPEN. This is the unified, reference-matching behaviour.
|
|
398
|
+
void controller.submitTextTurn(value);
|
|
399
|
+
}
|
|
400
|
+
function openTextPanel() {
|
|
401
|
+
controller.cancelAll();
|
|
402
|
+
setBubble(true);
|
|
403
|
+
}
|
|
404
|
+
// ── Keyboard chip (rebuilt when theme changes / visibility toggles) ──────────
|
|
405
|
+
// Appears beside the ACTIVE orb — whether listening for real (voice granted)
|
|
406
|
+
// or in the mic-denied fallback — and always deploys toward the free side.
|
|
407
|
+
function syncChip(state) {
|
|
408
|
+
const show = enableTextInput &&
|
|
409
|
+
!bubbleOpen &&
|
|
410
|
+
(state.phase === 'listening' || micFallbackActive);
|
|
411
|
+
if (!show) {
|
|
412
|
+
if (chipEl) {
|
|
413
|
+
chipEl.remove();
|
|
414
|
+
chipEl = null;
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (chipEl) {
|
|
419
|
+
applyDeployment();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const appearance = effectiveAppearance();
|
|
423
|
+
chipEl = buildKeyboardChip({
|
|
424
|
+
theme: chipTheme ?? {},
|
|
425
|
+
accentColor: appearance.accentColor,
|
|
426
|
+
backgroundColor: appearance.backgroundColor,
|
|
427
|
+
label: ui.keyboardChipLabel,
|
|
428
|
+
onClick: () => {
|
|
429
|
+
haptics.trigger('keyboardChip');
|
|
430
|
+
openTextPanel();
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
anchor.appendChild(chipEl);
|
|
434
|
+
applyDeployment();
|
|
435
|
+
}
|
|
436
|
+
// ── Overlays (confirmation + consent) ────────────────────────────────────────
|
|
437
|
+
let overlayEl = null;
|
|
438
|
+
function clearOverlay() {
|
|
439
|
+
if (overlayEl) {
|
|
440
|
+
overlayEl.remove();
|
|
441
|
+
overlayEl = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function themedModal() {
|
|
445
|
+
const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
|
|
446
|
+
const overlay = document.createElement('div');
|
|
447
|
+
overlay.className = 'fyodos-overlay';
|
|
448
|
+
overlay.style.zIndex = String(zIndex + 10);
|
|
449
|
+
const modal = document.createElement('div');
|
|
450
|
+
modal.className = 'fyodos-modal';
|
|
451
|
+
modal.style.background = t.cardBackgroundColor;
|
|
452
|
+
modal.style.border = `1px solid ${t.cardBorderColor}`;
|
|
453
|
+
modal.style.color = t.titleColor;
|
|
454
|
+
modal.setAttribute('role', 'dialog');
|
|
455
|
+
modal.setAttribute('aria-modal', 'true');
|
|
456
|
+
overlay.appendChild(modal);
|
|
457
|
+
return { overlay, modal };
|
|
458
|
+
}
|
|
459
|
+
function showConfirm(message, hint) {
|
|
460
|
+
clearOverlay();
|
|
461
|
+
const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
|
|
462
|
+
const accent = modalTheme?.primaryButtonColor ?? effectiveAppearance().accentColor;
|
|
463
|
+
const { overlay, modal } = themedModal();
|
|
464
|
+
const h = document.createElement('h3');
|
|
465
|
+
h.textContent = ui.confirmTitle;
|
|
466
|
+
const p = document.createElement('p');
|
|
467
|
+
p.style.color = t.bodyColor;
|
|
468
|
+
p.textContent = message;
|
|
469
|
+
modal.append(h, p);
|
|
470
|
+
if (hint) {
|
|
471
|
+
const hintEl = document.createElement('div');
|
|
472
|
+
hintEl.className = 'fyodos-hint';
|
|
473
|
+
hintEl.style.color = t.bodyColor;
|
|
474
|
+
hintEl.textContent = hint;
|
|
475
|
+
modal.append(hintEl);
|
|
476
|
+
}
|
|
477
|
+
const actions = document.createElement('div');
|
|
478
|
+
actions.className = 'fyodos-modal-actions';
|
|
479
|
+
const cancel = document.createElement('button');
|
|
480
|
+
cancel.className = 'fyodos-btn-ghost';
|
|
481
|
+
cancel.style.color = t.bodyColor;
|
|
482
|
+
cancel.textContent = ui.cancelLabel;
|
|
483
|
+
cancel.onclick = () => controller.cancelPendingAction();
|
|
484
|
+
const confirm = document.createElement('button');
|
|
485
|
+
confirm.className = 'fyodos-btn-primary';
|
|
486
|
+
confirm.style.background = accent;
|
|
487
|
+
confirm.textContent = ui.confirmLabel;
|
|
488
|
+
confirm.onclick = () => void controller.confirmPendingAction();
|
|
489
|
+
actions.append(cancel, confirm);
|
|
490
|
+
modal.append(actions);
|
|
491
|
+
overlay.onclick = (e) => {
|
|
492
|
+
if (e.target === overlay)
|
|
493
|
+
controller.cancelPendingAction();
|
|
494
|
+
};
|
|
495
|
+
container.appendChild(overlay);
|
|
496
|
+
overlayEl = overlay;
|
|
497
|
+
}
|
|
498
|
+
function showConsent() {
|
|
499
|
+
clearOverlay();
|
|
500
|
+
const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
|
|
501
|
+
const accent = modalTheme?.primaryButtonColor ?? effectiveAppearance().accentColor;
|
|
502
|
+
const { overlay, modal } = themedModal();
|
|
503
|
+
const h = document.createElement('h3');
|
|
504
|
+
h.textContent = ui.consentTitle;
|
|
505
|
+
const p = document.createElement('p');
|
|
506
|
+
p.style.color = t.bodyColor;
|
|
507
|
+
p.textContent = ui.consentBody;
|
|
508
|
+
const actions = document.createElement('div');
|
|
509
|
+
actions.className = 'fyodos-modal-actions';
|
|
510
|
+
const decline = document.createElement('button');
|
|
511
|
+
decline.className = 'fyodos-btn-ghost';
|
|
512
|
+
decline.style.color = t.bodyColor;
|
|
513
|
+
decline.textContent = ui.consentDecline;
|
|
514
|
+
decline.onclick = () => controller.declineConsent();
|
|
515
|
+
const accept = document.createElement('button');
|
|
516
|
+
accept.className = 'fyodos-btn-primary';
|
|
517
|
+
accept.style.background = accent;
|
|
518
|
+
accept.textContent = ui.consentAccept;
|
|
519
|
+
accept.onclick = () => void controller.acceptConsent();
|
|
520
|
+
actions.append(decline, accept);
|
|
521
|
+
modal.append(h, p, actions);
|
|
522
|
+
container.appendChild(overlay);
|
|
523
|
+
overlayEl = overlay;
|
|
524
|
+
}
|
|
525
|
+
// ── Event wiring ────────────────────────────────────────────────────────────
|
|
526
|
+
btn.onclick = () => {
|
|
527
|
+
// Swallow the click that ends a drag (so dragging never opens the orb).
|
|
528
|
+
if (suppressClick) {
|
|
529
|
+
suppressClick = false;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Unlock audio playback within this user gesture so the spoken reply is
|
|
533
|
+
// allowed/audible on mobile browsers (must happen before any await).
|
|
534
|
+
controller.primeAudio();
|
|
535
|
+
const state = controller.getState();
|
|
536
|
+
// Tapping the orb while it is processing/thinking CANCELS the in-flight turn
|
|
537
|
+
// (aborts the request, no reply is delivered) and returns the orb to idle.
|
|
538
|
+
if (state.showThinking) {
|
|
539
|
+
haptics.trigger('orbTap');
|
|
540
|
+
controller.cancelAll();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
haptics.trigger('orbTap');
|
|
544
|
+
if (bubbleOpen) {
|
|
545
|
+
setBubble(false);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
// `voiceAvailable` is dynamic: the Web Speech API may be present yet the mic
|
|
549
|
+
// denied/blocked, in which case we skip voice and go straight to the keyboard.
|
|
550
|
+
if (controller.voiceAvailable) {
|
|
551
|
+
micFallbackActive = false;
|
|
552
|
+
void controller.toggleListening();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// No microphone: skip the intermediate "active + chip" step and open the
|
|
556
|
+
// keyboard + conversation bar DIRECTLY — text is the only useful channel,
|
|
557
|
+
// so tapping the orb goes straight to it (Change 2, unified across SDKs).
|
|
558
|
+
if (enableTextInput) {
|
|
559
|
+
micFallbackActive = false;
|
|
560
|
+
openTextPanel();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
void controller.toggleListening();
|
|
564
|
+
};
|
|
565
|
+
sendBtn.onclick = () => {
|
|
566
|
+
// Send (arrow) while idle; Stop (square) while a turn is processing — tapping
|
|
567
|
+
// it cancels the in-flight turn so the user can type/send another message.
|
|
568
|
+
if (controller.getState().isBusy) {
|
|
569
|
+
haptics.trigger('orbTap');
|
|
570
|
+
controller.cancelAll();
|
|
571
|
+
textInput.focus();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
submitText();
|
|
575
|
+
};
|
|
576
|
+
textInput.onkeydown = (e) => {
|
|
577
|
+
if (e.key === 'Enter')
|
|
578
|
+
submitText();
|
|
579
|
+
if (e.key === 'Escape')
|
|
580
|
+
setBubble(false);
|
|
581
|
+
};
|
|
582
|
+
if (!enableTextInput) {
|
|
583
|
+
if (chipEl)
|
|
584
|
+
chipEl.style.display = 'none';
|
|
585
|
+
}
|
|
586
|
+
// ── Render from controller state ─────────────────────────────────────────────
|
|
587
|
+
let lastConfirm = null;
|
|
588
|
+
let lastConsent = false;
|
|
589
|
+
let lastVoiceBlocked = false;
|
|
590
|
+
// Track phase transitions to fire haptics: start/stop listening + response.
|
|
591
|
+
let lastPhase = null;
|
|
592
|
+
function render(state) {
|
|
593
|
+
// Mic just became unusable mid-attempt (no Permissions API): fall back to the
|
|
594
|
+
// keyboard so the user always has a working channel.
|
|
595
|
+
if (state.voiceBlocked && !lastVoiceBlocked) {
|
|
596
|
+
lastVoiceBlocked = true;
|
|
597
|
+
if (enableTextInput && !bubbleOpen && !state.isBusy) {
|
|
598
|
+
openTextPanel();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
else if (!state.voiceBlocked) {
|
|
603
|
+
lastVoiceBlocked = false;
|
|
604
|
+
}
|
|
605
|
+
if (state.phase !== lastPhase) {
|
|
606
|
+
if (state.phase === 'listening')
|
|
607
|
+
haptics.trigger('listenStart');
|
|
608
|
+
else if (lastPhase === 'listening')
|
|
609
|
+
haptics.trigger('listenStop');
|
|
610
|
+
// Answer delivered: leaving "thinking" (to speaking for voice, or back to
|
|
611
|
+
// idle for a silent text reply) means the orb produced its response.
|
|
612
|
+
if (lastPhase === 'thinking')
|
|
613
|
+
haptics.trigger('response');
|
|
614
|
+
lastPhase = state.phase;
|
|
615
|
+
}
|
|
616
|
+
const visual = state.showThinking
|
|
617
|
+
? 'thinking'
|
|
618
|
+
: state.showWaveform || micFallbackActive
|
|
619
|
+
? 'listening'
|
|
620
|
+
: state.phase === 'speaking'
|
|
621
|
+
? 'speaking'
|
|
622
|
+
: 'idle';
|
|
623
|
+
orbVisual.applyState(visual, state.volumeLevel);
|
|
624
|
+
// Text bubble: show the current exchange + busy state.
|
|
625
|
+
if (bubbleOpen) {
|
|
626
|
+
const ex = state.lastExchange;
|
|
627
|
+
exchangeScroll.style.display = 'flex';
|
|
628
|
+
userLine.textContent = ex?.userText ?? '';
|
|
629
|
+
replyLine.textContent = ex?.replyText ?? '';
|
|
630
|
+
replyLine.style.display = ex?.replyText ? 'block' : 'none';
|
|
631
|
+
busyRow.style.display = state.showThinking ? 'flex' : 'none';
|
|
632
|
+
// Send (arrow) ↔ Stop (square): while busy the action becomes "cancel".
|
|
633
|
+
const busy = state.isBusy;
|
|
634
|
+
sendBtn.textContent = busy ? '◼' : '↑';
|
|
635
|
+
sendBtn.setAttribute('aria-label', busy ? ui.cancelLabel : ui.sendLabel);
|
|
636
|
+
sendBtn.style.opacity = '1';
|
|
637
|
+
sendBtn.disabled = false;
|
|
638
|
+
}
|
|
639
|
+
syncChip(state);
|
|
640
|
+
if (state.confirmationMessage !== lastConfirm) {
|
|
641
|
+
lastConfirm = state.confirmationMessage;
|
|
642
|
+
if (state.confirmationMessage)
|
|
643
|
+
showConfirm(state.confirmationMessage, state.confirmationVoiceHint);
|
|
644
|
+
else if (!state.consentModalVisible)
|
|
645
|
+
clearOverlay();
|
|
646
|
+
}
|
|
647
|
+
if (state.consentModalVisible !== lastConsent) {
|
|
648
|
+
lastConsent = state.consentModalVisible;
|
|
649
|
+
if (state.consentModalVisible)
|
|
650
|
+
showConsent();
|
|
651
|
+
else if (!state.confirmationMessage)
|
|
652
|
+
clearOverlay();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const unsubscribe = controller.subscribe(render);
|
|
656
|
+
render(controller.getState());
|
|
657
|
+
// ── Live dashboard config ────────────────────────────────────────────────────
|
|
658
|
+
const stopWatch = watchPublishedConfig(controller, {
|
|
659
|
+
pollMs: options.appearancePollMs,
|
|
660
|
+
onChange: (config) => {
|
|
661
|
+
if (config) {
|
|
662
|
+
publishedAppearance = config.appearance;
|
|
663
|
+
chipTheme = config.keyboardChip;
|
|
664
|
+
modalTheme = config.modalTheme;
|
|
665
|
+
if (usePublishedPosition && config.screenPosition)
|
|
666
|
+
screenPosition = config.screenPosition;
|
|
667
|
+
}
|
|
668
|
+
orbVisual.applyAppearance(effectiveAppearance());
|
|
669
|
+
reposition();
|
|
670
|
+
if (chipEl) {
|
|
671
|
+
chipEl.remove();
|
|
672
|
+
chipEl = null;
|
|
673
|
+
}
|
|
674
|
+
syncChip(controller.getState());
|
|
675
|
+
if (bubbleOpen)
|
|
676
|
+
applyBubbleTheme();
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
return {
|
|
680
|
+
unmount() {
|
|
681
|
+
unsubscribe();
|
|
682
|
+
stopWatch();
|
|
683
|
+
window.removeEventListener('resize', reposition);
|
|
684
|
+
visualViewport?.removeEventListener('resize', onViewportChange);
|
|
685
|
+
visualViewport?.removeEventListener('scroll', onViewportChange);
|
|
686
|
+
clearOverlay();
|
|
687
|
+
chipEl?.remove();
|
|
688
|
+
root.remove();
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
}
|