@fiodos/web-core 0.1.5 → 0.1.9

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.
@@ -401,20 +401,40 @@ function createWebVoiceAdapter(options = {}) {
401
401
  // later programmatic playback (after the network round-trip) is allowed
402
402
  // and audible even with the silent switch on.
403
403
  const ctx = ensureAudioCtx();
404
- if (!ctx)
405
- return;
406
- try {
407
- if (ctx.state === 'suspended')
408
- void ctx.resume();
409
- // A 1-frame silent buffer fully unlocks playback on iOS Safari.
410
- const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
411
- const source = ctx.createBufferSource();
412
- source.buffer = buffer;
413
- source.connect(ctx.destination);
414
- source.start(0);
404
+ if (ctx) {
405
+ try {
406
+ if (ctx.state === 'suspended')
407
+ void ctx.resume();
408
+ // A 1-frame silent buffer fully unlocks playback on iOS Safari.
409
+ const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
410
+ const source = ctx.createBufferSource();
411
+ source.buffer = buffer;
412
+ source.connect(ctx.destination);
413
+ source.start(0);
414
+ }
415
+ catch {
416
+ /* best-effort unlock */
417
+ }
415
418
  }
416
- catch {
417
- /* best-effort unlock */
419
+ // Unlock device TTS (SpeechSynthesis) too. The spoken reply happens AFTER
420
+ // the network round-trip outside this gesture — and Safari/Chrome stay
421
+ // SILENT for a first programmatic speak that wasn't preceded by a
422
+ // gesture-initiated one. Warm the engine here: kick off voice loading and
423
+ // speak a zero-volume blank utterance inside the tap so the real reply is
424
+ // allowed to play later.
425
+ if (!options.disableDeviceTtsFallback && typeof window !== 'undefined' && window.speechSynthesis) {
426
+ try {
427
+ const synth = window.speechSynthesis;
428
+ synth.getVoices(); // triggers async voice loading on Chrome
429
+ if (synth.paused)
430
+ synth.resume();
431
+ const warm = new SpeechSynthesisUtterance(' ');
432
+ warm.volume = 0;
433
+ synth.speak(warm);
434
+ }
435
+ catch {
436
+ /* best-effort unlock */
437
+ }
418
438
  }
419
439
  },
420
440
  async playAudioBase64Mp3(base64) {
@@ -456,20 +476,32 @@ function createWebVoiceAdapter(options = {}) {
456
476
  // Voices load lazily on Chrome — speaking before they exist is silent.
457
477
  const voices = await loadVoicesOnce(synth);
458
478
  return new Promise((resolve) => {
479
+ let keepAlive = null;
480
+ const cleanup = () => {
481
+ if (keepAlive) {
482
+ clearInterval(keepAlive);
483
+ keepAlive = null;
484
+ }
485
+ };
459
486
  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();
487
+ // Clear any previous/wedged queue. After a prior cancel() (e.g. the
488
+ // stopPlayback() fired when listening started) Chrome on macOS can sit
489
+ // in a stuck "paused" state that does NOT report paused===true, so a
490
+ // conditional resume() never fires and every later speak() is silent.
491
+ synth.cancel();
466
492
  const utter = new SpeechSynthesisUtterance(text);
467
493
  utter.lang = ttsOptions.locale;
468
494
  const voice = pickVoiceForLocale(voices, ttsOptions.locale);
469
495
  if (voice)
470
496
  utter.voice = voice;
497
+ utter.volume = 1;
498
+ utter.rate = 1;
499
+ utter.pitch = 1;
500
+ // Keep a strong ref so Chrome can't GC the utterance mid-speech.
471
501
  ttsUtterance = utter;
472
502
  const finish = () => {
503
+ cleanup();
504
+ utter.onstart = null;
473
505
  utter.onend = null;
474
506
  utter.onerror = null;
475
507
  if (ttsUtterance === utter)
@@ -479,8 +511,22 @@ function createWebVoiceAdapter(options = {}) {
479
511
  utter.onend = finish;
480
512
  utter.onerror = finish;
481
513
  synth.speak(utter);
514
+ // THE FIX: resume() unconditionally right after queuing. This un-wedges
515
+ // the engine from the silent stuck-paused state described above.
516
+ synth.resume();
517
+ // Chrome desktop stops speaking after ~15s; pulse pause/resume to keep
518
+ // long replies going, and stop the timer once it has finished.
519
+ keepAlive = setInterval(() => {
520
+ if (!synth.speaking && !synth.pending) {
521
+ cleanup();
522
+ return;
523
+ }
524
+ synth.pause();
525
+ synth.resume();
526
+ }, 9000);
482
527
  }
483
528
  catch {
529
+ cleanup();
484
530
  resolve();
485
531
  }
486
532
  });
@@ -75,6 +75,7 @@ export declare class AgentController {
75
75
  private watchdog;
76
76
  private confirmWindow;
77
77
  private speakTimer;
78
+ private serverTtsUnavailable;
78
79
  private pendingConsentIntent;
79
80
  private readonly listeners;
80
81
  private unsubscribeSpeech;
@@ -54,6 +54,9 @@ class AgentController {
54
54
  // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
55
55
  // never reports completion (e.g. a stalled SpeechSynthesis engine).
56
56
  this.speakTimer = null;
57
+ // Once the backend shows it has no server-side TTS (a reply arrives with null
58
+ // audio), skip the extra /preview-tts round-trip and speak via device voice.
59
+ this.serverTtsUnavailable = false;
57
60
  this.pendingConsentIntent = null;
58
61
  this.listeners = new Set();
59
62
  this.unsubscribeSpeech = null;
@@ -90,7 +93,11 @@ class AgentController {
90
93
  });
91
94
  this.screenStore = (0, screenContextStore_1.createScreenContextStore)({
92
95
  getCurrentRoute: () => config.navigation.getCurrentRoute(),
93
- routeFallback: config.routeContextFallback,
96
+ // The integrator's fallback wins; otherwise the orb gets screen awareness
97
+ // out of the box from the matched manifest route (label + description +
98
+ // available actions). Null when no route matches → raw route, as today.
99
+ routeFallback: config.routeContextFallback ??
100
+ ((route) => (0, core_1.defaultRouteContextText)(route, config.manifest, this.messages)),
94
101
  });
95
102
  this.speech = (0, speechSession_1.createSpeechSession)({
96
103
  adapter: config.voice,
@@ -197,13 +204,20 @@ class AgentController {
197
204
  this.setPhase('speaking');
198
205
  try {
199
206
  let audio = audioBase64;
200
- if (!audio && text) {
201
- // A server-TTS failure must NOT skip the device-voice fallback below.
207
+ if (audio) {
208
+ this.serverTtsUnavailable = false; // server voice is working
209
+ }
210
+ // Once we know the backend ships no server TTS, skip the dead
211
+ // /preview-tts round-trip (pure latency) and use the device voice.
212
+ if (!audio && text && !this.serverTtsUnavailable) {
202
213
  try {
203
214
  audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
215
+ if (!audio)
216
+ this.serverTtsUnavailable = true;
204
217
  }
205
218
  catch {
206
219
  audio = null;
220
+ this.serverTtsUnavailable = true;
207
221
  }
208
222
  }
209
223
  const playback = audio
@@ -418,6 +432,10 @@ class AgentController {
418
432
  }, { signal: controller.signal });
419
433
  reply = turn.reply;
420
434
  audioBase64 = turn.audioBase64;
435
+ // Learn whether this backend ships server-side TTS: a non-empty reply
436
+ // with no audio means it doesn't — later lines then speak instantly.
437
+ if (reply)
438
+ this.serverTtsUnavailable = !audioBase64;
421
439
  const decision = await (0, turnEngine_1.decideAction)({
422
440
  action: turn.action,
423
441
  manifest: this.config.manifest,
@@ -27,8 +27,12 @@ export interface CreateFiodosAgentOptions {
27
27
  manifest?: AppManifest;
28
28
  /** Action handlers / context validators (defaults to empty registries). */
29
29
  registries?: ActionRegistries;
30
- /** Agent reply locale (e.g. 'es', 'en'). Defaults to the browser language. */
30
+ /** Agent UI locale (e.g. 'en', 'es'). Defaults to `'en'`. */
31
31
  locale?: string;
32
+ /** Live locale resolver — overrides `locale` when set. */
33
+ getLocale?: () => string;
34
+ /** Auto-detect locale when `locale` / `getLocale` are unset. Default: none (English). */
35
+ localeDetection?: 'browser' | 'document';
32
36
  /** STT locale (e.g. 'es-ES', 'en-US'). Defaults to the browser language. */
33
37
  sttLocale?: string;
34
38
  /** Host router navigate delegate. Defaults to location.assign. */
@@ -32,10 +32,27 @@ function browserLocale() {
32
32
  const lang = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en-US';
33
33
  return { locale: lang.split('-')[0] || 'en', sttLocale: lang };
34
34
  }
35
+ function resolveAgentLocale(options) {
36
+ const fromApp = options.getLocale?.()?.trim();
37
+ if (fromApp)
38
+ return fromApp;
39
+ if (options.locale?.trim())
40
+ return options.locale.trim();
41
+ if (options.localeDetection === 'browser')
42
+ return browserLocale().locale;
43
+ if (options.localeDetection === 'document') {
44
+ const docLang = typeof document !== 'undefined'
45
+ ? document.documentElement.lang?.split(/[-_]/)[0]?.trim()
46
+ : '';
47
+ if (docLang)
48
+ return docLang;
49
+ }
50
+ return 'en';
51
+ }
35
52
  /** Build the controller + adapters from a known manifest (shared by both paths). */
36
53
  function assemble(options, manifest, baseUrl) {
37
54
  const fallback = browserLocale();
38
- const locale = options.locale ?? fallback.locale;
55
+ const locale = resolveAgentLocale(options);
39
56
  const sttLocale = options.sttLocale ?? options.locale ?? fallback.sttLocale;
40
57
  const backend = (0, backendClient_1.createFiodosBackendClient)({
41
58
  baseUrl,
@@ -42,6 +42,8 @@ const CSS = `
42
42
  .fyodos-bubble-row{display:flex;gap:8px;align-items:flex-end;padding:8px 10px;border-top:1px solid rgba(160,180,220,0.18);}
43
43
  .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;}
44
44
  .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;}
45
+ .fyodos-thought{position:absolute;appearance:none;display:inline-block;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;line-height:1.25;font-weight:500;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,0.22);animation:fyodos-thought-in .18s ease-out;}
46
+ @keyframes fyodos-thought-in{from{opacity:0;transform:translateY(4px) scale(0.96)}to{opacity:1;transform:none}}
45
47
  @keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
46
48
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
47
49
  @keyframes fy-spin{to{transform:rotate(360deg)}}
@@ -130,6 +132,12 @@ function mountOrb(controller, options = {}) {
130
132
  let publishedAppearance = null;
131
133
  let chipTheme = null;
132
134
  let modalTheme;
135
+ let bubbleTheme = (0, core_1.resolveBubbleTheme)(null);
136
+ let bubbleEnabled = true;
137
+ // Dashboard VOICE switch. Default true (voice on). When false the orb never
138
+ // listens/speaks: a tap opens the keyboard directly and text turns are silent.
139
+ let voiceInputEnabled = true;
140
+ let currentRoute = null;
133
141
  let screenPosition = options.corner
134
142
  ? cornerToPosition(options.corner)
135
143
  : { ...core_1.DEFAULT_ORB_SCREEN_POSITION };
@@ -160,6 +168,22 @@ function mountOrb(controller, options = {}) {
160
168
  anchor.appendChild(btn);
161
169
  root.appendChild(anchor);
162
170
  let chipEl = null;
171
+ // Thought bubble: a short, screen-aware CTA beside the orb. Created once and
172
+ // shown/hidden by syncThoughtBubble; tapping it opens the keyboard.
173
+ const thoughtBubbleEl = document.createElement('button');
174
+ thoughtBubbleEl.type = 'button';
175
+ thoughtBubbleEl.className = 'fyodos-thought';
176
+ thoughtBubbleEl.style.display = 'none';
177
+ thoughtBubbleEl.addEventListener('click', () => {
178
+ haptics.trigger('orbTap');
179
+ controller.primeAudio();
180
+ if (enableTextInput) {
181
+ openTextPanel();
182
+ return;
183
+ }
184
+ void controller.toggleListening();
185
+ });
186
+ anchor.appendChild(thoughtBubbleEl);
163
187
  container.appendChild(root);
164
188
  // ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
165
189
  // The orb ALWAYS starts at the dashboard-published position on every load.
@@ -209,6 +233,9 @@ function mountOrb(controller, options = {}) {
209
233
  chipEl.style.right = '';
210
234
  }
211
235
  }
236
+ // Keep the thought bubble on the orb's free side too while it is visible.
237
+ if (thoughtBubbleEl.style.display !== 'none')
238
+ positionThoughtBubble();
212
239
  }
213
240
  window.addEventListener('resize', reposition);
214
241
  // Recalc when a scrollbar appears/disappears as the page content changes (the
@@ -459,6 +486,65 @@ function mountOrb(controller, options = {}) {
459
486
  anchor.appendChild(chipEl);
460
487
  applyDeployment();
461
488
  }
489
+ // ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
490
+ function resolveCurrentBubblePrompt() {
491
+ return (0, core_1.resolveBubblePrompt)((0, core_1.matchRouteIntent)(currentRoute, controller.config.manifest.routes), controller.messages, { locale: controller.config.locale, actions: controller.config.manifest.actions });
492
+ }
493
+ function applyThoughtBubbleTheme() {
494
+ const t = bubbleTheme;
495
+ const { fontPx, padV, padH, approxHeight } = (0, core_1.resolveBubblePadding)(t);
496
+ const shapeStyle = (0, core_1.resolveBubbleShapeStyle)(t, approxHeight);
497
+ thoughtBubbleEl.style.fontSize = `${fontPx}px`;
498
+ thoughtBubbleEl.style.padding = `${padV}px ${padH}px`;
499
+ thoughtBubbleEl.style.color = t.textColor;
500
+ thoughtBubbleEl.style.background = t.backgroundColor;
501
+ thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
502
+ thoughtBubbleEl.style.borderRadius =
503
+ shapeStyle.borderRadius != null ? `${shapeStyle.borderRadius}px` : '';
504
+ thoughtBubbleEl.style.borderTopLeftRadius =
505
+ shapeStyle.borderTopLeftRadius != null ? `${shapeStyle.borderTopLeftRadius}px` : '';
506
+ thoughtBubbleEl.style.borderTopRightRadius =
507
+ shapeStyle.borderTopRightRadius != null ? `${shapeStyle.borderTopRightRadius}px` : '';
508
+ thoughtBubbleEl.style.borderBottomLeftRadius =
509
+ shapeStyle.borderBottomLeftRadius != null ? `${shapeStyle.borderBottomLeftRadius}px` : '';
510
+ thoughtBubbleEl.style.borderBottomRightRadius =
511
+ shapeStyle.borderBottomRightRadius != null ? `${shapeStyle.borderBottomRightRadius}px` : '';
512
+ thoughtBubbleEl.style.clipPath = shapeStyle.clipPath ?? '';
513
+ }
514
+ function positionThoughtBubble() {
515
+ const dep = (0, core_1.resolveOrbDeployment)(effectivePosition());
516
+ if (dep.horizontal === 'left') {
517
+ thoughtBubbleEl.style.right = 'calc(100% + 10px)';
518
+ thoughtBubbleEl.style.left = '';
519
+ }
520
+ else {
521
+ thoughtBubbleEl.style.left = 'calc(100% + 10px)';
522
+ thoughtBubbleEl.style.right = '';
523
+ }
524
+ if (dep.vertical === 'up') {
525
+ thoughtBubbleEl.style.top = '0';
526
+ thoughtBubbleEl.style.bottom = '';
527
+ thoughtBubbleEl.style.transform = '';
528
+ }
529
+ else {
530
+ thoughtBubbleEl.style.bottom = '0';
531
+ thoughtBubbleEl.style.top = '';
532
+ thoughtBubbleEl.style.transform = '';
533
+ }
534
+ }
535
+ function syncThoughtBubble(state) {
536
+ const idle = !state.showThinking && !state.showWaveform && state.phase !== 'speaking' && !micFallbackActive;
537
+ const prompt = bubbleEnabled && !bubbleOpen && idle ? resolveCurrentBubblePrompt() : null;
538
+ if (!prompt) {
539
+ thoughtBubbleEl.style.display = 'none';
540
+ return;
541
+ }
542
+ thoughtBubbleEl.textContent = prompt;
543
+ thoughtBubbleEl.setAttribute('aria-label', ui.orbLabel);
544
+ applyThoughtBubbleTheme();
545
+ positionThoughtBubble();
546
+ thoughtBubbleEl.style.display = 'block';
547
+ }
462
548
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
463
549
  let overlayEl = null;
464
550
  function clearOverlay() {
@@ -571,9 +657,16 @@ function mountOrb(controller, options = {}) {
571
657
  setBubble(false);
572
658
  return;
573
659
  }
660
+ // Voice disabled in the dashboard: never listen/speak. Tap opens the keyboard
661
+ // directly (text turns are silent by default); animations are unchanged.
662
+ if (!voiceInputEnabled && enableTextInput) {
663
+ micFallbackActive = false;
664
+ openTextPanel();
665
+ return;
666
+ }
574
667
  // `voiceAvailable` is dynamic: the Web Speech API may be present yet the mic
575
668
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
576
- if (controller.voiceAvailable) {
669
+ if (controller.voiceAvailable && voiceInputEnabled) {
577
670
  micFallbackActive = false;
578
671
  void controller.toggleListening();
579
672
  return;
@@ -663,6 +756,7 @@ function mountOrb(controller, options = {}) {
663
756
  sendBtn.disabled = false;
664
757
  }
665
758
  syncChip(state);
759
+ syncThoughtBubble(state);
666
760
  if (state.confirmationMessage !== lastConfirm) {
667
761
  lastConfirm = state.confirmationMessage;
668
762
  if (state.confirmationMessage)
@@ -680,6 +774,26 @@ function mountOrb(controller, options = {}) {
680
774
  }
681
775
  const unsubscribe = controller.subscribe(render);
682
776
  render(controller.getState());
777
+ // Track the current route so the thought bubble re-resolves its CTA on
778
+ // navigation. No universal navigation event exists, so poll + listen to
779
+ // popstate / visibility for snappier updates.
780
+ const readRoute = () => {
781
+ let next = null;
782
+ try {
783
+ next = controller.config.navigation.getCurrentRoute();
784
+ }
785
+ catch {
786
+ /* navigation read is best-effort */
787
+ }
788
+ if (next !== currentRoute) {
789
+ currentRoute = next;
790
+ syncThoughtBubble(controller.getState());
791
+ }
792
+ };
793
+ readRoute();
794
+ const routeTimer = window.setInterval(readRoute, 700);
795
+ window.addEventListener('popstate', readRoute);
796
+ document.addEventListener('visibilitychange', readRoute);
683
797
  // ── Live dashboard config ────────────────────────────────────────────────────
684
798
  const stopWatch = (0, publishedConfig_1.watchPublishedConfig)(controller, {
685
799
  pollMs: options.appearancePollMs,
@@ -692,6 +806,9 @@ function mountOrb(controller, options = {}) {
692
806
  publishedAppearance = config.appearance;
693
807
  chipTheme = config.keyboardChip;
694
808
  modalTheme = config.modalTheme;
809
+ bubbleTheme = config.bubbleTheme;
810
+ bubbleEnabled = config.bubbleEnabled;
811
+ voiceInputEnabled = config.voiceInputEnabled;
695
812
  if (usePublishedPosition && config.screenPosition)
696
813
  screenPosition = config.screenPosition;
697
814
  }
@@ -702,6 +819,7 @@ function mountOrb(controller, options = {}) {
702
819
  chipEl = null;
703
820
  }
704
821
  syncChip(controller.getState());
822
+ syncThoughtBubble(controller.getState());
705
823
  if (bubbleOpen)
706
824
  applyBubbleTheme();
707
825
  },
@@ -710,6 +828,9 @@ function mountOrb(controller, options = {}) {
710
828
  unmount() {
711
829
  unsubscribe();
712
830
  stopWatch();
831
+ window.clearInterval(routeTimer);
832
+ window.removeEventListener('popstate', readRoute);
833
+ document.removeEventListener('visibilitychange', readRoute);
713
834
  window.removeEventListener('resize', reposition);
714
835
  contentObserver?.disconnect();
715
836
  visualViewport?.removeEventListener('resize', onViewportChange);
@@ -9,7 +9,7 @@
9
9
  * mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
10
10
  * config identically to the reference, instead of ignoring it.
11
11
  */
12
- import { type OrbScreenPosition, type PublishedModalTheme } from '@fiodos/core';
12
+ import { type OrbScreenPosition, type PublishedModalTheme, type ResolvedBubbleTheme } from '@fiodos/core';
13
13
  import type { AgentController } from '../controller/AgentController';
14
14
  import { type ChipTheme, type OrbAppearance } from './orbView';
15
15
  export interface ResolvedOrbConfig {
@@ -17,6 +17,16 @@ export interface ResolvedOrbConfig {
17
17
  keyboardChip: ChipTheme | null;
18
18
  modalTheme: PublishedModalTheme | undefined;
19
19
  screenPosition: OrbScreenPosition | undefined;
20
+ /** Resolved orb thought-bubble theme (defaults filled). */
21
+ bubbleTheme: ResolvedBubbleTheme;
22
+ /** Dashboard on/off for the thought bubble. Default true (absent = enabled). */
23
+ bubbleEnabled: boolean;
24
+ /**
25
+ * Dashboard on/off for the orb's VOICE channel (`voiceInputEnabled`). False
26
+ * ONLY when the developer turned voice off; then the orb opens the keyboard
27
+ * directly on tap and never plays TTS. Default true (absent = voice on).
28
+ */
29
+ voiceInputEnabled: boolean;
20
30
  /**
21
31
  * Dashboard master visibility switch (`orbEnabled`). False ONLY when the
22
32
  * developer explicitly turned the orb off. The orb is hidden when false and
@@ -14,7 +14,8 @@ exports.watchPublishedConfig = watchPublishedConfig;
14
14
  */
15
15
  const core_1 = require("@fiodos/core");
16
16
  const orbView_1 = require("./orbView");
17
- function toResolved(props, enabled) {
17
+ function toResolved(props, published) {
18
+ const enabled = published.orbEnabled !== false;
18
19
  const theme = props.theme;
19
20
  const appearance = {
20
21
  accentColor: theme.accentColor ?? orbView_1.DEFAULT_ORB_APPEARANCE.accentColor,
@@ -33,6 +34,9 @@ function toResolved(props, enabled) {
33
34
  keyboardChip: theme.keyboardChip ?? null,
34
35
  modalTheme: props.modalTheme,
35
36
  screenPosition: props.screenPosition,
37
+ bubbleTheme: (0, core_1.resolveBubbleTheme)(published.bubbleTheme),
38
+ bubbleEnabled: published.bubbleEnabled !== false,
39
+ voiceInputEnabled: published.voiceInputEnabled !== false,
36
40
  enabled,
37
41
  };
38
42
  }
@@ -54,7 +58,7 @@ function watchPublishedConfig(controller, options) {
54
58
  if (cancelled)
55
59
  return;
56
60
  options.onChange(res.published
57
- ? toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published), res.published.orbEnabled !== false)
61
+ ? toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published), res.published)
58
62
  : null);
59
63
  }
60
64
  catch {
@@ -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.5";
8
+ export declare const SDK_VERSION = "0.1.9";
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.5';
11
+ exports.SDK_VERSION = '0.1.9';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';
@@ -398,20 +398,40 @@ export function createWebVoiceAdapter(options = {}) {
398
398
  // later programmatic playback (after the network round-trip) is allowed
399
399
  // and audible even with the silent switch on.
400
400
  const ctx = ensureAudioCtx();
401
- if (!ctx)
402
- return;
403
- try {
404
- if (ctx.state === 'suspended')
405
- void ctx.resume();
406
- // A 1-frame silent buffer fully unlocks playback on iOS Safari.
407
- const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
408
- const source = ctx.createBufferSource();
409
- source.buffer = buffer;
410
- source.connect(ctx.destination);
411
- source.start(0);
401
+ if (ctx) {
402
+ try {
403
+ if (ctx.state === 'suspended')
404
+ void ctx.resume();
405
+ // A 1-frame silent buffer fully unlocks playback on iOS Safari.
406
+ const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
407
+ const source = ctx.createBufferSource();
408
+ source.buffer = buffer;
409
+ source.connect(ctx.destination);
410
+ source.start(0);
411
+ }
412
+ catch {
413
+ /* best-effort unlock */
414
+ }
412
415
  }
413
- catch {
414
- /* best-effort unlock */
416
+ // Unlock device TTS (SpeechSynthesis) too. The spoken reply happens AFTER
417
+ // the network round-trip outside this gesture — and Safari/Chrome stay
418
+ // SILENT for a first programmatic speak that wasn't preceded by a
419
+ // gesture-initiated one. Warm the engine here: kick off voice loading and
420
+ // speak a zero-volume blank utterance inside the tap so the real reply is
421
+ // allowed to play later.
422
+ if (!options.disableDeviceTtsFallback && typeof window !== 'undefined' && window.speechSynthesis) {
423
+ try {
424
+ const synth = window.speechSynthesis;
425
+ synth.getVoices(); // triggers async voice loading on Chrome
426
+ if (synth.paused)
427
+ synth.resume();
428
+ const warm = new SpeechSynthesisUtterance(' ');
429
+ warm.volume = 0;
430
+ synth.speak(warm);
431
+ }
432
+ catch {
433
+ /* best-effort unlock */
434
+ }
415
435
  }
416
436
  },
417
437
  async playAudioBase64Mp3(base64) {
@@ -453,20 +473,32 @@ export function createWebVoiceAdapter(options = {}) {
453
473
  // Voices load lazily on Chrome — speaking before they exist is silent.
454
474
  const voices = await loadVoicesOnce(synth);
455
475
  return new Promise((resolve) => {
476
+ let keepAlive = null;
477
+ const cleanup = () => {
478
+ if (keepAlive) {
479
+ clearInterval(keepAlive);
480
+ keepAlive = null;
481
+ }
482
+ };
456
483
  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();
484
+ // Clear any previous/wedged queue. After a prior cancel() (e.g. the
485
+ // stopPlayback() fired when listening started) Chrome on macOS can sit
486
+ // in a stuck "paused" state that does NOT report paused===true, so a
487
+ // conditional resume() never fires and every later speak() is silent.
488
+ synth.cancel();
463
489
  const utter = new SpeechSynthesisUtterance(text);
464
490
  utter.lang = ttsOptions.locale;
465
491
  const voice = pickVoiceForLocale(voices, ttsOptions.locale);
466
492
  if (voice)
467
493
  utter.voice = voice;
494
+ utter.volume = 1;
495
+ utter.rate = 1;
496
+ utter.pitch = 1;
497
+ // Keep a strong ref so Chrome can't GC the utterance mid-speech.
468
498
  ttsUtterance = utter;
469
499
  const finish = () => {
500
+ cleanup();
501
+ utter.onstart = null;
470
502
  utter.onend = null;
471
503
  utter.onerror = null;
472
504
  if (ttsUtterance === utter)
@@ -476,8 +508,22 @@ export function createWebVoiceAdapter(options = {}) {
476
508
  utter.onend = finish;
477
509
  utter.onerror = finish;
478
510
  synth.speak(utter);
511
+ // THE FIX: resume() unconditionally right after queuing. This un-wedges
512
+ // the engine from the silent stuck-paused state described above.
513
+ synth.resume();
514
+ // Chrome desktop stops speaking after ~15s; pulse pause/resume to keep
515
+ // long replies going, and stop the timer once it has finished.
516
+ keepAlive = setInterval(() => {
517
+ if (!synth.speaking && !synth.pending) {
518
+ cleanup();
519
+ return;
520
+ }
521
+ synth.pause();
522
+ synth.resume();
523
+ }, 9000);
479
524
  }
480
525
  catch {
526
+ cleanup();
481
527
  resolve();
482
528
  }
483
529
  });
@@ -75,6 +75,7 @@ export declare class AgentController {
75
75
  private watchdog;
76
76
  private confirmWindow;
77
77
  private speakTimer;
78
+ private serverTtsUnavailable;
78
79
  private pendingConsentIntent;
79
80
  private readonly listeners;
80
81
  private unsubscribeSpeech;
@@ -15,7 +15,7 @@
15
15
  * deliberate modal tap; a loose "yes" never confirms them.
16
16
  * - The agent only ever executes actions declared in the manifest.
17
17
  */
18
- import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, createConversationSession, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
18
+ import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, createConversationSession, defaultRouteContextText, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
19
19
  import { AgentApiError } from '../api/errors.js';
20
20
  import { formatAgentTurnError } from '../api/formatTurnError.js';
21
21
  import { buildManifestPayload } from '../api/backendClient.js';
@@ -51,6 +51,9 @@ export class AgentController {
51
51
  // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
52
52
  // never reports completion (e.g. a stalled SpeechSynthesis engine).
53
53
  this.speakTimer = null;
54
+ // Once the backend shows it has no server-side TTS (a reply arrives with null
55
+ // audio), skip the extra /preview-tts round-trip and speak via device voice.
56
+ this.serverTtsUnavailable = false;
54
57
  this.pendingConsentIntent = null;
55
58
  this.listeners = new Set();
56
59
  this.unsubscribeSpeech = null;
@@ -87,7 +90,11 @@ export class AgentController {
87
90
  });
88
91
  this.screenStore = createScreenContextStore({
89
92
  getCurrentRoute: () => config.navigation.getCurrentRoute(),
90
- routeFallback: config.routeContextFallback,
93
+ // The integrator's fallback wins; otherwise the orb gets screen awareness
94
+ // out of the box from the matched manifest route (label + description +
95
+ // available actions). Null when no route matches → raw route, as today.
96
+ routeFallback: config.routeContextFallback ??
97
+ ((route) => defaultRouteContextText(route, config.manifest, this.messages)),
91
98
  });
92
99
  this.speech = createSpeechSession({
93
100
  adapter: config.voice,
@@ -194,13 +201,20 @@ export class AgentController {
194
201
  this.setPhase('speaking');
195
202
  try {
196
203
  let audio = audioBase64;
197
- if (!audio && text) {
198
- // A server-TTS failure must NOT skip the device-voice fallback below.
204
+ if (audio) {
205
+ this.serverTtsUnavailable = false; // server voice is working
206
+ }
207
+ // Once we know the backend ships no server TTS, skip the dead
208
+ // /preview-tts round-trip (pure latency) and use the device voice.
209
+ if (!audio && text && !this.serverTtsUnavailable) {
199
210
  try {
200
211
  audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
212
+ if (!audio)
213
+ this.serverTtsUnavailable = true;
201
214
  }
202
215
  catch {
203
216
  audio = null;
217
+ this.serverTtsUnavailable = true;
204
218
  }
205
219
  }
206
220
  const playback = audio
@@ -415,6 +429,10 @@ export class AgentController {
415
429
  }, { signal: controller.signal });
416
430
  reply = turn.reply;
417
431
  audioBase64 = turn.audioBase64;
432
+ // Learn whether this backend ships server-side TTS: a non-empty reply
433
+ // with no audio means it doesn't — later lines then speak instantly.
434
+ if (reply)
435
+ this.serverTtsUnavailable = !audioBase64;
418
436
  const decision = await decideAction({
419
437
  action: turn.action,
420
438
  manifest: this.config.manifest,
@@ -27,8 +27,12 @@ export interface CreateFiodosAgentOptions {
27
27
  manifest?: AppManifest;
28
28
  /** Action handlers / context validators (defaults to empty registries). */
29
29
  registries?: ActionRegistries;
30
- /** Agent reply locale (e.g. 'es', 'en'). Defaults to the browser language. */
30
+ /** Agent UI locale (e.g. 'en', 'es'). Defaults to `'en'`. */
31
31
  locale?: string;
32
+ /** Live locale resolver — overrides `locale` when set. */
33
+ getLocale?: () => string;
34
+ /** Auto-detect locale when `locale` / `getLocale` are unset. Default: none (English). */
35
+ localeDetection?: 'browser' | 'document';
32
36
  /** STT locale (e.g. 'es-ES', 'en-US'). Defaults to the browser language. */
33
37
  sttLocale?: string;
34
38
  /** Host router navigate delegate. Defaults to location.assign. */
@@ -29,10 +29,27 @@ function browserLocale() {
29
29
  const lang = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en-US';
30
30
  return { locale: lang.split('-')[0] || 'en', sttLocale: lang };
31
31
  }
32
+ function resolveAgentLocale(options) {
33
+ const fromApp = options.getLocale?.()?.trim();
34
+ if (fromApp)
35
+ return fromApp;
36
+ if (options.locale?.trim())
37
+ return options.locale.trim();
38
+ if (options.localeDetection === 'browser')
39
+ return browserLocale().locale;
40
+ if (options.localeDetection === 'document') {
41
+ const docLang = typeof document !== 'undefined'
42
+ ? document.documentElement.lang?.split(/[-_]/)[0]?.trim()
43
+ : '';
44
+ if (docLang)
45
+ return docLang;
46
+ }
47
+ return 'en';
48
+ }
32
49
  /** Build the controller + adapters from a known manifest (shared by both paths). */
33
50
  function assemble(options, manifest, baseUrl) {
34
51
  const fallback = browserLocale();
35
- const locale = options.locale ?? fallback.locale;
52
+ const locale = resolveAgentLocale(options);
36
53
  const sttLocale = options.sttLocale ?? options.locale ?? fallback.sttLocale;
37
54
  const backend = createFiodosBackendClient({
38
55
  baseUrl,
@@ -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, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
19
+ import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, resolveBubbleTheme, resolveBubblePadding, resolveBubbleShapeStyle, 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';
@@ -39,6 +39,8 @@ const CSS = `
39
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
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
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
+ .fyodos-thought{position:absolute;appearance:none;display:inline-block;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;line-height:1.25;font-weight:500;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,0.22);animation:fyodos-thought-in .18s ease-out;}
43
+ @keyframes fyodos-thought-in{from{opacity:0;transform:translateY(4px) scale(0.96)}to{opacity:1;transform:none}}
42
44
  @keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
43
45
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
44
46
  @keyframes fy-spin{to{transform:rotate(360deg)}}
@@ -127,6 +129,12 @@ export function mountOrb(controller, options = {}) {
127
129
  let publishedAppearance = null;
128
130
  let chipTheme = null;
129
131
  let modalTheme;
132
+ let bubbleTheme = resolveBubbleTheme(null);
133
+ let bubbleEnabled = true;
134
+ // Dashboard VOICE switch. Default true (voice on). When false the orb never
135
+ // listens/speaks: a tap opens the keyboard directly and text turns are silent.
136
+ let voiceInputEnabled = true;
137
+ let currentRoute = null;
130
138
  let screenPosition = options.corner
131
139
  ? cornerToPosition(options.corner)
132
140
  : { ...DEFAULT_ORB_SCREEN_POSITION };
@@ -157,6 +165,22 @@ export function mountOrb(controller, options = {}) {
157
165
  anchor.appendChild(btn);
158
166
  root.appendChild(anchor);
159
167
  let chipEl = null;
168
+ // Thought bubble: a short, screen-aware CTA beside the orb. Created once and
169
+ // shown/hidden by syncThoughtBubble; tapping it opens the keyboard.
170
+ const thoughtBubbleEl = document.createElement('button');
171
+ thoughtBubbleEl.type = 'button';
172
+ thoughtBubbleEl.className = 'fyodos-thought';
173
+ thoughtBubbleEl.style.display = 'none';
174
+ thoughtBubbleEl.addEventListener('click', () => {
175
+ haptics.trigger('orbTap');
176
+ controller.primeAudio();
177
+ if (enableTextInput) {
178
+ openTextPanel();
179
+ return;
180
+ }
181
+ void controller.toggleListening();
182
+ });
183
+ anchor.appendChild(thoughtBubbleEl);
160
184
  container.appendChild(root);
161
185
  // ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
162
186
  // The orb ALWAYS starts at the dashboard-published position on every load.
@@ -206,6 +230,9 @@ export function mountOrb(controller, options = {}) {
206
230
  chipEl.style.right = '';
207
231
  }
208
232
  }
233
+ // Keep the thought bubble on the orb's free side too while it is visible.
234
+ if (thoughtBubbleEl.style.display !== 'none')
235
+ positionThoughtBubble();
209
236
  }
210
237
  window.addEventListener('resize', reposition);
211
238
  // Recalc when a scrollbar appears/disappears as the page content changes (the
@@ -456,6 +483,65 @@ export function mountOrb(controller, options = {}) {
456
483
  anchor.appendChild(chipEl);
457
484
  applyDeployment();
458
485
  }
486
+ // ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
487
+ function resolveCurrentBubblePrompt() {
488
+ return resolveBubblePrompt(matchRouteIntent(currentRoute, controller.config.manifest.routes), controller.messages, { locale: controller.config.locale, actions: controller.config.manifest.actions });
489
+ }
490
+ function applyThoughtBubbleTheme() {
491
+ const t = bubbleTheme;
492
+ const { fontPx, padV, padH, approxHeight } = resolveBubblePadding(t);
493
+ const shapeStyle = resolveBubbleShapeStyle(t, approxHeight);
494
+ thoughtBubbleEl.style.fontSize = `${fontPx}px`;
495
+ thoughtBubbleEl.style.padding = `${padV}px ${padH}px`;
496
+ thoughtBubbleEl.style.color = t.textColor;
497
+ thoughtBubbleEl.style.background = t.backgroundColor;
498
+ thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
499
+ thoughtBubbleEl.style.borderRadius =
500
+ shapeStyle.borderRadius != null ? `${shapeStyle.borderRadius}px` : '';
501
+ thoughtBubbleEl.style.borderTopLeftRadius =
502
+ shapeStyle.borderTopLeftRadius != null ? `${shapeStyle.borderTopLeftRadius}px` : '';
503
+ thoughtBubbleEl.style.borderTopRightRadius =
504
+ shapeStyle.borderTopRightRadius != null ? `${shapeStyle.borderTopRightRadius}px` : '';
505
+ thoughtBubbleEl.style.borderBottomLeftRadius =
506
+ shapeStyle.borderBottomLeftRadius != null ? `${shapeStyle.borderBottomLeftRadius}px` : '';
507
+ thoughtBubbleEl.style.borderBottomRightRadius =
508
+ shapeStyle.borderBottomRightRadius != null ? `${shapeStyle.borderBottomRightRadius}px` : '';
509
+ thoughtBubbleEl.style.clipPath = shapeStyle.clipPath ?? '';
510
+ }
511
+ function positionThoughtBubble() {
512
+ const dep = resolveOrbDeployment(effectivePosition());
513
+ if (dep.horizontal === 'left') {
514
+ thoughtBubbleEl.style.right = 'calc(100% + 10px)';
515
+ thoughtBubbleEl.style.left = '';
516
+ }
517
+ else {
518
+ thoughtBubbleEl.style.left = 'calc(100% + 10px)';
519
+ thoughtBubbleEl.style.right = '';
520
+ }
521
+ if (dep.vertical === 'up') {
522
+ thoughtBubbleEl.style.top = '0';
523
+ thoughtBubbleEl.style.bottom = '';
524
+ thoughtBubbleEl.style.transform = '';
525
+ }
526
+ else {
527
+ thoughtBubbleEl.style.bottom = '0';
528
+ thoughtBubbleEl.style.top = '';
529
+ thoughtBubbleEl.style.transform = '';
530
+ }
531
+ }
532
+ function syncThoughtBubble(state) {
533
+ const idle = !state.showThinking && !state.showWaveform && state.phase !== 'speaking' && !micFallbackActive;
534
+ const prompt = bubbleEnabled && !bubbleOpen && idle ? resolveCurrentBubblePrompt() : null;
535
+ if (!prompt) {
536
+ thoughtBubbleEl.style.display = 'none';
537
+ return;
538
+ }
539
+ thoughtBubbleEl.textContent = prompt;
540
+ thoughtBubbleEl.setAttribute('aria-label', ui.orbLabel);
541
+ applyThoughtBubbleTheme();
542
+ positionThoughtBubble();
543
+ thoughtBubbleEl.style.display = 'block';
544
+ }
459
545
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
460
546
  let overlayEl = null;
461
547
  function clearOverlay() {
@@ -568,9 +654,16 @@ export function mountOrb(controller, options = {}) {
568
654
  setBubble(false);
569
655
  return;
570
656
  }
657
+ // Voice disabled in the dashboard: never listen/speak. Tap opens the keyboard
658
+ // directly (text turns are silent by default); animations are unchanged.
659
+ if (!voiceInputEnabled && enableTextInput) {
660
+ micFallbackActive = false;
661
+ openTextPanel();
662
+ return;
663
+ }
571
664
  // `voiceAvailable` is dynamic: the Web Speech API may be present yet the mic
572
665
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
573
- if (controller.voiceAvailable) {
666
+ if (controller.voiceAvailable && voiceInputEnabled) {
574
667
  micFallbackActive = false;
575
668
  void controller.toggleListening();
576
669
  return;
@@ -660,6 +753,7 @@ export function mountOrb(controller, options = {}) {
660
753
  sendBtn.disabled = false;
661
754
  }
662
755
  syncChip(state);
756
+ syncThoughtBubble(state);
663
757
  if (state.confirmationMessage !== lastConfirm) {
664
758
  lastConfirm = state.confirmationMessage;
665
759
  if (state.confirmationMessage)
@@ -677,6 +771,26 @@ export function mountOrb(controller, options = {}) {
677
771
  }
678
772
  const unsubscribe = controller.subscribe(render);
679
773
  render(controller.getState());
774
+ // Track the current route so the thought bubble re-resolves its CTA on
775
+ // navigation. No universal navigation event exists, so poll + listen to
776
+ // popstate / visibility for snappier updates.
777
+ const readRoute = () => {
778
+ let next = null;
779
+ try {
780
+ next = controller.config.navigation.getCurrentRoute();
781
+ }
782
+ catch {
783
+ /* navigation read is best-effort */
784
+ }
785
+ if (next !== currentRoute) {
786
+ currentRoute = next;
787
+ syncThoughtBubble(controller.getState());
788
+ }
789
+ };
790
+ readRoute();
791
+ const routeTimer = window.setInterval(readRoute, 700);
792
+ window.addEventListener('popstate', readRoute);
793
+ document.addEventListener('visibilitychange', readRoute);
680
794
  // ── Live dashboard config ────────────────────────────────────────────────────
681
795
  const stopWatch = watchPublishedConfig(controller, {
682
796
  pollMs: options.appearancePollMs,
@@ -689,6 +803,9 @@ export function mountOrb(controller, options = {}) {
689
803
  publishedAppearance = config.appearance;
690
804
  chipTheme = config.keyboardChip;
691
805
  modalTheme = config.modalTheme;
806
+ bubbleTheme = config.bubbleTheme;
807
+ bubbleEnabled = config.bubbleEnabled;
808
+ voiceInputEnabled = config.voiceInputEnabled;
692
809
  if (usePublishedPosition && config.screenPosition)
693
810
  screenPosition = config.screenPosition;
694
811
  }
@@ -699,6 +816,7 @@ export function mountOrb(controller, options = {}) {
699
816
  chipEl = null;
700
817
  }
701
818
  syncChip(controller.getState());
819
+ syncThoughtBubble(controller.getState());
702
820
  if (bubbleOpen)
703
821
  applyBubbleTheme();
704
822
  },
@@ -707,6 +825,9 @@ export function mountOrb(controller, options = {}) {
707
825
  unmount() {
708
826
  unsubscribe();
709
827
  stopWatch();
828
+ window.clearInterval(routeTimer);
829
+ window.removeEventListener('popstate', readRoute);
830
+ document.removeEventListener('visibilitychange', readRoute);
710
831
  window.removeEventListener('resize', reposition);
711
832
  contentObserver?.disconnect();
712
833
  visualViewport?.removeEventListener('resize', onViewportChange);
@@ -9,7 +9,7 @@
9
9
  * mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
10
10
  * config identically to the reference, instead of ignoring it.
11
11
  */
12
- import { type OrbScreenPosition, type PublishedModalTheme } from '@fiodos/core';
12
+ import { type OrbScreenPosition, type PublishedModalTheme, type ResolvedBubbleTheme } from '@fiodos/core';
13
13
  import type { AgentController } from '../controller/AgentController.js';
14
14
  import { type ChipTheme, type OrbAppearance } from './orbView.js';
15
15
  export interface ResolvedOrbConfig {
@@ -17,6 +17,16 @@ export interface ResolvedOrbConfig {
17
17
  keyboardChip: ChipTheme | null;
18
18
  modalTheme: PublishedModalTheme | undefined;
19
19
  screenPosition: OrbScreenPosition | undefined;
20
+ /** Resolved orb thought-bubble theme (defaults filled). */
21
+ bubbleTheme: ResolvedBubbleTheme;
22
+ /** Dashboard on/off for the thought bubble. Default true (absent = enabled). */
23
+ bubbleEnabled: boolean;
24
+ /**
25
+ * Dashboard on/off for the orb's VOICE channel (`voiceInputEnabled`). False
26
+ * ONLY when the developer turned voice off; then the orb opens the keyboard
27
+ * directly on tap and never plays TTS. Default true (absent = voice on).
28
+ */
29
+ voiceInputEnabled: boolean;
20
30
  /**
21
31
  * Dashboard master visibility switch (`orbEnabled`). False ONLY when the
22
32
  * developer explicitly turned the orb off. The orb is hidden when false and
@@ -9,9 +9,10 @@
9
9
  * mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
10
10
  * config identically to the reference, instead of ignoring it.
11
11
  */
12
- import { mapPublishedConfigToOrbProps, } from '@fiodos/core';
12
+ import { mapPublishedConfigToOrbProps, resolveBubbleTheme, } from '@fiodos/core';
13
13
  import { DEFAULT_ORB_APPEARANCE } from './orbView.js';
14
- function toResolved(props, enabled) {
14
+ function toResolved(props, published) {
15
+ const enabled = published.orbEnabled !== false;
15
16
  const theme = props.theme;
16
17
  const appearance = {
17
18
  accentColor: theme.accentColor ?? DEFAULT_ORB_APPEARANCE.accentColor,
@@ -30,6 +31,9 @@ function toResolved(props, enabled) {
30
31
  keyboardChip: theme.keyboardChip ?? null,
31
32
  modalTheme: props.modalTheme,
32
33
  screenPosition: props.screenPosition,
34
+ bubbleTheme: resolveBubbleTheme(published.bubbleTheme),
35
+ bubbleEnabled: published.bubbleEnabled !== false,
36
+ voiceInputEnabled: published.voiceInputEnabled !== false,
33
37
  enabled,
34
38
  };
35
39
  }
@@ -51,7 +55,7 @@ export function watchPublishedConfig(controller, options) {
51
55
  if (cancelled)
52
56
  return;
53
57
  options.onChange(res.published
54
- ? toResolved(mapPublishedConfigToOrbProps(res.published), res.published.orbEnabled !== false)
58
+ ? toResolved(mapPublishedConfigToOrbProps(res.published), res.published)
55
59
  : null);
56
60
  }
57
61
  catch {
@@ -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.5";
8
+ export declare const SDK_VERSION = "0.1.9";
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.5';
8
+ export const SDK_VERSION = '0.1.9';
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.5",
3
+ "version": "0.1.9",
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": {
@@ -29,7 +29,7 @@
29
29
  "prepublishOnly": "node ../../scripts/sync-sdk-version.mjs && npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@fiodos/core": "^0.1.0"
32
+ "@fiodos/core": "^0.1.7"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.0.0",