@fiodos/web-core 0.1.7 → 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.
@@ -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.7";
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.7';
11
+ exports.SDK_VERSION = '0.1.9';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';
@@ -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.7";
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.7';
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.7",
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",