@fiodos/web-core 0.1.7 → 0.1.10

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,11 @@ 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;box-sizing:border-box;display:flex;align-items:center;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;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}}
47
+ @keyframes fyodos-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}
48
+ .fyodos-thought-track{display:flex;flex-shrink:0;width:max-content;will-change:transform;}
49
+ .fyodos-thought-track>span{flex-shrink:0;padding-right:48px;white-space:nowrap;}
45
50
  @keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
46
51
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
47
52
  @keyframes fy-spin{to{transform:rotate(360deg)}}
@@ -130,6 +135,12 @@ function mountOrb(controller, options = {}) {
130
135
  let publishedAppearance = null;
131
136
  let chipTheme = null;
132
137
  let modalTheme;
138
+ let bubbleTheme = (0, core_1.resolveBubbleTheme)(null);
139
+ let bubbleEnabled = true;
140
+ // Dashboard VOICE switch. Default true (voice on). When false the orb never
141
+ // listens/speaks: a tap opens the keyboard directly and text turns are silent.
142
+ let voiceInputEnabled = true;
143
+ let currentRoute = null;
133
144
  let screenPosition = options.corner
134
145
  ? cornerToPosition(options.corner)
135
146
  : { ...core_1.DEFAULT_ORB_SCREEN_POSITION };
@@ -160,6 +171,35 @@ function mountOrb(controller, options = {}) {
160
171
  anchor.appendChild(btn);
161
172
  root.appendChild(anchor);
162
173
  let chipEl = null;
174
+ // Thought bubble: a short, screen-aware CTA beside the orb. Created once and
175
+ // shown/hidden by syncThoughtBubble; tapping it opens the keyboard.
176
+ const thoughtBubbleEl = document.createElement('div');
177
+ thoughtBubbleEl.className = 'fyodos-thought';
178
+ thoughtBubbleEl.setAttribute('role', 'button');
179
+ thoughtBubbleEl.tabIndex = 0;
180
+ thoughtBubbleEl.style.display = 'none';
181
+ thoughtBubbleEl.addEventListener('click', () => {
182
+ haptics.trigger('orbTap');
183
+ controller.primeAudio();
184
+ if (enableTextInput) {
185
+ const prompt = resolveCurrentBubblePrompt();
186
+ openTextPanel(prompt
187
+ ? (0, core_1.bubblePromptToAffirmative)(prompt, {
188
+ locale: controller.config.locale,
189
+ messages: controller.messages,
190
+ })
191
+ : undefined);
192
+ return;
193
+ }
194
+ void controller.toggleListening();
195
+ });
196
+ thoughtBubbleEl.addEventListener('keydown', (event) => {
197
+ if (event.key === 'Enter' || event.key === ' ') {
198
+ event.preventDefault();
199
+ thoughtBubbleEl.click();
200
+ }
201
+ });
202
+ anchor.appendChild(thoughtBubbleEl);
163
203
  container.appendChild(root);
164
204
  // ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
165
205
  // The orb ALWAYS starts at the dashboard-published position on every load.
@@ -209,6 +249,9 @@ function mountOrb(controller, options = {}) {
209
249
  chipEl.style.right = '';
210
250
  }
211
251
  }
252
+ // Keep the thought bubble on the orb's free side too while it is visible.
253
+ if (thoughtBubbleEl.style.display !== 'none')
254
+ positionThoughtBubble();
212
255
  }
213
256
  window.addEventListener('resize', reposition);
214
257
  // Recalc when a scrollbar appears/disappears as the page content changes (the
@@ -423,9 +466,14 @@ function mountOrb(controller, options = {}) {
423
466
  // STAYS OPEN. This is the unified, reference-matching behaviour.
424
467
  void controller.submitTextTurn(value);
425
468
  }
426
- function openTextPanel() {
469
+ function openTextPanel(seed, autoSend = false) {
427
470
  controller.cancelAll();
471
+ const draft = seed?.trim() ?? '';
472
+ textInput.value = draft;
428
473
  setBubble(true);
474
+ if (autoSend && draft) {
475
+ queueMicrotask(() => submitText());
476
+ }
429
477
  }
430
478
  // ── Keyboard chip (rebuilt when theme changes / visibility toggles) ──────────
431
479
  // Appears beside the ACTIVE orb — whether listening for real (voice granted)
@@ -459,6 +507,102 @@ function mountOrb(controller, options = {}) {
459
507
  anchor.appendChild(chipEl);
460
508
  applyDeployment();
461
509
  }
510
+ // ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
511
+ function resolveCurrentBubblePrompt() {
512
+ 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 });
513
+ }
514
+ // Render the CTA on ONE line at its NATURAL size. When the phrase is too long
515
+ // to fit the max width, scroll it (marquee, right-to-left, looping) instead of
516
+ // shrinking the font or truncating — the bubble keeps a constant height.
517
+ function layoutThoughtContent(prompt) {
518
+ const t = bubbleTheme;
519
+ const { padH } = (0, core_1.resolveBubblePadding)(t);
520
+ // Measure the natural (single-copy) text width at the base font size.
521
+ thoughtBubbleEl.style.width = '';
522
+ thoughtBubbleEl.textContent = prompt;
523
+ const measured = thoughtBubbleEl.scrollWidth;
524
+ const viewport = typeof window !== 'undefined' ? window.innerWidth : 280;
525
+ const maxW = Math.min(280, viewport * 0.7);
526
+ const overflow = measured > maxW - t.borderWidth * 2;
527
+ if (!overflow) {
528
+ thoughtBubbleEl.style.width = '';
529
+ return;
530
+ }
531
+ const textWidth = Math.max(0, measured - padH * 2);
532
+ const durationSec = Math.max(4, (textWidth + 48) / 30);
533
+ thoughtBubbleEl.style.width = `${maxW}px`;
534
+ thoughtBubbleEl.textContent = '';
535
+ const track = document.createElement('div');
536
+ track.className = 'fyodos-thought-track';
537
+ track.style.animation = `fyodos-marquee ${durationSec}s linear infinite`;
538
+ const first = document.createElement('span');
539
+ first.textContent = prompt;
540
+ const second = document.createElement('span');
541
+ second.textContent = prompt;
542
+ second.setAttribute('aria-hidden', 'true');
543
+ track.appendChild(first);
544
+ track.appendChild(second);
545
+ thoughtBubbleEl.appendChild(track);
546
+ }
547
+ function applyThoughtBubbleTheme() {
548
+ const t = bubbleTheme;
549
+ const { fontPx, padH, approxHeight } = (0, core_1.resolveBubblePadding)(t);
550
+ const shapeCss = (0, core_1.bubbleShapeToCss)(t, approxHeight);
551
+ thoughtBubbleEl.style.fontSize = `${fontPx}px`;
552
+ thoughtBubbleEl.style.height = `${approxHeight}px`;
553
+ thoughtBubbleEl.style.padding = `0 ${padH}px`;
554
+ thoughtBubbleEl.style.color = t.textColor;
555
+ thoughtBubbleEl.style.background = t.backgroundColor;
556
+ thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
557
+ const setImportant = (prop, value) => {
558
+ if (!value) {
559
+ thoughtBubbleEl.style.removeProperty(prop);
560
+ return;
561
+ }
562
+ thoughtBubbleEl.style.setProperty(prop, value, 'important');
563
+ };
564
+ setImportant('border-radius', shapeCss.borderRadius);
565
+ setImportant('border-top-left-radius', shapeCss.borderTopLeftRadius);
566
+ setImportant('border-top-right-radius', shapeCss.borderTopRightRadius);
567
+ setImportant('border-bottom-left-radius', shapeCss.borderBottomLeftRadius);
568
+ setImportant('border-bottom-right-radius', shapeCss.borderBottomRightRadius);
569
+ setImportant('clip-path', shapeCss.clipPath);
570
+ }
571
+ function positionThoughtBubble() {
572
+ const dep = (0, core_1.resolveOrbDeployment)(effectivePosition());
573
+ if (dep.horizontal === 'left') {
574
+ thoughtBubbleEl.style.right = 'calc(100% + 10px)';
575
+ thoughtBubbleEl.style.left = '';
576
+ }
577
+ else {
578
+ thoughtBubbleEl.style.left = 'calc(100% + 10px)';
579
+ thoughtBubbleEl.style.right = '';
580
+ }
581
+ if (dep.vertical === 'up') {
582
+ thoughtBubbleEl.style.top = '0';
583
+ thoughtBubbleEl.style.bottom = '';
584
+ thoughtBubbleEl.style.transform = '';
585
+ }
586
+ else {
587
+ thoughtBubbleEl.style.bottom = '0';
588
+ thoughtBubbleEl.style.top = '';
589
+ thoughtBubbleEl.style.transform = '';
590
+ }
591
+ }
592
+ function syncThoughtBubble(state) {
593
+ const idle = !state.showThinking && !state.showWaveform && state.phase !== 'speaking' && !micFallbackActive;
594
+ const prompt = bubbleEnabled && !bubbleOpen && idle ? resolveCurrentBubblePrompt() : null;
595
+ if (!prompt) {
596
+ thoughtBubbleEl.style.display = 'none';
597
+ return;
598
+ }
599
+ thoughtBubbleEl.setAttribute('aria-label', prompt);
600
+ // Display before measuring so `scrollWidth` (used to detect overflow) is real.
601
+ thoughtBubbleEl.style.display = 'flex';
602
+ applyThoughtBubbleTheme();
603
+ layoutThoughtContent(prompt);
604
+ positionThoughtBubble();
605
+ }
462
606
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
463
607
  let overlayEl = null;
464
608
  function clearOverlay() {
@@ -571,9 +715,16 @@ function mountOrb(controller, options = {}) {
571
715
  setBubble(false);
572
716
  return;
573
717
  }
718
+ // Voice disabled in the dashboard: never listen/speak. Tap opens the keyboard
719
+ // directly (text turns are silent by default); animations are unchanged.
720
+ if (!voiceInputEnabled && enableTextInput) {
721
+ micFallbackActive = false;
722
+ openTextPanel();
723
+ return;
724
+ }
574
725
  // `voiceAvailable` is dynamic: the Web Speech API may be present yet the mic
575
726
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
576
- if (controller.voiceAvailable) {
727
+ if (controller.voiceAvailable && voiceInputEnabled) {
577
728
  micFallbackActive = false;
578
729
  void controller.toggleListening();
579
730
  return;
@@ -663,6 +814,7 @@ function mountOrb(controller, options = {}) {
663
814
  sendBtn.disabled = false;
664
815
  }
665
816
  syncChip(state);
817
+ syncThoughtBubble(state);
666
818
  if (state.confirmationMessage !== lastConfirm) {
667
819
  lastConfirm = state.confirmationMessage;
668
820
  if (state.confirmationMessage)
@@ -680,6 +832,26 @@ function mountOrb(controller, options = {}) {
680
832
  }
681
833
  const unsubscribe = controller.subscribe(render);
682
834
  render(controller.getState());
835
+ // Track the current route so the thought bubble re-resolves its CTA on
836
+ // navigation. No universal navigation event exists, so poll + listen to
837
+ // popstate / visibility for snappier updates.
838
+ const readRoute = () => {
839
+ let next = null;
840
+ try {
841
+ next = controller.config.navigation.getCurrentRoute();
842
+ }
843
+ catch {
844
+ /* navigation read is best-effort */
845
+ }
846
+ if (next !== currentRoute) {
847
+ currentRoute = next;
848
+ syncThoughtBubble(controller.getState());
849
+ }
850
+ };
851
+ readRoute();
852
+ const routeTimer = window.setInterval(readRoute, 700);
853
+ window.addEventListener('popstate', readRoute);
854
+ document.addEventListener('visibilitychange', readRoute);
683
855
  // ── Live dashboard config ────────────────────────────────────────────────────
684
856
  const stopWatch = (0, publishedConfig_1.watchPublishedConfig)(controller, {
685
857
  pollMs: options.appearancePollMs,
@@ -692,6 +864,9 @@ function mountOrb(controller, options = {}) {
692
864
  publishedAppearance = config.appearance;
693
865
  chipTheme = config.keyboardChip;
694
866
  modalTheme = config.modalTheme;
867
+ bubbleTheme = config.bubbleTheme;
868
+ bubbleEnabled = config.bubbleEnabled;
869
+ voiceInputEnabled = config.voiceInputEnabled;
695
870
  if (usePublishedPosition && config.screenPosition)
696
871
  screenPosition = config.screenPosition;
697
872
  }
@@ -702,6 +877,7 @@ function mountOrb(controller, options = {}) {
702
877
  chipEl = null;
703
878
  }
704
879
  syncChip(controller.getState());
880
+ syncThoughtBubble(controller.getState());
705
881
  if (bubbleOpen)
706
882
  applyBubbleTheme();
707
883
  },
@@ -710,6 +886,9 @@ function mountOrb(controller, options = {}) {
710
886
  unmount() {
711
887
  unsubscribe();
712
888
  stopWatch();
889
+ window.clearInterval(routeTimer);
890
+ window.removeEventListener('popstate', readRoute);
891
+ document.removeEventListener('visibilitychange', readRoute);
713
892
  window.removeEventListener('resize', reposition);
714
893
  contentObserver?.disconnect();
715
894
  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.10";
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.10';
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, bubblePromptToAffirmative, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, 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,11 @@ 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;box-sizing:border-box;display:flex;align-items:center;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;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}}
44
+ @keyframes fyodos-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}
45
+ .fyodos-thought-track{display:flex;flex-shrink:0;width:max-content;will-change:transform;}
46
+ .fyodos-thought-track>span{flex-shrink:0;padding-right:48px;white-space:nowrap;}
42
47
  @keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
43
48
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
44
49
  @keyframes fy-spin{to{transform:rotate(360deg)}}
@@ -127,6 +132,12 @@ export function mountOrb(controller, options = {}) {
127
132
  let publishedAppearance = null;
128
133
  let chipTheme = null;
129
134
  let modalTheme;
135
+ let bubbleTheme = 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;
130
141
  let screenPosition = options.corner
131
142
  ? cornerToPosition(options.corner)
132
143
  : { ...DEFAULT_ORB_SCREEN_POSITION };
@@ -157,6 +168,35 @@ export function mountOrb(controller, options = {}) {
157
168
  anchor.appendChild(btn);
158
169
  root.appendChild(anchor);
159
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('div');
174
+ thoughtBubbleEl.className = 'fyodos-thought';
175
+ thoughtBubbleEl.setAttribute('role', 'button');
176
+ thoughtBubbleEl.tabIndex = 0;
177
+ thoughtBubbleEl.style.display = 'none';
178
+ thoughtBubbleEl.addEventListener('click', () => {
179
+ haptics.trigger('orbTap');
180
+ controller.primeAudio();
181
+ if (enableTextInput) {
182
+ const prompt = resolveCurrentBubblePrompt();
183
+ openTextPanel(prompt
184
+ ? bubblePromptToAffirmative(prompt, {
185
+ locale: controller.config.locale,
186
+ messages: controller.messages,
187
+ })
188
+ : undefined);
189
+ return;
190
+ }
191
+ void controller.toggleListening();
192
+ });
193
+ thoughtBubbleEl.addEventListener('keydown', (event) => {
194
+ if (event.key === 'Enter' || event.key === ' ') {
195
+ event.preventDefault();
196
+ thoughtBubbleEl.click();
197
+ }
198
+ });
199
+ anchor.appendChild(thoughtBubbleEl);
160
200
  container.appendChild(root);
161
201
  // ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
162
202
  // The orb ALWAYS starts at the dashboard-published position on every load.
@@ -206,6 +246,9 @@ export function mountOrb(controller, options = {}) {
206
246
  chipEl.style.right = '';
207
247
  }
208
248
  }
249
+ // Keep the thought bubble on the orb's free side too while it is visible.
250
+ if (thoughtBubbleEl.style.display !== 'none')
251
+ positionThoughtBubble();
209
252
  }
210
253
  window.addEventListener('resize', reposition);
211
254
  // Recalc when a scrollbar appears/disappears as the page content changes (the
@@ -420,9 +463,14 @@ export function mountOrb(controller, options = {}) {
420
463
  // STAYS OPEN. This is the unified, reference-matching behaviour.
421
464
  void controller.submitTextTurn(value);
422
465
  }
423
- function openTextPanel() {
466
+ function openTextPanel(seed, autoSend = false) {
424
467
  controller.cancelAll();
468
+ const draft = seed?.trim() ?? '';
469
+ textInput.value = draft;
425
470
  setBubble(true);
471
+ if (autoSend && draft) {
472
+ queueMicrotask(() => submitText());
473
+ }
426
474
  }
427
475
  // ── Keyboard chip (rebuilt when theme changes / visibility toggles) ──────────
428
476
  // Appears beside the ACTIVE orb — whether listening for real (voice granted)
@@ -456,6 +504,102 @@ export function mountOrb(controller, options = {}) {
456
504
  anchor.appendChild(chipEl);
457
505
  applyDeployment();
458
506
  }
507
+ // ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
508
+ function resolveCurrentBubblePrompt() {
509
+ return resolveBubblePrompt(matchRouteIntent(currentRoute, controller.config.manifest.routes), controller.messages, { locale: controller.config.locale, actions: controller.config.manifest.actions });
510
+ }
511
+ // Render the CTA on ONE line at its NATURAL size. When the phrase is too long
512
+ // to fit the max width, scroll it (marquee, right-to-left, looping) instead of
513
+ // shrinking the font or truncating — the bubble keeps a constant height.
514
+ function layoutThoughtContent(prompt) {
515
+ const t = bubbleTheme;
516
+ const { padH } = resolveBubblePadding(t);
517
+ // Measure the natural (single-copy) text width at the base font size.
518
+ thoughtBubbleEl.style.width = '';
519
+ thoughtBubbleEl.textContent = prompt;
520
+ const measured = thoughtBubbleEl.scrollWidth;
521
+ const viewport = typeof window !== 'undefined' ? window.innerWidth : 280;
522
+ const maxW = Math.min(280, viewport * 0.7);
523
+ const overflow = measured > maxW - t.borderWidth * 2;
524
+ if (!overflow) {
525
+ thoughtBubbleEl.style.width = '';
526
+ return;
527
+ }
528
+ const textWidth = Math.max(0, measured - padH * 2);
529
+ const durationSec = Math.max(4, (textWidth + 48) / 30);
530
+ thoughtBubbleEl.style.width = `${maxW}px`;
531
+ thoughtBubbleEl.textContent = '';
532
+ const track = document.createElement('div');
533
+ track.className = 'fyodos-thought-track';
534
+ track.style.animation = `fyodos-marquee ${durationSec}s linear infinite`;
535
+ const first = document.createElement('span');
536
+ first.textContent = prompt;
537
+ const second = document.createElement('span');
538
+ second.textContent = prompt;
539
+ second.setAttribute('aria-hidden', 'true');
540
+ track.appendChild(first);
541
+ track.appendChild(second);
542
+ thoughtBubbleEl.appendChild(track);
543
+ }
544
+ function applyThoughtBubbleTheme() {
545
+ const t = bubbleTheme;
546
+ const { fontPx, padH, approxHeight } = resolveBubblePadding(t);
547
+ const shapeCss = bubbleShapeToCss(t, approxHeight);
548
+ thoughtBubbleEl.style.fontSize = `${fontPx}px`;
549
+ thoughtBubbleEl.style.height = `${approxHeight}px`;
550
+ thoughtBubbleEl.style.padding = `0 ${padH}px`;
551
+ thoughtBubbleEl.style.color = t.textColor;
552
+ thoughtBubbleEl.style.background = t.backgroundColor;
553
+ thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
554
+ const setImportant = (prop, value) => {
555
+ if (!value) {
556
+ thoughtBubbleEl.style.removeProperty(prop);
557
+ return;
558
+ }
559
+ thoughtBubbleEl.style.setProperty(prop, value, 'important');
560
+ };
561
+ setImportant('border-radius', shapeCss.borderRadius);
562
+ setImportant('border-top-left-radius', shapeCss.borderTopLeftRadius);
563
+ setImportant('border-top-right-radius', shapeCss.borderTopRightRadius);
564
+ setImportant('border-bottom-left-radius', shapeCss.borderBottomLeftRadius);
565
+ setImportant('border-bottom-right-radius', shapeCss.borderBottomRightRadius);
566
+ setImportant('clip-path', shapeCss.clipPath);
567
+ }
568
+ function positionThoughtBubble() {
569
+ const dep = resolveOrbDeployment(effectivePosition());
570
+ if (dep.horizontal === 'left') {
571
+ thoughtBubbleEl.style.right = 'calc(100% + 10px)';
572
+ thoughtBubbleEl.style.left = '';
573
+ }
574
+ else {
575
+ thoughtBubbleEl.style.left = 'calc(100% + 10px)';
576
+ thoughtBubbleEl.style.right = '';
577
+ }
578
+ if (dep.vertical === 'up') {
579
+ thoughtBubbleEl.style.top = '0';
580
+ thoughtBubbleEl.style.bottom = '';
581
+ thoughtBubbleEl.style.transform = '';
582
+ }
583
+ else {
584
+ thoughtBubbleEl.style.bottom = '0';
585
+ thoughtBubbleEl.style.top = '';
586
+ thoughtBubbleEl.style.transform = '';
587
+ }
588
+ }
589
+ function syncThoughtBubble(state) {
590
+ const idle = !state.showThinking && !state.showWaveform && state.phase !== 'speaking' && !micFallbackActive;
591
+ const prompt = bubbleEnabled && !bubbleOpen && idle ? resolveCurrentBubblePrompt() : null;
592
+ if (!prompt) {
593
+ thoughtBubbleEl.style.display = 'none';
594
+ return;
595
+ }
596
+ thoughtBubbleEl.setAttribute('aria-label', prompt);
597
+ // Display before measuring so `scrollWidth` (used to detect overflow) is real.
598
+ thoughtBubbleEl.style.display = 'flex';
599
+ applyThoughtBubbleTheme();
600
+ layoutThoughtContent(prompt);
601
+ positionThoughtBubble();
602
+ }
459
603
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
460
604
  let overlayEl = null;
461
605
  function clearOverlay() {
@@ -568,9 +712,16 @@ export function mountOrb(controller, options = {}) {
568
712
  setBubble(false);
569
713
  return;
570
714
  }
715
+ // Voice disabled in the dashboard: never listen/speak. Tap opens the keyboard
716
+ // directly (text turns are silent by default); animations are unchanged.
717
+ if (!voiceInputEnabled && enableTextInput) {
718
+ micFallbackActive = false;
719
+ openTextPanel();
720
+ return;
721
+ }
571
722
  // `voiceAvailable` is dynamic: the Web Speech API may be present yet the mic
572
723
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
573
- if (controller.voiceAvailable) {
724
+ if (controller.voiceAvailable && voiceInputEnabled) {
574
725
  micFallbackActive = false;
575
726
  void controller.toggleListening();
576
727
  return;
@@ -660,6 +811,7 @@ export function mountOrb(controller, options = {}) {
660
811
  sendBtn.disabled = false;
661
812
  }
662
813
  syncChip(state);
814
+ syncThoughtBubble(state);
663
815
  if (state.confirmationMessage !== lastConfirm) {
664
816
  lastConfirm = state.confirmationMessage;
665
817
  if (state.confirmationMessage)
@@ -677,6 +829,26 @@ export function mountOrb(controller, options = {}) {
677
829
  }
678
830
  const unsubscribe = controller.subscribe(render);
679
831
  render(controller.getState());
832
+ // Track the current route so the thought bubble re-resolves its CTA on
833
+ // navigation. No universal navigation event exists, so poll + listen to
834
+ // popstate / visibility for snappier updates.
835
+ const readRoute = () => {
836
+ let next = null;
837
+ try {
838
+ next = controller.config.navigation.getCurrentRoute();
839
+ }
840
+ catch {
841
+ /* navigation read is best-effort */
842
+ }
843
+ if (next !== currentRoute) {
844
+ currentRoute = next;
845
+ syncThoughtBubble(controller.getState());
846
+ }
847
+ };
848
+ readRoute();
849
+ const routeTimer = window.setInterval(readRoute, 700);
850
+ window.addEventListener('popstate', readRoute);
851
+ document.addEventListener('visibilitychange', readRoute);
680
852
  // ── Live dashboard config ────────────────────────────────────────────────────
681
853
  const stopWatch = watchPublishedConfig(controller, {
682
854
  pollMs: options.appearancePollMs,
@@ -689,6 +861,9 @@ export function mountOrb(controller, options = {}) {
689
861
  publishedAppearance = config.appearance;
690
862
  chipTheme = config.keyboardChip;
691
863
  modalTheme = config.modalTheme;
864
+ bubbleTheme = config.bubbleTheme;
865
+ bubbleEnabled = config.bubbleEnabled;
866
+ voiceInputEnabled = config.voiceInputEnabled;
692
867
  if (usePublishedPosition && config.screenPosition)
693
868
  screenPosition = config.screenPosition;
694
869
  }
@@ -699,6 +874,7 @@ export function mountOrb(controller, options = {}) {
699
874
  chipEl = null;
700
875
  }
701
876
  syncChip(controller.getState());
877
+ syncThoughtBubble(controller.getState());
702
878
  if (bubbleOpen)
703
879
  applyBubbleTheme();
704
880
  },
@@ -707,6 +883,9 @@ export function mountOrb(controller, options = {}) {
707
883
  unmount() {
708
884
  unsubscribe();
709
885
  stopWatch();
886
+ window.clearInterval(routeTimer);
887
+ window.removeEventListener('popstate', readRoute);
888
+ document.removeEventListener('visibilitychange', readRoute);
710
889
  window.removeEventListener('resize', reposition);
711
890
  contentObserver?.disconnect();
712
891
  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.10";
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.10';
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.10",
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",