@fiodos/web-core 0.1.3 → 0.1.5

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.
@@ -2,6 +2,43 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createWebVoiceAdapter = createWebVoiceAdapter;
4
4
  const core_1 = require("@fiodos/core");
5
+ // ── Device TTS (SpeechSynthesis) helpers ────────────────────────────────────
6
+ // Chrome loads voices asynchronously: calling speak() before getVoices() is
7
+ // populated produces NO sound. Resolve the voice list once (event or timeout).
8
+ function loadVoicesOnce(synth) {
9
+ const ready = synth.getVoices();
10
+ if (ready.length)
11
+ return Promise.resolve(ready);
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const finish = () => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ resolve(synth.getVoices());
19
+ };
20
+ try {
21
+ synth.addEventListener('voiceschanged', finish, { once: true });
22
+ }
23
+ catch {
24
+ /* older engines: the timeout below is the only path */
25
+ }
26
+ // Some engines never emit `voiceschanged` — never block the reply forever.
27
+ setTimeout(finish, 600);
28
+ });
29
+ }
30
+ /** Best voice for a BCP-47 locale: exact → region-agnostic → language → default. */
31
+ function pickVoiceForLocale(voices, locale) {
32
+ if (!voices.length)
33
+ return undefined;
34
+ const lc = locale.toLowerCase();
35
+ const lang = lc.split('-')[0] ?? lc;
36
+ return (voices.find((v) => v.lang?.toLowerCase() === lc) ??
37
+ voices.find((v) => v.lang?.toLowerCase().startsWith(`${lang}-`)) ??
38
+ voices.find((v) => v.lang?.toLowerCase().startsWith(lang)) ??
39
+ voices.find((v) => v.default) ??
40
+ voices[0]);
41
+ }
5
42
  function getRecognitionCtor() {
6
43
  if (typeof window === 'undefined')
7
44
  return null;
@@ -33,6 +70,9 @@ function createWebVoiceAdapter(options = {}) {
33
70
  let aborted = false;
34
71
  let settled = false;
35
72
  let audioEl = null;
73
+ // Strong reference to the in-flight utterance: Chrome garbage-collects it
74
+ // otherwise, cutting device speech off mid-sentence (or producing silence).
75
+ let ttsUtterance = null;
36
76
  // ── Wake-word state (separate recognition instance; the browser allows only
37
77
  // one active session, so push-to-talk stops wake first). Desktop-only:
38
78
  // foreground tab while the page is visible — there is no passive background
@@ -412,13 +452,33 @@ function createWebVoiceAdapter(options = {}) {
412
452
  return;
413
453
  if (typeof window === 'undefined' || !window.speechSynthesis)
414
454
  return;
455
+ const synth = window.speechSynthesis;
456
+ // Voices load lazily on Chrome — speaking before they exist is silent.
457
+ const voices = await loadVoicesOnce(synth);
415
458
  return new Promise((resolve) => {
416
459
  try {
460
+ // Clear any utterance the engine wedged on (a known Chrome failure
461
+ // mode) and unpause before queuing the reply.
462
+ if (synth.speaking || synth.pending)
463
+ synth.cancel();
464
+ if (synth.paused)
465
+ synth.resume();
417
466
  const utter = new SpeechSynthesisUtterance(text);
418
467
  utter.lang = ttsOptions.locale;
419
- utter.onend = () => resolve();
420
- utter.onerror = () => resolve();
421
- window.speechSynthesis.speak(utter);
468
+ const voice = pickVoiceForLocale(voices, ttsOptions.locale);
469
+ if (voice)
470
+ utter.voice = voice;
471
+ ttsUtterance = utter;
472
+ const finish = () => {
473
+ utter.onend = null;
474
+ utter.onerror = null;
475
+ if (ttsUtterance === utter)
476
+ ttsUtterance = null;
477
+ resolve();
478
+ };
479
+ utter.onend = finish;
480
+ utter.onerror = finish;
481
+ synth.speak(utter);
422
482
  }
423
483
  catch {
424
484
  resolve();
@@ -74,6 +74,7 @@ export declare class AgentController {
74
74
  private abort;
75
75
  private watchdog;
76
76
  private confirmWindow;
77
+ private speakTimer;
77
78
  private pendingConsentIntent;
78
79
  private readonly listeners;
79
80
  private unsubscribeSpeech;
@@ -95,7 +96,14 @@ export declare class AgentController {
95
96
  private setPhase;
96
97
  private clearWatchdog;
97
98
  private clearConfirmWindow;
99
+ private clearSpeakTimer;
98
100
  private speakReply;
101
+ /**
102
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
103
+ * completion (a stalled engine, a never-firing onend), stop it and continue
104
+ * after a generous, length-based cap so the orb returns to idle on its own.
105
+ */
106
+ private raceSpeakingWatchdog;
99
107
  private beginConfirmationVoiceWindow;
100
108
  confirmPendingAction(): Promise<void>;
101
109
  cancelPendingAction(): void;
@@ -51,6 +51,9 @@ class AgentController {
51
51
  this.abort = null;
52
52
  this.watchdog = null;
53
53
  this.confirmWindow = null;
54
+ // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
55
+ // never reports completion (e.g. a stalled SpeechSynthesis engine).
56
+ this.speakTimer = null;
54
57
  this.pendingConsentIntent = null;
55
58
  this.listeners = new Set();
56
59
  this.unsubscribeSpeech = null;
@@ -179,6 +182,12 @@ class AgentController {
179
182
  }
180
183
  this.confirmationVoiceActive = false;
181
184
  }
185
+ clearSpeakTimer() {
186
+ if (this.speakTimer) {
187
+ clearTimeout(this.speakTimer);
188
+ this.speakTimer = null;
189
+ }
190
+ }
182
191
  // ── Speech playback (ONE consistent voice) ─────────────────────────────────
183
192
  async speakReply(text, audioBase64) {
184
193
  if (this.silentTurn)
@@ -189,23 +198,54 @@ class AgentController {
189
198
  try {
190
199
  let audio = audioBase64;
191
200
  if (!audio && text) {
192
- audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
193
- }
194
- if (audio) {
195
- await this.config.voice.playAudioBase64Mp3(audio);
196
- }
197
- else if (text) {
198
- await this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale });
201
+ // A server-TTS failure must NOT skip the device-voice fallback below.
202
+ try {
203
+ audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
204
+ }
205
+ catch {
206
+ audio = null;
207
+ }
199
208
  }
209
+ const playback = audio
210
+ ? this.config.voice.playAudioBase64Mp3(audio)
211
+ : text
212
+ ? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
213
+ : Promise.resolve();
214
+ await this.raceSpeakingWatchdog(playback, text);
200
215
  }
201
216
  catch {
202
217
  /* playback best-effort */
203
218
  }
204
219
  finally {
220
+ this.clearSpeakTimer();
205
221
  if (this.phase === 'speaking')
206
222
  this.setPhase('idle');
207
223
  }
208
224
  }
225
+ /**
226
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
227
+ * completion (a stalled engine, a never-firing onend), stop it and continue
228
+ * after a generous, length-based cap so the orb returns to idle on its own.
229
+ */
230
+ raceSpeakingWatchdog(playback, text) {
231
+ const capMs = Math.min(60000, 5000 + text.length * 90);
232
+ return new Promise((resolve) => {
233
+ let settled = false;
234
+ const finish = () => {
235
+ if (settled)
236
+ return;
237
+ settled = true;
238
+ this.clearSpeakTimer();
239
+ resolve();
240
+ };
241
+ this.clearSpeakTimer();
242
+ this.speakTimer = setTimeout(() => {
243
+ this.config.voice.stopPlayback();
244
+ finish();
245
+ }, capMs);
246
+ playback.then(finish, finish);
247
+ });
248
+ }
209
249
  // ── Confirmation resolution ─────────────────────────────────────────────────
210
250
  beginConfirmationVoiceWindow() {
211
251
  const pending = this.pending;
@@ -544,8 +584,14 @@ class AgentController {
544
584
  this.setPhase('thinking');
545
585
  return;
546
586
  }
547
- if (this.phase === 'thinking' || this.phase === 'speaking')
587
+ if (this.phase === 'speaking') {
588
+ // Tap while the assistant is talking → stop the reply and reset, so the
589
+ // orb is immediately usable again (no more "stuck pulsing").
590
+ this.cancelAll();
548
591
  return;
592
+ }
593
+ if (this.phase === 'thinking')
594
+ return; // a turn is in flight; let it finish
549
595
  const consented = await this.consent.hasConsented();
550
596
  if (!consented) {
551
597
  this.pendingConsentIntent = 'voice';
@@ -597,6 +643,7 @@ class AgentController {
597
643
  this.abort = null;
598
644
  this.clearWatchdog();
599
645
  this.clearConfirmWindow();
646
+ this.clearSpeakTimer();
600
647
  this.speech.cancel();
601
648
  this.config.voice.stopPlayback();
602
649
  this.pending = null;
@@ -46,6 +46,7 @@ const CSS = `
46
46
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
47
47
  @keyframes fy-spin{to{transform:rotate(360deg)}}
48
48
  @keyframes fy-opulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.06);opacity:.85}}
49
+ @keyframes fy-voice{0%{transform:scale(0.6);opacity:.7}100%{transform:scale(1.35);opacity:0}}
49
50
  .fyodos-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(4,6,12,0.6);}
50
51
  .fyodos-modal{width:min(420px,100%);border-radius:16px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.5);}
51
52
  .fyodos-modal h3{margin:0 0 10px;font-size:17px;font-weight:600;}
@@ -65,6 +66,18 @@ function ensureStyles() {
65
66
  el.textContent = CSS;
66
67
  document.head.appendChild(el);
67
68
  }
69
+ // Content box of the layout viewport. `documentElement.clientWidth/Height`
70
+ // EXCLUDE a classic scrollbar that occupies real width/height (Windows, some
71
+ // Linux/Chromium); overlay scrollbars (macOS, mobile) report the same value as
72
+ // the layout viewport. Anchoring against this — not `innerWidth` — keeps a
73
+ // right/bottom-edge orb just INSIDE the scrollbar instead of under/over it.
74
+ function viewportContentSize() {
75
+ const de = typeof document !== 'undefined' ? document.documentElement : null;
76
+ return {
77
+ width: de?.clientWidth || window.innerWidth,
78
+ height: de?.clientHeight || window.innerHeight,
79
+ };
80
+ }
68
81
  function cornerToPosition(corner) {
69
82
  switch (corner) {
70
83
  case 'bottom-left':
@@ -161,14 +174,15 @@ function mountOrb(controller, options = {}) {
161
174
  }
162
175
  function reposition() {
163
176
  const size = orbDiameterPx();
164
- const insets = (0, core_1.resolveOrbLayerInsets)({
177
+ const content = viewportContentSize();
178
+ const { left, top } = (0, core_1.resolveOrbAnchorPx)({
165
179
  viewportWidth: window.innerWidth,
166
180
  viewportHeight: window.innerHeight,
181
+ contentWidth: content.width,
182
+ contentHeight: content.height,
167
183
  orbDiameter: size,
168
184
  position: effectivePosition(),
169
185
  });
170
- const left = Math.max(8, window.innerWidth - insets.right - size);
171
- const top = Math.max(8, window.innerHeight - insets.bottom - size);
172
186
  root.style.left = `${left}px`;
173
187
  root.style.top = `${top}px`;
174
188
  root.style.right = '';
@@ -197,6 +211,14 @@ function mountOrb(controller, options = {}) {
197
211
  }
198
212
  }
199
213
  window.addEventListener('resize', reposition);
214
+ // Recalc when a scrollbar appears/disappears as the page content changes (the
215
+ // content box shrinks/grows even when the window size doesn't), so the orb
216
+ // never ends up overlapped by — or detached from — the scrollbar edge.
217
+ let contentObserver;
218
+ if (typeof ResizeObserver !== 'undefined' && document.documentElement) {
219
+ contentObserver = new ResizeObserver(() => reposition());
220
+ contentObserver.observe(document.documentElement);
221
+ }
200
222
  // Keep the conversation bar above the keyboard as the visual viewport resizes
201
223
  // (keyboard open/close) or scrolls on mobile browsers.
202
224
  const visualViewport = window.visualViewport;
@@ -240,9 +262,10 @@ function mountOrb(controller, options = {}) {
240
262
  dragging = true;
241
263
  e.preventDefault();
242
264
  const size = orbDiameterPx();
265
+ const content = viewportContentSize();
243
266
  const margin = 8;
244
- const maxLeft = window.innerWidth - margin - size;
245
- const maxTop = window.innerHeight - margin - size;
267
+ const maxLeft = content.width - margin - size;
268
+ const maxTop = content.height - margin - size;
246
269
  const left = Math.min(maxLeft, Math.max(margin, originLeft + dx));
247
270
  const top = Math.min(maxTop, Math.max(margin, originTop + dy));
248
271
  root.style.left = `${left}px`;
@@ -252,8 +275,8 @@ function mountOrb(controller, options = {}) {
252
275
  draggedPosition = (0, core_1.viewportPixelsToOrbScreenPosition)({
253
276
  centerX: left + size / 2,
254
277
  centerY: top + size / 2,
255
- viewportWidth: window.innerWidth,
256
- viewportHeight: window.innerHeight,
278
+ viewportWidth: content.width,
279
+ viewportHeight: content.height,
257
280
  orbDiameter: size,
258
281
  });
259
282
  applyDeployment();
@@ -688,6 +711,7 @@ function mountOrb(controller, options = {}) {
688
711
  unsubscribe();
689
712
  stopWatch();
690
713
  window.removeEventListener('resize', reposition);
714
+ contentObserver?.disconnect();
691
715
  visualViewport?.removeEventListener('resize', onViewportChange);
692
716
  visualViewport?.removeEventListener('scroll', onViewportChange);
693
717
  clearOverlay();
@@ -215,6 +215,52 @@ function buildThinkingSpinner(orbSizePx, color) {
215
215
  wrap.appendChild(spin);
216
216
  return wrap;
217
217
  }
218
+ // ── Speaking pulse (port of the dashboard's no-logo "speaking" state) ─────────
219
+ // Two expanding rings + a pulsing centre dot — the default "talking" drawing the
220
+ // dashboard orb editor shows when there is no custom photo. Ratios are taken
221
+ // from the editor (118 / 30 / 2 px on its 380px reference orb) so the embedded
222
+ // orb matches it at any size.
223
+ const SPEAK_RING_RATIO = 118 / 380;
224
+ const SPEAK_DOT_RATIO = 30 / 380;
225
+ const SPEAK_BORDER_RATIO = 2 / 380;
226
+ function buildSpeakingPulse(orbSize, color) {
227
+ const wrap = document.createElement('div');
228
+ Object.assign(wrap.style, {
229
+ position: 'absolute',
230
+ inset: '0',
231
+ display: 'flex',
232
+ alignItems: 'center',
233
+ justifyContent: 'center',
234
+ pointerEvents: 'none',
235
+ });
236
+ const ringPx = orbSize * SPEAK_RING_RATIO;
237
+ const borderPx = Math.max(1, orbSize * SPEAK_BORDER_RATIO);
238
+ for (const delay of [0, 0.85]) {
239
+ const ring = document.createElement('div');
240
+ Object.assign(ring.style, {
241
+ position: 'absolute',
242
+ width: `${ringPx}px`,
243
+ height: `${ringPx}px`,
244
+ borderRadius: '50%',
245
+ border: `${borderPx}px solid ${color}`,
246
+ opacity: '0.55',
247
+ animation: `fy-voice 1.7s ease-out infinite ${delay}s`,
248
+ });
249
+ wrap.appendChild(ring);
250
+ }
251
+ const dotPx = orbSize * SPEAK_DOT_RATIO;
252
+ const dot = document.createElement('div');
253
+ Object.assign(dot.style, {
254
+ width: `${dotPx}px`,
255
+ height: `${dotPx}px`,
256
+ borderRadius: '50%',
257
+ background: color,
258
+ opacity: '0.9',
259
+ animation: 'fy-opulse 1s ease-in-out infinite',
260
+ });
261
+ wrap.appendChild(dot);
262
+ return wrap;
263
+ }
218
264
  // ── Keyboard chip icon (port of ChipIconSvg) ──────────────────────────────────
219
265
  function buildChipIcon(icon, size, color) {
220
266
  const spec = (0, core_1.chipIconSpec)(icon);
@@ -369,7 +415,10 @@ function createOrbVisual(initial) {
369
415
  waveform?.stop();
370
416
  waveform = null;
371
417
  fillEl.replaceChildren();
372
- wrapper.style.animation = state === 'speaking' ? 'fy-opulse 1.1s ease-in-out infinite' : '';
418
+ // The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
419
+ // rings + dot when there's no photo) to mirror the dashboard editor, so the
420
+ // orb no longer pulses as a whole.
421
+ wrapper.style.animation = '';
373
422
  if (state === 'thinking') {
374
423
  fillEl.appendChild(buildThinkingSpinner(size, waveColor));
375
424
  return;
@@ -397,10 +446,17 @@ function createOrbVisual(initial) {
397
446
  userSelect: 'none',
398
447
  webkitUserSelect: 'none',
399
448
  webkitUserDrag: 'none',
449
+ // While speaking, the photo gently pulses (matches the dashboard).
450
+ animation: state === 'speaking' ? 'fy-opulse 1s ease-in-out infinite' : '',
400
451
  });
401
452
  fillEl.appendChild(img);
402
453
  return;
403
454
  }
455
+ if (state === 'speaking') {
456
+ // No photo → the default "talking" drawing (expanding rings + centre dot).
457
+ fillEl.appendChild(buildSpeakingPulse(size, waveColor));
458
+ return;
459
+ }
404
460
  if (state === 'idle') {
405
461
  fillEl.appendChild(buildOrbMark(size));
406
462
  }
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.3";
8
+ export declare const SDK_VERSION = "0.1.5";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -8,5 +8,5 @@ exports.SDK_PACKAGE = exports.SDK_VERSION = void 0;
8
8
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
9
9
  * by hand — change package.json `version` and re-run the sync/release script.
10
10
  */
11
- exports.SDK_VERSION = '0.1.3';
11
+ exports.SDK_VERSION = '0.1.5';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';
@@ -1,4 +1,41 @@
1
1
  import { detectWakePhrase } from '@fiodos/core';
2
+ // ── Device TTS (SpeechSynthesis) helpers ────────────────────────────────────
3
+ // Chrome loads voices asynchronously: calling speak() before getVoices() is
4
+ // populated produces NO sound. Resolve the voice list once (event or timeout).
5
+ function loadVoicesOnce(synth) {
6
+ const ready = synth.getVoices();
7
+ if (ready.length)
8
+ return Promise.resolve(ready);
9
+ return new Promise((resolve) => {
10
+ let settled = false;
11
+ const finish = () => {
12
+ if (settled)
13
+ return;
14
+ settled = true;
15
+ resolve(synth.getVoices());
16
+ };
17
+ try {
18
+ synth.addEventListener('voiceschanged', finish, { once: true });
19
+ }
20
+ catch {
21
+ /* older engines: the timeout below is the only path */
22
+ }
23
+ // Some engines never emit `voiceschanged` — never block the reply forever.
24
+ setTimeout(finish, 600);
25
+ });
26
+ }
27
+ /** Best voice for a BCP-47 locale: exact → region-agnostic → language → default. */
28
+ function pickVoiceForLocale(voices, locale) {
29
+ if (!voices.length)
30
+ return undefined;
31
+ const lc = locale.toLowerCase();
32
+ const lang = lc.split('-')[0] ?? lc;
33
+ return (voices.find((v) => v.lang?.toLowerCase() === lc) ??
34
+ voices.find((v) => v.lang?.toLowerCase().startsWith(`${lang}-`)) ??
35
+ voices.find((v) => v.lang?.toLowerCase().startsWith(lang)) ??
36
+ voices.find((v) => v.default) ??
37
+ voices[0]);
38
+ }
2
39
  function getRecognitionCtor() {
3
40
  if (typeof window === 'undefined')
4
41
  return null;
@@ -30,6 +67,9 @@ export function createWebVoiceAdapter(options = {}) {
30
67
  let aborted = false;
31
68
  let settled = false;
32
69
  let audioEl = null;
70
+ // Strong reference to the in-flight utterance: Chrome garbage-collects it
71
+ // otherwise, cutting device speech off mid-sentence (or producing silence).
72
+ let ttsUtterance = null;
33
73
  // ── Wake-word state (separate recognition instance; the browser allows only
34
74
  // one active session, so push-to-talk stops wake first). Desktop-only:
35
75
  // foreground tab while the page is visible — there is no passive background
@@ -409,13 +449,33 @@ export function createWebVoiceAdapter(options = {}) {
409
449
  return;
410
450
  if (typeof window === 'undefined' || !window.speechSynthesis)
411
451
  return;
452
+ const synth = window.speechSynthesis;
453
+ // Voices load lazily on Chrome — speaking before they exist is silent.
454
+ const voices = await loadVoicesOnce(synth);
412
455
  return new Promise((resolve) => {
413
456
  try {
457
+ // Clear any utterance the engine wedged on (a known Chrome failure
458
+ // mode) and unpause before queuing the reply.
459
+ if (synth.speaking || synth.pending)
460
+ synth.cancel();
461
+ if (synth.paused)
462
+ synth.resume();
414
463
  const utter = new SpeechSynthesisUtterance(text);
415
464
  utter.lang = ttsOptions.locale;
416
- utter.onend = () => resolve();
417
- utter.onerror = () => resolve();
418
- window.speechSynthesis.speak(utter);
465
+ const voice = pickVoiceForLocale(voices, ttsOptions.locale);
466
+ if (voice)
467
+ utter.voice = voice;
468
+ ttsUtterance = utter;
469
+ const finish = () => {
470
+ utter.onend = null;
471
+ utter.onerror = null;
472
+ if (ttsUtterance === utter)
473
+ ttsUtterance = null;
474
+ resolve();
475
+ };
476
+ utter.onend = finish;
477
+ utter.onerror = finish;
478
+ synth.speak(utter);
419
479
  }
420
480
  catch {
421
481
  resolve();
@@ -74,6 +74,7 @@ export declare class AgentController {
74
74
  private abort;
75
75
  private watchdog;
76
76
  private confirmWindow;
77
+ private speakTimer;
77
78
  private pendingConsentIntent;
78
79
  private readonly listeners;
79
80
  private unsubscribeSpeech;
@@ -95,7 +96,14 @@ export declare class AgentController {
95
96
  private setPhase;
96
97
  private clearWatchdog;
97
98
  private clearConfirmWindow;
99
+ private clearSpeakTimer;
98
100
  private speakReply;
101
+ /**
102
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
103
+ * completion (a stalled engine, a never-firing onend), stop it and continue
104
+ * after a generous, length-based cap so the orb returns to idle on its own.
105
+ */
106
+ private raceSpeakingWatchdog;
99
107
  private beginConfirmationVoiceWindow;
100
108
  confirmPendingAction(): Promise<void>;
101
109
  cancelPendingAction(): void;
@@ -48,6 +48,9 @@ export class AgentController {
48
48
  this.abort = null;
49
49
  this.watchdog = null;
50
50
  this.confirmWindow = null;
51
+ // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
52
+ // never reports completion (e.g. a stalled SpeechSynthesis engine).
53
+ this.speakTimer = null;
51
54
  this.pendingConsentIntent = null;
52
55
  this.listeners = new Set();
53
56
  this.unsubscribeSpeech = null;
@@ -176,6 +179,12 @@ export class AgentController {
176
179
  }
177
180
  this.confirmationVoiceActive = false;
178
181
  }
182
+ clearSpeakTimer() {
183
+ if (this.speakTimer) {
184
+ clearTimeout(this.speakTimer);
185
+ this.speakTimer = null;
186
+ }
187
+ }
179
188
  // ── Speech playback (ONE consistent voice) ─────────────────────────────────
180
189
  async speakReply(text, audioBase64) {
181
190
  if (this.silentTurn)
@@ -186,23 +195,54 @@ export class AgentController {
186
195
  try {
187
196
  let audio = audioBase64;
188
197
  if (!audio && text) {
189
- audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
190
- }
191
- if (audio) {
192
- await this.config.voice.playAudioBase64Mp3(audio);
193
- }
194
- else if (text) {
195
- await this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale });
198
+ // A server-TTS failure must NOT skip the device-voice fallback below.
199
+ try {
200
+ audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
201
+ }
202
+ catch {
203
+ audio = null;
204
+ }
196
205
  }
206
+ const playback = audio
207
+ ? this.config.voice.playAudioBase64Mp3(audio)
208
+ : text
209
+ ? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
210
+ : Promise.resolve();
211
+ await this.raceSpeakingWatchdog(playback, text);
197
212
  }
198
213
  catch {
199
214
  /* playback best-effort */
200
215
  }
201
216
  finally {
217
+ this.clearSpeakTimer();
202
218
  if (this.phase === 'speaking')
203
219
  this.setPhase('idle');
204
220
  }
205
221
  }
222
+ /**
223
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
224
+ * completion (a stalled engine, a never-firing onend), stop it and continue
225
+ * after a generous, length-based cap so the orb returns to idle on its own.
226
+ */
227
+ raceSpeakingWatchdog(playback, text) {
228
+ const capMs = Math.min(60000, 5000 + text.length * 90);
229
+ return new Promise((resolve) => {
230
+ let settled = false;
231
+ const finish = () => {
232
+ if (settled)
233
+ return;
234
+ settled = true;
235
+ this.clearSpeakTimer();
236
+ resolve();
237
+ };
238
+ this.clearSpeakTimer();
239
+ this.speakTimer = setTimeout(() => {
240
+ this.config.voice.stopPlayback();
241
+ finish();
242
+ }, capMs);
243
+ playback.then(finish, finish);
244
+ });
245
+ }
206
246
  // ── Confirmation resolution ─────────────────────────────────────────────────
207
247
  beginConfirmationVoiceWindow() {
208
248
  const pending = this.pending;
@@ -541,8 +581,14 @@ export class AgentController {
541
581
  this.setPhase('thinking');
542
582
  return;
543
583
  }
544
- if (this.phase === 'thinking' || this.phase === 'speaking')
584
+ if (this.phase === 'speaking') {
585
+ // Tap while the assistant is talking → stop the reply and reset, so the
586
+ // orb is immediately usable again (no more "stuck pulsing").
587
+ this.cancelAll();
545
588
  return;
589
+ }
590
+ if (this.phase === 'thinking')
591
+ return; // a turn is in flight; let it finish
546
592
  const consented = await this.consent.hasConsented();
547
593
  if (!consented) {
548
594
  this.pendingConsentIntent = 'voice';
@@ -594,6 +640,7 @@ export class AgentController {
594
640
  this.abort = null;
595
641
  this.clearWatchdog();
596
642
  this.clearConfirmWindow();
643
+ this.clearSpeakTimer();
597
644
  this.speech.cancel();
598
645
  this.config.voice.stopPlayback();
599
646
  this.pending = null;
@@ -16,7 +16,7 @@
16
16
  * - CONFIRMATIONS: the same security model (manifest decides; strict phrase /
17
17
  * deliberate tap for payment-grade; loose "yes" never resolves strict).
18
18
  */
19
- import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, resolveOrbLayerInsets, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
19
+ import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
20
20
  import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
21
21
  import { watchPublishedConfig } from './publishedConfig.js';
22
22
  const STYLE_ID = 'fyodos-orb-styles';
@@ -43,6 +43,7 @@ const CSS = `
43
43
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
44
44
  @keyframes fy-spin{to{transform:rotate(360deg)}}
45
45
  @keyframes fy-opulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.06);opacity:.85}}
46
+ @keyframes fy-voice{0%{transform:scale(0.6);opacity:.7}100%{transform:scale(1.35);opacity:0}}
46
47
  .fyodos-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(4,6,12,0.6);}
47
48
  .fyodos-modal{width:min(420px,100%);border-radius:16px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.5);}
48
49
  .fyodos-modal h3{margin:0 0 10px;font-size:17px;font-weight:600;}
@@ -62,6 +63,18 @@ function ensureStyles() {
62
63
  el.textContent = CSS;
63
64
  document.head.appendChild(el);
64
65
  }
66
+ // Content box of the layout viewport. `documentElement.clientWidth/Height`
67
+ // EXCLUDE a classic scrollbar that occupies real width/height (Windows, some
68
+ // Linux/Chromium); overlay scrollbars (macOS, mobile) report the same value as
69
+ // the layout viewport. Anchoring against this — not `innerWidth` — keeps a
70
+ // right/bottom-edge orb just INSIDE the scrollbar instead of under/over it.
71
+ function viewportContentSize() {
72
+ const de = typeof document !== 'undefined' ? document.documentElement : null;
73
+ return {
74
+ width: de?.clientWidth || window.innerWidth,
75
+ height: de?.clientHeight || window.innerHeight,
76
+ };
77
+ }
65
78
  function cornerToPosition(corner) {
66
79
  switch (corner) {
67
80
  case 'bottom-left':
@@ -158,14 +171,15 @@ export function mountOrb(controller, options = {}) {
158
171
  }
159
172
  function reposition() {
160
173
  const size = orbDiameterPx();
161
- const insets = resolveOrbLayerInsets({
174
+ const content = viewportContentSize();
175
+ const { left, top } = resolveOrbAnchorPx({
162
176
  viewportWidth: window.innerWidth,
163
177
  viewportHeight: window.innerHeight,
178
+ contentWidth: content.width,
179
+ contentHeight: content.height,
164
180
  orbDiameter: size,
165
181
  position: effectivePosition(),
166
182
  });
167
- const left = Math.max(8, window.innerWidth - insets.right - size);
168
- const top = Math.max(8, window.innerHeight - insets.bottom - size);
169
183
  root.style.left = `${left}px`;
170
184
  root.style.top = `${top}px`;
171
185
  root.style.right = '';
@@ -194,6 +208,14 @@ export function mountOrb(controller, options = {}) {
194
208
  }
195
209
  }
196
210
  window.addEventListener('resize', reposition);
211
+ // Recalc when a scrollbar appears/disappears as the page content changes (the
212
+ // content box shrinks/grows even when the window size doesn't), so the orb
213
+ // never ends up overlapped by — or detached from — the scrollbar edge.
214
+ let contentObserver;
215
+ if (typeof ResizeObserver !== 'undefined' && document.documentElement) {
216
+ contentObserver = new ResizeObserver(() => reposition());
217
+ contentObserver.observe(document.documentElement);
218
+ }
197
219
  // Keep the conversation bar above the keyboard as the visual viewport resizes
198
220
  // (keyboard open/close) or scrolls on mobile browsers.
199
221
  const visualViewport = window.visualViewport;
@@ -237,9 +259,10 @@ export function mountOrb(controller, options = {}) {
237
259
  dragging = true;
238
260
  e.preventDefault();
239
261
  const size = orbDiameterPx();
262
+ const content = viewportContentSize();
240
263
  const margin = 8;
241
- const maxLeft = window.innerWidth - margin - size;
242
- const maxTop = window.innerHeight - margin - size;
264
+ const maxLeft = content.width - margin - size;
265
+ const maxTop = content.height - margin - size;
243
266
  const left = Math.min(maxLeft, Math.max(margin, originLeft + dx));
244
267
  const top = Math.min(maxTop, Math.max(margin, originTop + dy));
245
268
  root.style.left = `${left}px`;
@@ -249,8 +272,8 @@ export function mountOrb(controller, options = {}) {
249
272
  draggedPosition = viewportPixelsToOrbScreenPosition({
250
273
  centerX: left + size / 2,
251
274
  centerY: top + size / 2,
252
- viewportWidth: window.innerWidth,
253
- viewportHeight: window.innerHeight,
275
+ viewportWidth: content.width,
276
+ viewportHeight: content.height,
254
277
  orbDiameter: size,
255
278
  });
256
279
  applyDeployment();
@@ -685,6 +708,7 @@ export function mountOrb(controller, options = {}) {
685
708
  unsubscribe();
686
709
  stopWatch();
687
710
  window.removeEventListener('resize', reposition);
711
+ contentObserver?.disconnect();
688
712
  visualViewport?.removeEventListener('resize', onViewportChange);
689
713
  visualViewport?.removeEventListener('scroll', onViewportChange);
690
714
  clearOverlay();
@@ -210,6 +210,52 @@ function buildThinkingSpinner(orbSizePx, color) {
210
210
  wrap.appendChild(spin);
211
211
  return wrap;
212
212
  }
213
+ // ── Speaking pulse (port of the dashboard's no-logo "speaking" state) ─────────
214
+ // Two expanding rings + a pulsing centre dot — the default "talking" drawing the
215
+ // dashboard orb editor shows when there is no custom photo. Ratios are taken
216
+ // from the editor (118 / 30 / 2 px on its 380px reference orb) so the embedded
217
+ // orb matches it at any size.
218
+ const SPEAK_RING_RATIO = 118 / 380;
219
+ const SPEAK_DOT_RATIO = 30 / 380;
220
+ const SPEAK_BORDER_RATIO = 2 / 380;
221
+ function buildSpeakingPulse(orbSize, color) {
222
+ const wrap = document.createElement('div');
223
+ Object.assign(wrap.style, {
224
+ position: 'absolute',
225
+ inset: '0',
226
+ display: 'flex',
227
+ alignItems: 'center',
228
+ justifyContent: 'center',
229
+ pointerEvents: 'none',
230
+ });
231
+ const ringPx = orbSize * SPEAK_RING_RATIO;
232
+ const borderPx = Math.max(1, orbSize * SPEAK_BORDER_RATIO);
233
+ for (const delay of [0, 0.85]) {
234
+ const ring = document.createElement('div');
235
+ Object.assign(ring.style, {
236
+ position: 'absolute',
237
+ width: `${ringPx}px`,
238
+ height: `${ringPx}px`,
239
+ borderRadius: '50%',
240
+ border: `${borderPx}px solid ${color}`,
241
+ opacity: '0.55',
242
+ animation: `fy-voice 1.7s ease-out infinite ${delay}s`,
243
+ });
244
+ wrap.appendChild(ring);
245
+ }
246
+ const dotPx = orbSize * SPEAK_DOT_RATIO;
247
+ const dot = document.createElement('div');
248
+ Object.assign(dot.style, {
249
+ width: `${dotPx}px`,
250
+ height: `${dotPx}px`,
251
+ borderRadius: '50%',
252
+ background: color,
253
+ opacity: '0.9',
254
+ animation: 'fy-opulse 1s ease-in-out infinite',
255
+ });
256
+ wrap.appendChild(dot);
257
+ return wrap;
258
+ }
213
259
  // ── Keyboard chip icon (port of ChipIconSvg) ──────────────────────────────────
214
260
  function buildChipIcon(icon, size, color) {
215
261
  const spec = chipIconSpec(icon);
@@ -364,7 +410,10 @@ export function createOrbVisual(initial) {
364
410
  waveform?.stop();
365
411
  waveform = null;
366
412
  fillEl.replaceChildren();
367
- wrapper.style.animation = state === 'speaking' ? 'fy-opulse 1.1s ease-in-out infinite' : '';
413
+ // The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
414
+ // rings + dot when there's no photo) to mirror the dashboard editor, so the
415
+ // orb no longer pulses as a whole.
416
+ wrapper.style.animation = '';
368
417
  if (state === 'thinking') {
369
418
  fillEl.appendChild(buildThinkingSpinner(size, waveColor));
370
419
  return;
@@ -392,10 +441,17 @@ export function createOrbVisual(initial) {
392
441
  userSelect: 'none',
393
442
  webkitUserSelect: 'none',
394
443
  webkitUserDrag: 'none',
444
+ // While speaking, the photo gently pulses (matches the dashboard).
445
+ animation: state === 'speaking' ? 'fy-opulse 1s ease-in-out infinite' : '',
395
446
  });
396
447
  fillEl.appendChild(img);
397
448
  return;
398
449
  }
450
+ if (state === 'speaking') {
451
+ // No photo → the default "talking" drawing (expanding rings + centre dot).
452
+ fillEl.appendChild(buildSpeakingPulse(size, waveColor));
453
+ return;
454
+ }
399
455
  if (state === 'idle') {
400
456
  fillEl.appendChild(buildOrbMark(size));
401
457
  }
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.3";
8
+ export declare const SDK_VERSION = "0.1.5";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export const SDK_VERSION = '0.1.3';
8
+ export const SDK_VERSION = '0.1.5';
9
9
  export const SDK_PACKAGE = '@fiodos/web-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/web-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Framework-agnostic browser layer for the Fiodos agent: a vanilla AgentController (orchestrator), DOM adapters (navigation/voice/storage), HTTP client and decision engine over @fiodos/core. Shared base for @fiodos/vue, @fiodos/svelte and @fiodos/angular (no framework imports).",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {
@@ -25,7 +25,8 @@
25
25
  "scripts": {
26
26
  "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node ../../scripts/fix-esm-extensions.mjs dist/esm && node ../../scripts/mark-dual.mjs",
27
27
  "typecheck": "tsc -p tsconfig.json --noEmit",
28
- "test": "node --import tsx --test test/*.test.ts"
28
+ "test": "node --import tsx --test test/*.test.ts",
29
+ "prepublishOnly": "node ../../scripts/sync-sdk-version.mjs && npm run build"
29
30
  },
30
31
  "dependencies": {
31
32
  "@fiodos/core": "^0.1.0"