@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;
@@ -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 { manifestActionsForBackend, manifestRoutesForBackend, parseAgentAction, } from '@fiodos/core';
9
+ import { manifestActionsForBackend, manifestRoutesForBackend, parseAgentAction, withExecutableActions, } from '@fiodos/core';
10
10
  import { AgentApiError } from './errors.js';
11
11
  const DEFAULT_TURN_TIMEOUT_MS = 15000;
12
12
  const DEFAULT_TTS_TIMEOUT_MS = 12000;
@@ -198,15 +198,23 @@ export function createFiodosBackendClient(options) {
198
198
  },
199
199
  };
200
200
  }
201
- /** Serializes the manifest payloads for a turn request (used by AgentController). */
202
- export function buildManifestPayload(manifest) {
201
+ /**
202
+ * Serializes the manifest payloads for a turn request (used by AgentController).
203
+ *
204
+ * When the live registries are provided, actions with no registered handler
205
+ * are dropped from the payload (runtime "wired or nonexistent"): the LLM never
206
+ * hears about an action this mounted app cannot execute, so it can never
207
+ * promise it. Fail-open without registries.
208
+ */
209
+ export function buildManifestPayload(manifest, registries) {
210
+ const effective = withExecutableActions(manifest, registries);
203
211
  return {
204
- manifestVersion: manifest.version,
205
- manifestRoutes: manifestRoutesForBackend(manifest),
206
- manifestActions: manifestActionsForBackend(manifest),
207
- appName: manifest.appName,
208
- appDescription: manifest.appDescription,
209
- ...(manifest.appType ? { appType: manifest.appType } : {}),
210
- ...(manifest.appFlow ? { appFlow: manifest.appFlow } : {}),
212
+ manifestVersion: effective.version,
213
+ manifestRoutes: manifestRoutesForBackend(effective),
214
+ manifestActions: manifestActionsForBackend(effective),
215
+ appName: effective.appName,
216
+ appDescription: effective.appDescription,
217
+ ...(effective.appType ? { appType: effective.appType } : {}),
218
+ ...(effective.appFlow ? { appFlow: effective.appFlow } : {}),
211
219
  };
212
220
  }
@@ -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. */
@@ -827,7 +827,7 @@ export class AgentController {
827
827
  lastStep,
828
828
  platformCapabilities: this.platformCapabilities(),
829
829
  sessionContext: this.sessionContext(),
830
- ...buildManifestPayload(this.config.manifest),
830
+ ...buildManifestPayload(this.config.manifest, this.config.registries),
831
831
  }, { signal: controller.signal });
832
832
  oc = await this.applyOutcome(turn, routeNow, snap?.context ?? {}, state.userMessage);
833
833
  }
@@ -986,7 +986,7 @@ export class AgentController {
986
986
  sessionId: this.sessionId,
987
987
  platformCapabilities: this.platformCapabilities(),
988
988
  sessionContext: this.sessionContext(),
989
- ...buildManifestPayload(this.config.manifest),
989
+ ...buildManifestPayload(this.config.manifest, this.config.registries),
990
990
  }, { signal: controller.signal });
991
991
  outcome = await this.applyOutcome(turn, currentRoute, snapshot?.context ?? {}, text);
992
992
  }
@@ -29,6 +29,11 @@ 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 documentLocale() {
33
+ return typeof document !== 'undefined'
34
+ ? document.documentElement.lang?.split(/[-_]/)[0]?.trim() || ''
35
+ : '';
36
+ }
32
37
  function resolveAgentLocale(options) {
33
38
  const fromApp = options.getLocale?.()?.trim();
34
39
  if (fromApp)
@@ -38,12 +43,16 @@ function resolveAgentLocale(options) {
38
43
  if (options.localeDetection === 'browser')
39
44
  return browserLocale().locale;
40
45
  if (options.localeDetection === 'document') {
41
- const docLang = typeof document !== 'undefined'
42
- ? document.documentElement.lang?.split(/[-_]/)[0]?.trim()
43
- : '';
46
+ const docLang = documentLocale();
44
47
  if (docLang)
45
48
  return docLang;
46
49
  }
50
+ // Default (nothing configured): follow the HOST PAGE's language when the
51
+ // page declares one (<html lang>), so the orb bubble speaks the language the
52
+ // end user is actually browsing in. English only as the last resort.
53
+ const docLang = documentLocale();
54
+ if (docLang)
55
+ return docLang;
47
56
  return 'en';
48
57
  }
49
58
  /** Build the controller + adapters from a known manifest (shared by both paths). */
@@ -64,6 +73,10 @@ function assemble(options, manifest, baseUrl) {
64
73
  const config = {
65
74
  manifest,
66
75
  locale,
76
+ // Live resolver: re-runs the same resolution chain (app getLocale → static
77
+ // locale → detection) on every read, so the bubble follows the host app's
78
+ // language switcher / <html lang> without remounting the agent.
79
+ getLocale: () => resolveAgentLocale(options),
67
80
  sttLocale,
68
81
  navigation: createWebNavigationAdapter({
69
82
  navigate: options.navigate ?? defaultNavigate,
@@ -27,7 +27,7 @@ import { createScreenContextStore } from '../context/screenContextStore.js';
27
27
  import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId } from './dynamics.js';
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;
@@ -210,12 +210,13 @@ export function mountOrb(controller, options = {}) {
210
210
  const prompt = resolveCurrentBubblePrompt();
211
211
  openTextPanel(prompt
212
212
  ? bubblePromptToAffirmative(prompt, {
213
- locale: controller.config.locale,
213
+ locale: currentBubbleLocale(),
214
214
  messages: controller.messages,
215
215
  })
216
216
  : undefined);
217
217
  return;
218
218
  }
219
+ userTriedVoice = true;
219
220
  void controller.toggleListening();
220
221
  });
221
222
  thoughtBubbleEl.addEventListener('keydown', (event) => {
@@ -394,6 +395,13 @@ export function mountOrb(controller, options = {}) {
394
395
  // SAME "active/listening" visual state and reveals the keyboard chip — the
395
396
  // user is never blocked, text is always an open avenue (Change 1).
396
397
  let micFallbackActive = false;
398
+ // True once the user actually ATTEMPTED voice (tapped the orb/bubble to
399
+ // listen). The voiceBlocked→keyboard fallback in render() must only fire for
400
+ // a mic that failed MID-ATTEMPT: the upfront Permissions-API detection also
401
+ // sets voiceBlocked at mount (e.g. the site has the mic permission denied),
402
+ // and without this guard the conversation panel would pop open on page load
403
+ // with zero interaction.
404
+ let userTriedVoice = false;
397
405
  const bubble = document.createElement('div');
398
406
  bubble.className = 'fyodos-bubble';
399
407
  bubble.style.display = 'none';
@@ -462,6 +470,12 @@ export function mountOrb(controller, options = {}) {
462
470
  signature.endsWith(renderedSignature);
463
471
  const fromBottom = exchangeScroll.scrollHeight - exchangeScroll.scrollTop;
464
472
  renderedSignature = signature;
473
+ // Theme colors INLINE at node creation: the embed lives inside arbitrary
474
+ // host pages (e.g. Shopify themes) whose CSS sets `color` on everything,
475
+ // so a freshly-rendered message must never wait for the next
476
+ // applyBubbleTheme() pass to become readable (it would inherit the host's
477
+ // ink — black on our dark panel — until the next config poll).
478
+ const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
465
479
  exchangeList.replaceChildren();
466
480
  for (const ex of exchanges) {
467
481
  const block = document.createElement('div');
@@ -469,11 +483,13 @@ export function mountOrb(controller, options = {}) {
469
483
  const user = document.createElement('div');
470
484
  user.className = 'fyodos-bubble-user';
471
485
  user.textContent = ex.userText;
486
+ user.style.color = t.bodyColor;
472
487
  block.append(user);
473
488
  if (ex.replyText) {
474
489
  const reply = document.createElement('div');
475
490
  reply.className = 'fyodos-bubble-reply';
476
491
  reply.textContent = ex.replyText;
492
+ reply.style.color = t.titleColor;
477
493
  block.append(reply);
478
494
  }
479
495
  exchangeList.append(block);
@@ -580,7 +596,8 @@ export function mountOrb(controller, options = {}) {
580
596
  if (active) {
581
597
  micBtn.style.background = accent;
582
598
  micBtn.style.border = 'none';
583
- micBtn.style.color = '#000';
599
+ // Ink readable on the (customizable) accent — dark accents need light ink.
600
+ micBtn.style.color = fiodosInputInkColor(accent);
584
601
  micBtn.style.borderRadius = sendRadius;
585
602
  }
586
603
  else {
@@ -679,6 +696,9 @@ export function mountOrb(controller, options = {}) {
679
696
  textInput.style.color = fiodosInputInkColor(inputBg);
680
697
  textInput.style.setProperty('--fyodos-input-placeholder', fiodosInputMutedInkColor(inputBg));
681
698
  sendBtn.style.background = accent;
699
+ // The glyph (↑ / ◼) must stay readable on ANY published accent: dark ink
700
+ // on light accents, light ink on dark ones (never the hardcoded black).
701
+ sendBtn.style.color = fiodosInputInkColor(accent);
682
702
  sendBtn.style.borderRadius = `${modalTheme?.sendButtonRadius ?? 10}px`;
683
703
  syncMicButton();
684
704
  busyRow.querySelectorAll('span').forEach((s) => {
@@ -707,6 +727,13 @@ export function mountOrb(controller, options = {}) {
707
727
  setBubbleExpanded(false);
708
728
  }
709
729
  syncChip(controller.getState());
730
+ // Re-resolve the thought bubble NOW: closing the panel must bring the
731
+ // screen-aware CTA back immediately. Nothing else is guaranteed to run
732
+ // right after close (controller state does not change on close, the route
733
+ // poll only fires on changes, the config poll every ~12s) — without this
734
+ // the orb sat "naked" until some unrelated event. Opening hides it (the
735
+ // resolver returns null while the panel is open), so one call covers both.
736
+ syncThoughtBubble(controller.getState());
710
737
  }
711
738
  function submitText() {
712
739
  // While a turn is in flight the button is a STOP button; never start a new
@@ -771,8 +798,22 @@ export function mountOrb(controller, options = {}) {
771
798
  applyDeployment();
772
799
  }
773
800
  // ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
801
+ // LIVE locale: re-read the host app's active language on every resolution so
802
+ // the bubble text (analyzer `bubblePrompts` translations) follows the app's
803
+ // language switcher without remounting. Falls back to the mount-time locale.
804
+ function currentBubbleLocale() {
805
+ try {
806
+ const live = controller.config.getLocale?.();
807
+ if (typeof live === 'string' && live.trim())
808
+ return live.trim();
809
+ }
810
+ catch {
811
+ /* live locale read is best-effort */
812
+ }
813
+ return controller.config.locale;
814
+ }
774
815
  function resolveCurrentBubblePrompt() {
775
- return resolveBubblePrompt(matchRouteIntent(currentRoute, controller.config.manifest.routes), controller.messages, { locale: controller.config.locale, actions: controller.config.manifest.actions });
816
+ return resolveBubblePrompt(matchRouteIntent(currentRoute, controller.config.manifest.routes), controller.messages, { locale: currentBubbleLocale(), actions: controller.config.manifest.actions });
776
817
  }
777
818
  // Render the CTA on ONE line at its NATURAL size. When the phrase is too long
778
819
  // to fit the max width, scroll it (marquee, right-to-left, looping) instead of
@@ -987,6 +1028,7 @@ export function mountOrb(controller, options = {}) {
987
1028
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
988
1029
  if (controller.voiceAvailable && voiceInputEnabled) {
989
1030
  micFallbackActive = false;
1031
+ userTriedVoice = true;
990
1032
  void controller.toggleListening();
991
1033
  return;
992
1034
  }
@@ -998,6 +1040,7 @@ export function mountOrb(controller, options = {}) {
998
1040
  openTextPanel();
999
1041
  return;
1000
1042
  }
1043
+ userTriedVoice = true;
1001
1044
  void controller.toggleListening();
1002
1045
  };
1003
1046
  sendBtn.onclick = () => {
@@ -1036,11 +1079,15 @@ export function mountOrb(controller, options = {}) {
1036
1079
  // Track phase transitions to fire haptics: start/stop listening + response.
1037
1080
  let lastPhase = null;
1038
1081
  function render(state) {
1039
- // Mic just became unusable mid-attempt (no Permissions API): fall back to the
1040
- // keyboard so the user always has a working channel.
1082
+ // Mic just became unusable MID-ATTEMPT (no Permissions API): fall back to
1083
+ // the keyboard so the user always has a working channel. Guarded by
1084
+ // userTriedVoice: the upfront permission detection also sets voiceBlocked
1085
+ // right at mount (site-level mic denial), and that must NEVER auto-open
1086
+ // the conversation panel on page load — the user did nothing yet; their
1087
+ // first orb tap will route to the keyboard via the voiceAvailable check.
1041
1088
  if (state.voiceBlocked && !lastVoiceBlocked) {
1042
1089
  lastVoiceBlocked = true;
1043
- if (enableTextInput && !bubbleOpen && !state.isBusy) {
1090
+ if (userTriedVoice && enableTextInput && !bubbleOpen && !state.isBusy) {
1044
1091
  openTextPanel();
1045
1092
  return;
1046
1093
  }
@@ -1109,9 +1156,11 @@ export function mountOrb(controller, options = {}) {
1109
1156
  }
1110
1157
  const unsubscribe = controller.subscribe(render);
1111
1158
  render(controller.getState());
1112
- // Track the current route so the thought bubble re-resolves its CTA on
1113
- // navigation. No universal navigation event exists, so poll + listen to
1114
- // popstate / visibility for snappier updates.
1159
+ // Track the current route AND the app's live locale so the thought bubble
1160
+ // re-resolves its CTA on navigation and on language switches. No universal
1161
+ // navigation/i18n event exists, so poll + listen to popstate / visibility
1162
+ // for snappier updates.
1163
+ let lastBubbleLocale = currentBubbleLocale();
1115
1164
  const readRoute = () => {
1116
1165
  let next = null;
1117
1166
  try {
@@ -1120,8 +1169,10 @@ export function mountOrb(controller, options = {}) {
1120
1169
  catch {
1121
1170
  /* navigation read is best-effort */
1122
1171
  }
1123
- if (next !== currentRoute) {
1172
+ const nextLocale = currentBubbleLocale();
1173
+ if (next !== currentRoute || nextLocale !== lastBubbleLocale) {
1124
1174
  currentRoute = next;
1175
+ lastBubbleLocale = nextLocale;
1125
1176
  syncThoughtBubble(controller.getState());
1126
1177
  }
1127
1178
  };
@@ -74,13 +74,14 @@ function buildDoubleBorder(opts) {
74
74
  return { root, fill };
75
75
  }
76
76
  /**
77
- * Neutral Fiodos brand mark (idle, no logo). Breathes very gently on web — a
77
+ * Fiodos brand mark (idle, no logo). Breathes very gently on web — a
78
78
  * direct port of the landing hero orbs' waveform easing (see
79
79
  * `fiodosOrbMarkIdleHalfHeight` in @fiodos/core) — deliberately subtler than
80
80
  * and distinct from the volume-reactive waveform shown while listening.
81
+ * Tinted with the same "audio color" (waveformColor) as that waveform.
81
82
  */
82
- function buildOrbMark(orbSize) {
83
- const { bars, color, radius } = resolveFiodosOrbMarkAnimatedBars(orbSize);
83
+ function buildOrbMark(orbSize, markColor) {
84
+ const { bars, color, radius } = resolveFiodosOrbMarkAnimatedBars(orbSize, markColor);
84
85
  const svg = svgEl('svg', {
85
86
  width: orbSize,
86
87
  height: orbSize,
@@ -484,7 +485,7 @@ export function createOrbVisual(initial) {
484
485
  return;
485
486
  }
486
487
  if (state === 'idle') {
487
- markAnim = buildOrbMark(size);
488
+ markAnim = buildOrbMark(size, appearance.waveformColor);
488
489
  fillEl.appendChild(markAnim.el);
489
490
  markAnim.start();
490
491
  }
@@ -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";
@@ -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.14';
8
+ export const SDK_VERSION = '0.1.15';
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.14",
3
+ "version": "0.1.15",
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": {