@fiodos/web-core 0.1.14 → 0.1.15

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.
@@ -6,7 +6,7 @@
6
6
  * way. The only platform dependency is `fetch` + `AbortController` (native in
7
7
  * the browser). No client source code is ever sent — only the manifest payload.
8
8
  */
9
- import { type AgentBackendClient, type AppManifest } from '@fiodos/core';
9
+ import { type ActionRegistries, type AgentBackendClient, type AppManifest } from '@fiodos/core';
10
10
  export interface FiodosBackendClientOptions {
11
11
  baseUrl: string;
12
12
  /** Client API key sent as x-api-key. */
@@ -19,8 +19,15 @@ export interface FiodosBackendClientOptions {
19
19
  ttsTimeoutMs?: number;
20
20
  }
21
21
  export declare function createFiodosBackendClient(options: FiodosBackendClientOptions): AgentBackendClient;
22
- /** Serializes the manifest payloads for a turn request (used by AgentController). */
23
- export declare function buildManifestPayload(manifest: AppManifest): {
22
+ /**
23
+ * Serializes the manifest payloads for a turn request (used by AgentController).
24
+ *
25
+ * When the live registries are provided, actions with no registered handler
26
+ * are dropped from the payload (runtime "wired or nonexistent"): the LLM never
27
+ * hears about an action this mounted app cannot execute, so it can never
28
+ * promise it. Fail-open without registries.
29
+ */
30
+ export declare function buildManifestPayload(manifest: AppManifest, registries?: ActionRegistries | null): {
24
31
  appFlow?: string | undefined;
25
32
  appType?: string | undefined;
26
33
  manifestVersion: string;
@@ -202,15 +202,23 @@ function createFiodosBackendClient(options) {
202
202
  },
203
203
  };
204
204
  }
205
- /** Serializes the manifest payloads for a turn request (used by AgentController). */
206
- function buildManifestPayload(manifest) {
205
+ /**
206
+ * Serializes the manifest payloads for a turn request (used by AgentController).
207
+ *
208
+ * When the live registries are provided, actions with no registered handler
209
+ * are dropped from the payload (runtime "wired or nonexistent"): the LLM never
210
+ * hears about an action this mounted app cannot execute, so it can never
211
+ * promise it. Fail-open without registries.
212
+ */
213
+ function buildManifestPayload(manifest, registries) {
214
+ const effective = (0, core_1.withExecutableActions)(manifest, registries);
207
215
  return {
208
- manifestVersion: manifest.version,
209
- manifestRoutes: (0, core_1.manifestRoutesForBackend)(manifest),
210
- manifestActions: (0, core_1.manifestActionsForBackend)(manifest),
211
- appName: manifest.appName,
212
- appDescription: manifest.appDescription,
213
- ...(manifest.appType ? { appType: manifest.appType } : {}),
214
- ...(manifest.appFlow ? { appFlow: manifest.appFlow } : {}),
216
+ manifestVersion: effective.version,
217
+ manifestRoutes: (0, core_1.manifestRoutesForBackend)(effective),
218
+ manifestActions: (0, core_1.manifestActionsForBackend)(effective),
219
+ appName: effective.appName,
220
+ appDescription: effective.appDescription,
221
+ ...(effective.appType ? { appType: effective.appType } : {}),
222
+ ...(effective.appFlow ? { appFlow: effective.appFlow } : {}),
215
223
  };
216
224
  }
@@ -44,6 +44,13 @@ export interface FiodosWebAgentConfig {
44
44
  manifest: AppManifest;
45
45
  /** Agent locale (reply language, message catalogs). MANDATORY. */
46
46
  locale: string;
47
+ /**
48
+ * LIVE locale resolver. When set, the orb re-reads it periodically so the
49
+ * thought bubble follows the app's ACTIVE language (host language switcher,
50
+ * `<html lang>`, i18n runtime) without remounting. Falls back to `locale`
51
+ * whenever it returns nothing.
52
+ */
53
+ getLocale?: () => string | null | undefined;
47
54
  /** Speech recognition locale, e.g. 'en-US', 'es-ES'. MANDATORY. */
48
55
  sttLocale: string;
49
56
  /** Device-TTS fallback locale. Defaults to sttLocale. */
@@ -830,7 +830,7 @@ class AgentController {
830
830
  lastStep,
831
831
  platformCapabilities: this.platformCapabilities(),
832
832
  sessionContext: this.sessionContext(),
833
- ...(0, backendClient_1.buildManifestPayload)(this.config.manifest),
833
+ ...(0, backendClient_1.buildManifestPayload)(this.config.manifest, this.config.registries),
834
834
  }, { signal: controller.signal });
835
835
  oc = await this.applyOutcome(turn, routeNow, snap?.context ?? {}, state.userMessage);
836
836
  }
@@ -989,7 +989,7 @@ class AgentController {
989
989
  sessionId: this.sessionId,
990
990
  platformCapabilities: this.platformCapabilities(),
991
991
  sessionContext: this.sessionContext(),
992
- ...(0, backendClient_1.buildManifestPayload)(this.config.manifest),
992
+ ...(0, backendClient_1.buildManifestPayload)(this.config.manifest, this.config.registries),
993
993
  }, { signal: controller.signal });
994
994
  outcome = await this.applyOutcome(turn, currentRoute, snapshot?.context ?? {}, text);
995
995
  }
@@ -32,6 +32,11 @@ 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 documentLocale() {
36
+ return typeof document !== 'undefined'
37
+ ? document.documentElement.lang?.split(/[-_]/)[0]?.trim() || ''
38
+ : '';
39
+ }
35
40
  function resolveAgentLocale(options) {
36
41
  const fromApp = options.getLocale?.()?.trim();
37
42
  if (fromApp)
@@ -41,12 +46,16 @@ function resolveAgentLocale(options) {
41
46
  if (options.localeDetection === 'browser')
42
47
  return browserLocale().locale;
43
48
  if (options.localeDetection === 'document') {
44
- const docLang = typeof document !== 'undefined'
45
- ? document.documentElement.lang?.split(/[-_]/)[0]?.trim()
46
- : '';
49
+ const docLang = documentLocale();
47
50
  if (docLang)
48
51
  return docLang;
49
52
  }
53
+ // Default (nothing configured): follow the HOST PAGE's language when the
54
+ // page declares one (<html lang>), so the orb bubble speaks the language the
55
+ // end user is actually browsing in. English only as the last resort.
56
+ const docLang = documentLocale();
57
+ if (docLang)
58
+ return docLang;
50
59
  return 'en';
51
60
  }
52
61
  /** Build the controller + adapters from a known manifest (shared by both paths). */
@@ -67,6 +76,10 @@ function assemble(options, manifest, baseUrl) {
67
76
  const config = {
68
77
  manifest,
69
78
  locale,
79
+ // Live resolver: re-runs the same resolution chain (app getLocale → static
80
+ // locale → detection) on every read, so the bubble follows the host app's
81
+ // language switcher / <html lang> without remounting the agent.
82
+ getLocale: () => resolveAgentLocale(options),
70
83
  sttLocale,
71
84
  navigation: (0, webNavigationAdapter_1.createWebNavigationAdapter)({
72
85
  navigate: options.navigate ?? defaultNavigate,
@@ -27,7 +27,7 @@ import { createScreenContextStore } from '../context/screenContextStore';
27
27
  import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId } from './dynamics';
28
28
  import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
29
29
  declare const api: {
30
- readonly version: "0.1.14";
30
+ readonly version: "0.1.15";
31
31
  readonly createFiodosAgent: typeof createFiodosAgent;
32
32
  readonly mountOrb: typeof mountOrb;
33
33
  readonly AgentController: typeof AgentController;
@@ -213,12 +213,13 @@ function mountOrb(controller, options = {}) {
213
213
  const prompt = resolveCurrentBubblePrompt();
214
214
  openTextPanel(prompt
215
215
  ? (0, core_1.bubblePromptToAffirmative)(prompt, {
216
- locale: controller.config.locale,
216
+ locale: currentBubbleLocale(),
217
217
  messages: controller.messages,
218
218
  })
219
219
  : undefined);
220
220
  return;
221
221
  }
222
+ userTriedVoice = true;
222
223
  void controller.toggleListening();
223
224
  });
224
225
  thoughtBubbleEl.addEventListener('keydown', (event) => {
@@ -397,6 +398,13 @@ function mountOrb(controller, options = {}) {
397
398
  // SAME "active/listening" visual state and reveals the keyboard chip — the
398
399
  // user is never blocked, text is always an open avenue (Change 1).
399
400
  let micFallbackActive = false;
401
+ // True once the user actually ATTEMPTED voice (tapped the orb/bubble to
402
+ // listen). The voiceBlocked→keyboard fallback in render() must only fire for
403
+ // a mic that failed MID-ATTEMPT: the upfront Permissions-API detection also
404
+ // sets voiceBlocked at mount (e.g. the site has the mic permission denied),
405
+ // and without this guard the conversation panel would pop open on page load
406
+ // with zero interaction.
407
+ let userTriedVoice = false;
400
408
  const bubble = document.createElement('div');
401
409
  bubble.className = 'fyodos-bubble';
402
410
  bubble.style.display = 'none';
@@ -465,6 +473,12 @@ function mountOrb(controller, options = {}) {
465
473
  signature.endsWith(renderedSignature);
466
474
  const fromBottom = exchangeScroll.scrollHeight - exchangeScroll.scrollTop;
467
475
  renderedSignature = signature;
476
+ // Theme colors INLINE at node creation: the embed lives inside arbitrary
477
+ // host pages (e.g. Shopify themes) whose CSS sets `color` on everything,
478
+ // so a freshly-rendered message must never wait for the next
479
+ // applyBubbleTheme() pass to become readable (it would inherit the host's
480
+ // ink — black on our dark panel — until the next config poll).
481
+ const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
468
482
  exchangeList.replaceChildren();
469
483
  for (const ex of exchanges) {
470
484
  const block = document.createElement('div');
@@ -472,11 +486,13 @@ function mountOrb(controller, options = {}) {
472
486
  const user = document.createElement('div');
473
487
  user.className = 'fyodos-bubble-user';
474
488
  user.textContent = ex.userText;
489
+ user.style.color = t.bodyColor;
475
490
  block.append(user);
476
491
  if (ex.replyText) {
477
492
  const reply = document.createElement('div');
478
493
  reply.className = 'fyodos-bubble-reply';
479
494
  reply.textContent = ex.replyText;
495
+ reply.style.color = t.titleColor;
480
496
  block.append(reply);
481
497
  }
482
498
  exchangeList.append(block);
@@ -583,7 +599,8 @@ function mountOrb(controller, options = {}) {
583
599
  if (active) {
584
600
  micBtn.style.background = accent;
585
601
  micBtn.style.border = 'none';
586
- micBtn.style.color = '#000';
602
+ // Ink readable on the (customizable) accent — dark accents need light ink.
603
+ micBtn.style.color = (0, core_1.fiodosInputInkColor)(accent);
587
604
  micBtn.style.borderRadius = sendRadius;
588
605
  }
589
606
  else {
@@ -682,6 +699,9 @@ function mountOrb(controller, options = {}) {
682
699
  textInput.style.color = (0, core_1.fiodosInputInkColor)(inputBg);
683
700
  textInput.style.setProperty('--fyodos-input-placeholder', (0, core_1.fiodosInputMutedInkColor)(inputBg));
684
701
  sendBtn.style.background = accent;
702
+ // The glyph (↑ / ◼) must stay readable on ANY published accent: dark ink
703
+ // on light accents, light ink on dark ones (never the hardcoded black).
704
+ sendBtn.style.color = (0, core_1.fiodosInputInkColor)(accent);
685
705
  sendBtn.style.borderRadius = `${modalTheme?.sendButtonRadius ?? 10}px`;
686
706
  syncMicButton();
687
707
  busyRow.querySelectorAll('span').forEach((s) => {
@@ -710,6 +730,13 @@ function mountOrb(controller, options = {}) {
710
730
  setBubbleExpanded(false);
711
731
  }
712
732
  syncChip(controller.getState());
733
+ // Re-resolve the thought bubble NOW: closing the panel must bring the
734
+ // screen-aware CTA back immediately. Nothing else is guaranteed to run
735
+ // right after close (controller state does not change on close, the route
736
+ // poll only fires on changes, the config poll every ~12s) — without this
737
+ // the orb sat "naked" until some unrelated event. Opening hides it (the
738
+ // resolver returns null while the panel is open), so one call covers both.
739
+ syncThoughtBubble(controller.getState());
713
740
  }
714
741
  function submitText() {
715
742
  // While a turn is in flight the button is a STOP button; never start a new
@@ -774,8 +801,22 @@ function mountOrb(controller, options = {}) {
774
801
  applyDeployment();
775
802
  }
776
803
  // ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
804
+ // LIVE locale: re-read the host app's active language on every resolution so
805
+ // the bubble text (analyzer `bubblePrompts` translations) follows the app's
806
+ // language switcher without remounting. Falls back to the mount-time locale.
807
+ function currentBubbleLocale() {
808
+ try {
809
+ const live = controller.config.getLocale?.();
810
+ if (typeof live === 'string' && live.trim())
811
+ return live.trim();
812
+ }
813
+ catch {
814
+ /* live locale read is best-effort */
815
+ }
816
+ return controller.config.locale;
817
+ }
777
818
  function resolveCurrentBubblePrompt() {
778
- 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 });
819
+ return (0, core_1.resolveBubblePrompt)((0, core_1.matchRouteIntent)(currentRoute, controller.config.manifest.routes), controller.messages, { locale: currentBubbleLocale(), actions: controller.config.manifest.actions });
779
820
  }
780
821
  // Render the CTA on ONE line at its NATURAL size. When the phrase is too long
781
822
  // to fit the max width, scroll it (marquee, right-to-left, looping) instead of
@@ -990,6 +1031,7 @@ function mountOrb(controller, options = {}) {
990
1031
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
991
1032
  if (controller.voiceAvailable && voiceInputEnabled) {
992
1033
  micFallbackActive = false;
1034
+ userTriedVoice = true;
993
1035
  void controller.toggleListening();
994
1036
  return;
995
1037
  }
@@ -1001,6 +1043,7 @@ function mountOrb(controller, options = {}) {
1001
1043
  openTextPanel();
1002
1044
  return;
1003
1045
  }
1046
+ userTriedVoice = true;
1004
1047
  void controller.toggleListening();
1005
1048
  };
1006
1049
  sendBtn.onclick = () => {
@@ -1039,11 +1082,15 @@ function mountOrb(controller, options = {}) {
1039
1082
  // Track phase transitions to fire haptics: start/stop listening + response.
1040
1083
  let lastPhase = null;
1041
1084
  function render(state) {
1042
- // Mic just became unusable mid-attempt (no Permissions API): fall back to the
1043
- // keyboard so the user always has a working channel.
1085
+ // Mic just became unusable MID-ATTEMPT (no Permissions API): fall back to
1086
+ // the keyboard so the user always has a working channel. Guarded by
1087
+ // userTriedVoice: the upfront permission detection also sets voiceBlocked
1088
+ // right at mount (site-level mic denial), and that must NEVER auto-open
1089
+ // the conversation panel on page load — the user did nothing yet; their
1090
+ // first orb tap will route to the keyboard via the voiceAvailable check.
1044
1091
  if (state.voiceBlocked && !lastVoiceBlocked) {
1045
1092
  lastVoiceBlocked = true;
1046
- if (enableTextInput && !bubbleOpen && !state.isBusy) {
1093
+ if (userTriedVoice && enableTextInput && !bubbleOpen && !state.isBusy) {
1047
1094
  openTextPanel();
1048
1095
  return;
1049
1096
  }
@@ -1112,9 +1159,11 @@ function mountOrb(controller, options = {}) {
1112
1159
  }
1113
1160
  const unsubscribe = controller.subscribe(render);
1114
1161
  render(controller.getState());
1115
- // Track the current route so the thought bubble re-resolves its CTA on
1116
- // navigation. No universal navigation event exists, so poll + listen to
1117
- // popstate / visibility for snappier updates.
1162
+ // Track the current route AND the app's live locale so the thought bubble
1163
+ // re-resolves its CTA on navigation and on language switches. No universal
1164
+ // navigation/i18n event exists, so poll + listen to popstate / visibility
1165
+ // for snappier updates.
1166
+ let lastBubbleLocale = currentBubbleLocale();
1118
1167
  const readRoute = () => {
1119
1168
  let next = null;
1120
1169
  try {
@@ -1123,8 +1172,10 @@ function mountOrb(controller, options = {}) {
1123
1172
  catch {
1124
1173
  /* navigation read is best-effort */
1125
1174
  }
1126
- if (next !== currentRoute) {
1175
+ const nextLocale = currentBubbleLocale();
1176
+ if (next !== currentRoute || nextLocale !== lastBubbleLocale) {
1127
1177
  currentRoute = next;
1178
+ lastBubbleLocale = nextLocale;
1128
1179
  syncThoughtBubble(controller.getState());
1129
1180
  }
1130
1181
  };
@@ -79,13 +79,14 @@ function buildDoubleBorder(opts) {
79
79
  return { root, fill };
80
80
  }
81
81
  /**
82
- * Neutral Fiodos brand mark (idle, no logo). Breathes very gently on web — a
82
+ * Fiodos brand mark (idle, no logo). Breathes very gently on web — a
83
83
  * direct port of the landing hero orbs' waveform easing (see
84
84
  * `fiodosOrbMarkIdleHalfHeight` in @fiodos/core) — deliberately subtler than
85
85
  * and distinct from the volume-reactive waveform shown while listening.
86
+ * Tinted with the same "audio color" (waveformColor) as that waveform.
86
87
  */
87
- function buildOrbMark(orbSize) {
88
- const { bars, color, radius } = (0, core_1.resolveFiodosOrbMarkAnimatedBars)(orbSize);
88
+ function buildOrbMark(orbSize, markColor) {
89
+ const { bars, color, radius } = (0, core_1.resolveFiodosOrbMarkAnimatedBars)(orbSize, markColor);
89
90
  const svg = svgEl('svg', {
90
91
  width: orbSize,
91
92
  height: orbSize,
@@ -489,7 +490,7 @@ function createOrbVisual(initial) {
489
490
  return;
490
491
  }
491
492
  if (state === 'idle') {
492
- markAnim = buildOrbMark(size);
493
+ markAnim = buildOrbMark(size, appearance.waveformColor);
493
494
  fillEl.appendChild(markAnim.el);
494
495
  markAnim.start();
495
496
  }
@@ -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.14";
8
+ export declare const SDK_VERSION = "0.1.15";
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.14';
11
+ exports.SDK_VERSION = '0.1.15';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';