@fiodos/web-core 0.1.12 → 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.
Files changed (44) hide show
  1. package/dist/cjs/adapters/webCredentialAutofillAdapter.d.ts +16 -0
  2. package/dist/cjs/adapters/webCredentialAutofillAdapter.js +47 -0
  3. package/dist/cjs/api/backendClient.d.ts +10 -3
  4. package/dist/cjs/api/backendClient.js +31 -9
  5. package/dist/cjs/config/types.d.ts +22 -2
  6. package/dist/cjs/controller/AgentController.d.ts +14 -0
  7. package/dist/cjs/controller/AgentController.js +65 -2
  8. package/dist/cjs/dropin/createFiodosAgent.d.ts +27 -1
  9. package/dist/cjs/dropin/createFiodosAgent.js +48 -4
  10. package/dist/cjs/embed/dynamics.d.ts +61 -0
  11. package/dist/cjs/embed/dynamics.js +173 -0
  12. package/dist/cjs/embed/global.d.ts +45 -0
  13. package/dist/cjs/embed/global.js +48 -0
  14. package/dist/cjs/index.d.ts +3 -0
  15. package/dist/cjs/index.js +8 -1
  16. package/dist/cjs/orb/mountOrb.js +172 -28
  17. package/dist/cjs/orb/orbView.js +42 -8
  18. package/dist/cjs/orb/publishedConfig.d.ts +4 -2
  19. package/dist/cjs/orb/publishedConfig.js +56 -7
  20. package/dist/cjs/version.d.ts +1 -1
  21. package/dist/cjs/version.js +1 -1
  22. package/dist/embed/fiodos-embed.js +66 -0
  23. package/dist/esm/adapters/webCredentialAutofillAdapter.d.ts +16 -0
  24. package/dist/esm/adapters/webCredentialAutofillAdapter.js +44 -0
  25. package/dist/esm/api/backendClient.d.ts +10 -3
  26. package/dist/esm/api/backendClient.js +32 -10
  27. package/dist/esm/config/types.d.ts +22 -2
  28. package/dist/esm/controller/AgentController.d.ts +14 -0
  29. package/dist/esm/controller/AgentController.js +65 -2
  30. package/dist/esm/dropin/createFiodosAgent.d.ts +27 -1
  31. package/dist/esm/dropin/createFiodosAgent.js +48 -4
  32. package/dist/esm/embed/dynamics.d.ts +61 -0
  33. package/dist/esm/embed/dynamics.js +168 -0
  34. package/dist/esm/embed/global.d.ts +45 -0
  35. package/dist/esm/embed/global.js +46 -0
  36. package/dist/esm/index.d.ts +3 -0
  37. package/dist/esm/index.js +3 -0
  38. package/dist/esm/orb/mountOrb.js +173 -29
  39. package/dist/esm/orb/orbView.js +43 -9
  40. package/dist/esm/orb/publishedConfig.d.ts +4 -2
  41. package/dist/esm/orb/publishedConfig.js +56 -7
  42. package/dist/esm/version.d.ts +1 -1
  43. package/dist/esm/version.js +1 -1
  44. package/package.json +5 -3
@@ -26,6 +26,7 @@ export { createWebNavigationAdapter, BACK_ROUTE, } from './adapters/webNavigatio
26
26
  export type { WebNavigationAdapter, WebNavigationAdapterOptions, } from './adapters/webNavigationAdapter.js';
27
27
  export { createWebVoiceAdapter } from './adapters/webVoiceAdapter.js';
28
28
  export type { WebVoiceAdapterOptions } from './adapters/webVoiceAdapter.js';
29
+ export { createWebCredentialAutofillAdapter } from './adapters/webCredentialAutofillAdapter.js';
29
30
  export { createFiodosBackendClient, buildManifestPayload } from './api/backendClient.js';
30
31
  export type { FiodosBackendClientOptions } from './api/backendClient.js';
31
32
  export { createFiodosTelemetry } from './api/backendTelemetry.js';
@@ -36,6 +37,8 @@ export { createBridge } from './bridge/createBridge.js';
36
37
  export type { Bridge } from './bridge/createBridge.js';
37
38
  export { createScreenContextStore } from './context/screenContextStore.js';
38
39
  export type { ScreenContextStore, AgentScreenSnapshot } from './context/screenContextStore.js';
40
+ export { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId, } from './embed/dynamics.js';
41
+ export type { DynamicsFormContextLike, DynamicsSnapshotOptions, DynamicsContextBridge, ScreenContextSink, } from './embed/dynamics.js';
39
42
  export { createSpeechSession } from './speech/speechSession.js';
40
43
  export type { SpeechSession, SpeechSessionOptions, SpeechSessionState, SpeechSessionSnapshot, } from './speech/speechSession.js';
41
44
  export { DEFAULT_AGENT_TIMINGS, DEFAULT_AGENT_CHAINING } from './config/types.js';
package/dist/esm/index.js CHANGED
@@ -23,6 +23,7 @@ export { watchPublishedConfig } from './orb/publishedConfig.js';
23
23
  export { createWebStorageAdapter } from './adapters/webStorageAdapter.js';
24
24
  export { createWebNavigationAdapter, BACK_ROUTE, } from './adapters/webNavigationAdapter.js';
25
25
  export { createWebVoiceAdapter } from './adapters/webVoiceAdapter.js';
26
+ export { createWebCredentialAutofillAdapter } from './adapters/webCredentialAutofillAdapter.js';
26
27
  // ── Backend + telemetry (same wire contract as every platform) ────────────────
27
28
  export { createFiodosBackendClient, buildManifestPayload } from './api/backendClient.js';
28
29
  export { createFiodosTelemetry } from './api/backendTelemetry.js';
@@ -31,6 +32,8 @@ export { AgentApiError, isAgentApiError } from './api/errors.js';
31
32
  export { createBridge } from './bridge/createBridge.js';
32
33
  // ── Screen context ────────────────────────────────────────────────────────────
33
34
  export { createScreenContextStore } from './context/screenContextStore.js';
35
+ // ── Closed-host context adapters (embed mode) ─────────────────────────────────
36
+ export { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId, } from './embed/dynamics.js';
34
37
  // ── Speech session ──────────────────────────────────────────────────────────────
35
38
  export { createSpeechSession } from './speech/speechSession.js';
36
39
  // ── Config / i18n ─────────────────────────────────────────────────────────────
@@ -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, matchRouteIntent, resolveBubblePrompt, bubblePromptToAffirmative, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, fiodosInputInkColor, fiodosInputMutedInkColor, shouldHideInternalOrb, } from '@fiodos/core';
19
+ import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, bubblePromptToAffirmative, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, applyBubbleShapeCss, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, fiodosInputInkColor, fiodosInputMutedInkColor, shouldHideInternalOrb, } from '@fiodos/core';
20
20
  import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
21
21
  import { watchPublishedConfig } from './publishedConfig.js';
22
22
  import { createBubbleDictation, scrollTextInputToEnd } from '../speech/bubbleDictation.js';
@@ -27,8 +27,23 @@ const CSS = `
27
27
  .fyodos-orb-btn img{pointer-events:none;-webkit-user-drag:none;user-select:none;}
28
28
  .fyodos-orb-btn:focus-visible{outline:2px solid #8ab6ff;outline-offset:4px;border-radius:50%;}
29
29
  .fyodos-orb-anchor{position:relative;display:inline-flex;}
30
- .fyodos-bubble{position:absolute;display:flex;flex-direction:column;width:min(320px,calc(100vw - 48px));max-height:340px;overflow:hidden;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,0.28);border:1px solid rgba(160,180,220,0.22);animation:fyodos-fade-in .14s ease-out;}
31
- .fyodos-bubble-close{position:absolute;top:4px;right:6px;z-index:1;display:flex;align-items:center;justify-content:center;border:none;background:transparent;font-size:18px;line-height:18px;width:28px;height:28px;cursor:pointer;padding:0;opacity:.75;}
30
+ .fyodos-bubble{position:absolute;display:flex;flex-direction:column;width:min(320px,calc(100vw - 48px));max-height:340px;overflow:hidden;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,0.28);border:1px solid rgba(160,180,220,0.22);animation:fyodos-fade-in .14s ease-out;transition:width .25s cubic-bezier(0.22,1,0.36,1),max-height .25s cubic-bezier(0.22,1,0.36,1);cursor:zoom-in;}
31
+ .fyodos-bubble--expanded{width:min(560px,calc(100vw - 32px));max-height:min(620px,75vh);cursor:auto;}
32
+ .fyodos-bubble-user,.fyodos-bubble-reply{cursor:text;}
33
+ .fyodos-bubble-row{cursor:default;}
34
+ .fyodos-bubble--expanded .fyodos-bubble-scroll{padding:22px 24px 8px;gap:10px;}
35
+ .fyodos-bubble--expanded .fyodos-bubble-exchanges{gap:14px;}
36
+ .fyodos-bubble--expanded .fyodos-bubble-exchange{gap:9px;}
37
+ .fyodos-bubble--expanded .fyodos-bubble-exchange:first-child .fyodos-bubble-user{padding-right:0;}
38
+ .fyodos-bubble--expanded .fyodos-bubble-user{font-size:14.5px;line-height:21px;}
39
+ .fyodos-bubble--expanded .fyodos-bubble-reply{font-size:17px;line-height:25px;}
40
+ .fyodos-bubble--expanded .fyodos-bubble-row{padding:12px 16px;gap:10px;}
41
+ .fyodos-bubble--expanded .fyodos-bubble-busy{padding:8px 24px;}
42
+ .fyodos-bubble--expanded .fyodos-bubble-row input{padding:12px 14px;font-size:16px;}
43
+ .fyodos-bubble--expanded .fyodos-bubble-mic,.fyodos-bubble--expanded .fyodos-bubble-row button{width:38px;height:38px;min-width:38px;font-size:19px;}
44
+ .fyodos-bubble-close,.fyodos-bubble-unexpand{position:absolute;top:4px;z-index:1;display:flex;align-items:center;justify-content:center;border:none;background:transparent;font-size:18px;line-height:18px;width:28px;height:28px;cursor:pointer;padding:0;opacity:.75;}
45
+ .fyodos-bubble-close{right:6px;}
46
+ .fyodos-bubble-unexpand{right:38px;display:none;}
32
47
  .fyodos-bubble-user-row{display:flex;flex-direction:row;align-items:flex-start;gap:6px;}
33
48
  .fyodos-bubble-scroll{overflow-y:auto;padding:12px 14px 4px;display:flex;flex-direction:column;gap:6px;min-height:18px;scrollbar-width:none;-ms-overflow-style:none;}
34
49
  .fyodos-bubble-scroll::-webkit-scrollbar{display:none;width:0;height:0;}
@@ -195,12 +210,13 @@ export function mountOrb(controller, options = {}) {
195
210
  const prompt = resolveCurrentBubblePrompt();
196
211
  openTextPanel(prompt
197
212
  ? bubblePromptToAffirmative(prompt, {
198
- locale: controller.config.locale,
213
+ locale: currentBubbleLocale(),
199
214
  messages: controller.messages,
200
215
  })
201
216
  : undefined);
202
217
  return;
203
218
  }
219
+ userTriedVoice = true;
204
220
  void controller.toggleListening();
205
221
  });
206
222
  thoughtBubbleEl.addEventListener('keydown', (event) => {
@@ -370,10 +386,22 @@ export function mountOrb(controller, options = {}) {
370
386
  btn.addEventListener('lostpointercapture', onPointerUp);
371
387
  // ── Text bubble ─────────────────────────────────────────────────────────────
372
388
  let bubbleOpen = false;
389
+ // Reduced (default) size stays exactly as before. Tapping any EMPTY area of
390
+ // the panel (not a message, not the input row, not a button — so text stays
391
+ // selectable/copyable) grows it to the expanded layout; shrinking back is an
392
+ // explicit tap on `unexpandBtn` next to the close button.
393
+ let bubbleExpanded = false;
373
394
  // When the mic is denied/unavailable, tapping the orb still puts it in the
374
395
  // SAME "active/listening" visual state and reveals the keyboard chip — the
375
396
  // user is never blocked, text is always an open avenue (Change 1).
376
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;
377
405
  const bubble = document.createElement('div');
378
406
  bubble.className = 'fyodos-bubble';
379
407
  bubble.style.display = 'none';
@@ -406,6 +434,19 @@ export function mountOrb(controller, options = {}) {
406
434
  closeBtn.textContent = '×';
407
435
  closeBtn.setAttribute('aria-label', 'Close');
408
436
  closeBtn.addEventListener('click', () => setBubble(false));
437
+ // Collapse button — only shown while expanded, right next to the close
438
+ // button (same minimalist style). Tapping an empty area of the panel is
439
+ // what EXPANDS it; shrinking back is always an explicit, discoverable tap.
440
+ const unexpandBtn = document.createElement('button');
441
+ unexpandBtn.type = 'button';
442
+ unexpandBtn.className = 'fyodos-bubble-unexpand';
443
+ unexpandBtn.innerHTML =
444
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"></path></svg>';
445
+ unexpandBtn.setAttribute('aria-label', 'Collapse');
446
+ unexpandBtn.addEventListener('click', (e) => {
447
+ e.stopPropagation();
448
+ setBubbleExpanded(false);
449
+ });
409
450
  // Loading row shown at the top while an older page of the session history
410
451
  // streams in (scroll-to-top pagination).
411
452
  const loaderRow = document.createElement('div');
@@ -429,6 +470,12 @@ export function mountOrb(controller, options = {}) {
429
470
  signature.endsWith(renderedSignature);
430
471
  const fromBottom = exchangeScroll.scrollHeight - exchangeScroll.scrollTop;
431
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 ?? {}) };
432
479
  exchangeList.replaceChildren();
433
480
  for (const ex of exchanges) {
434
481
  const block = document.createElement('div');
@@ -436,11 +483,13 @@ export function mountOrb(controller, options = {}) {
436
483
  const user = document.createElement('div');
437
484
  user.className = 'fyodos-bubble-user';
438
485
  user.textContent = ex.userText;
486
+ user.style.color = t.bodyColor;
439
487
  block.append(user);
440
488
  if (ex.replyText) {
441
489
  const reply = document.createElement('div');
442
490
  reply.className = 'fyodos-bubble-reply';
443
491
  reply.textContent = ex.replyText;
492
+ reply.style.color = t.titleColor;
444
493
  block.append(reply);
445
494
  }
446
495
  exchangeList.append(block);
@@ -547,7 +596,8 @@ export function mountOrb(controller, options = {}) {
547
596
  if (active) {
548
597
  micBtn.style.background = accent;
549
598
  micBtn.style.border = 'none';
550
- micBtn.style.color = '#000';
599
+ // Ink readable on the (customizable) accent — dark accents need light ink.
600
+ micBtn.style.color = fiodosInputInkColor(accent);
551
601
  micBtn.style.borderRadius = sendRadius;
552
602
  }
553
603
  else {
@@ -559,8 +609,71 @@ export function mountOrb(controller, options = {}) {
559
609
  micBtn.setAttribute('aria-label', active ? ui.micStopLabel : ui.micLabel);
560
610
  micBtn.title = active ? ui.micStopLabel : ui.micLabel;
561
611
  }
562
- bubble.append(closeBtn, exchangeScroll, busyRow, inputRow);
612
+ bubble.append(closeBtn, unexpandBtn, exchangeScroll, busyRow, inputRow);
563
613
  anchor.appendChild(bubble);
614
+ function setBubbleExpanded(expanded) {
615
+ if (bubbleExpanded === expanded)
616
+ return;
617
+ bubbleExpanded = expanded;
618
+ bubble.classList.toggle('fyodos-bubble--expanded', expanded);
619
+ unexpandBtn.style.display = expanded ? 'flex' : 'none';
620
+ positionBubble();
621
+ }
622
+ // Tap-to-expand: a plain click on the panel grows it. Text selection is
623
+ // never broken: a drag-selection is a move (threshold guard), a double/
624
+ // triple-click word/paragraph selection cancels the pending expand (detail
625
+ // guard + delay), and any active selection blocks it. Controls (input row,
626
+ // buttons) never expand.
627
+ let bubbleTapX = 0;
628
+ let bubbleTapY = 0;
629
+ let pendingExpandTimer = null;
630
+ const TAP_MOVE_THRESHOLD = 8;
631
+ const hasActiveSelection = () => {
632
+ const selection = typeof window !== 'undefined' ? window.getSelection?.() : null;
633
+ return !!selection && !selection.isCollapsed && selection.toString().length > 0;
634
+ };
635
+ const cancelPendingExpand = () => {
636
+ if (pendingExpandTimer != null) {
637
+ window.clearTimeout(pendingExpandTimer);
638
+ pendingExpandTimer = null;
639
+ }
640
+ };
641
+ bubble.addEventListener('pointerdown', (e) => {
642
+ bubbleTapX = e.clientX;
643
+ bubbleTapY = e.clientY;
644
+ });
645
+ bubble.addEventListener('click', (e) => {
646
+ if (bubbleExpanded)
647
+ return;
648
+ const target = e.target;
649
+ if (target?.closest('.fyodos-bubble-row, .fyodos-bubble-close, .fyodos-bubble-unexpand, button, input, a')) {
650
+ return;
651
+ }
652
+ const moved = Math.hypot(e.clientX - bubbleTapX, e.clientY - bubbleTapY);
653
+ if (moved > TAP_MOVE_THRESHOLD)
654
+ return;
655
+ // Double/triple click is a word/paragraph selection gesture, never expand
656
+ // (it also cancels the pending expand from its own first click).
657
+ if (e.detail > 1) {
658
+ cancelPendingExpand();
659
+ return;
660
+ }
661
+ cancelPendingExpand();
662
+ const overText = !!target?.closest('.fyodos-bubble-user, .fyodos-bubble-reply');
663
+ if (!overText && !hasActiveSelection()) {
664
+ // Genuinely empty area, nothing selected — expand immediately.
665
+ setBubbleExpanded(true);
666
+ return;
667
+ }
668
+ // Over message text, or a selection may still be collapsing: expand after
669
+ // a short delay, re-checking the selection right before — a double-click's
670
+ // second click or a real selection cancels it.
671
+ pendingExpandTimer = window.setTimeout(() => {
672
+ pendingExpandTimer = null;
673
+ if (!hasActiveSelection())
674
+ setBubbleExpanded(true);
675
+ }, 280);
676
+ });
564
677
  // Initial placement (after the bubble exists, so applyDeployment can lay it out).
565
678
  reposition();
566
679
  function applyBubbleTheme() {
@@ -571,6 +684,7 @@ export function mountOrb(controller, options = {}) {
571
684
  if (modalTheme?.borderRadius != null)
572
685
  bubble.style.borderRadius = `${modalTheme.borderRadius}px`;
573
686
  closeBtn.style.color = t.bodyColor;
687
+ unexpandBtn.style.color = t.bodyColor;
574
688
  exchangeList.querySelectorAll('.fyodos-bubble-user').forEach((node) => {
575
689
  node.style.color = t.bodyColor;
576
690
  });
@@ -582,6 +696,9 @@ export function mountOrb(controller, options = {}) {
582
696
  textInput.style.color = fiodosInputInkColor(inputBg);
583
697
  textInput.style.setProperty('--fyodos-input-placeholder', fiodosInputMutedInkColor(inputBg));
584
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);
585
702
  sendBtn.style.borderRadius = `${modalTheme?.sendButtonRadius ?? 10}px`;
586
703
  syncMicButton();
587
704
  busyRow.querySelectorAll('span').forEach((s) => {
@@ -605,8 +722,18 @@ export function mountOrb(controller, options = {}) {
605
722
  controller.resetBubbleWindow();
606
723
  // Closing the panel returns the orb to idle (also clears mic fallback).
607
724
  micFallbackActive = false;
725
+ // Always re-open at the reduced size.
726
+ cancelPendingExpand();
727
+ setBubbleExpanded(false);
608
728
  }
609
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());
610
737
  }
611
738
  function submitText() {
612
739
  // While a turn is in flight the button is a STOP button; never start a new
@@ -671,8 +798,22 @@ export function mountOrb(controller, options = {}) {
671
798
  applyDeployment();
672
799
  }
673
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
+ }
674
815
  function resolveCurrentBubblePrompt() {
675
- 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 });
676
817
  }
677
818
  // Render the CTA on ONE line at its NATURAL size. When the phrase is too long
678
819
  // to fit the max width, scroll it (marquee, right-to-left, looping) instead of
@@ -707,29 +848,17 @@ export function mountOrb(controller, options = {}) {
707
848
  track.appendChild(second);
708
849
  thoughtBubbleEl.appendChild(track);
709
850
  }
710
- function applyThoughtBubbleTheme() {
851
+ function applyThoughtBubbleTheme(widthPx) {
711
852
  const t = bubbleTheme;
712
853
  const { fontPx, padH, approxHeight } = resolveBubblePadding(t);
713
- const shapeCss = bubbleShapeToCss(t, approxHeight);
854
+ const shapeCss = bubbleShapeToCss(t, approxHeight, 1, widthPx);
714
855
  thoughtBubbleEl.style.fontSize = `${fontPx}px`;
715
856
  thoughtBubbleEl.style.height = `${approxHeight}px`;
716
857
  thoughtBubbleEl.style.padding = `0 ${padH}px`;
717
858
  thoughtBubbleEl.style.color = t.textColor;
718
859
  thoughtBubbleEl.style.background = t.backgroundColor;
719
860
  thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
720
- const setImportant = (prop, value) => {
721
- if (!value) {
722
- thoughtBubbleEl.style.removeProperty(prop);
723
- return;
724
- }
725
- thoughtBubbleEl.style.setProperty(prop, value, 'important');
726
- };
727
- setImportant('border-radius', shapeCss.borderRadius);
728
- setImportant('border-top-left-radius', shapeCss.borderTopLeftRadius);
729
- setImportant('border-top-right-radius', shapeCss.borderTopRightRadius);
730
- setImportant('border-bottom-left-radius', shapeCss.borderBottomLeftRadius);
731
- setImportant('border-bottom-right-radius', shapeCss.borderBottomRightRadius);
732
- setImportant('clip-path', shapeCss.clipPath);
861
+ applyBubbleShapeCss(thoughtBubbleEl, shapeCss);
733
862
  }
734
863
  function positionThoughtBubble() {
735
864
  const dep = resolveOrbDeployment(effectivePosition());
@@ -764,6 +893,11 @@ export function mountOrb(controller, options = {}) {
764
893
  thoughtBubbleEl.style.display = 'flex';
765
894
  applyThoughtBubbleTheme();
766
895
  layoutThoughtContent(prompt);
896
+ if (bubbleTheme.shape === 'cloud') {
897
+ const widthPx = thoughtBubbleEl.offsetWidth;
898
+ if (widthPx > 0)
899
+ applyThoughtBubbleTheme(widthPx);
900
+ }
767
901
  positionThoughtBubble();
768
902
  }
769
903
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
@@ -894,6 +1028,7 @@ export function mountOrb(controller, options = {}) {
894
1028
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
895
1029
  if (controller.voiceAvailable && voiceInputEnabled) {
896
1030
  micFallbackActive = false;
1031
+ userTriedVoice = true;
897
1032
  void controller.toggleListening();
898
1033
  return;
899
1034
  }
@@ -905,6 +1040,7 @@ export function mountOrb(controller, options = {}) {
905
1040
  openTextPanel();
906
1041
  return;
907
1042
  }
1043
+ userTriedVoice = true;
908
1044
  void controller.toggleListening();
909
1045
  };
910
1046
  sendBtn.onclick = () => {
@@ -943,11 +1079,15 @@ export function mountOrb(controller, options = {}) {
943
1079
  // Track phase transitions to fire haptics: start/stop listening + response.
944
1080
  let lastPhase = null;
945
1081
  function render(state) {
946
- // Mic just became unusable mid-attempt (no Permissions API): fall back to the
947
- // 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.
948
1088
  if (state.voiceBlocked && !lastVoiceBlocked) {
949
1089
  lastVoiceBlocked = true;
950
- if (enableTextInput && !bubbleOpen && !state.isBusy) {
1090
+ if (userTriedVoice && enableTextInput && !bubbleOpen && !state.isBusy) {
951
1091
  openTextPanel();
952
1092
  return;
953
1093
  }
@@ -1016,9 +1156,11 @@ export function mountOrb(controller, options = {}) {
1016
1156
  }
1017
1157
  const unsubscribe = controller.subscribe(render);
1018
1158
  render(controller.getState());
1019
- // Track the current route so the thought bubble re-resolves its CTA on
1020
- // navigation. No universal navigation event exists, so poll + listen to
1021
- // 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();
1022
1164
  const readRoute = () => {
1023
1165
  let next = null;
1024
1166
  try {
@@ -1027,8 +1169,10 @@ export function mountOrb(controller, options = {}) {
1027
1169
  catch {
1028
1170
  /* navigation read is best-effort */
1029
1171
  }
1030
- if (next !== currentRoute) {
1172
+ const nextLocale = currentBubbleLocale();
1173
+ if (next !== currentRoute || nextLocale !== lastBubbleLocale) {
1031
1174
  currentRoute = next;
1175
+ lastBubbleLocale = nextLocale;
1032
1176
  syncThoughtBubble(controller.getState());
1033
1177
  }
1034
1178
  };
@@ -8,7 +8,7 @@
8
8
  * pixel-for-pixel — no "basic circle" divergence. Vue / Svelte / Angular mount
9
9
  * THIS orb through createFiodosAgent → mountOrb.
10
10
  */
11
- import { chipBorderRadius, chipIconBaseSizePx, chipIconCenterOffset, chipIconSpec, computeOutwardBorderLayout, normalizeChipIconScale, normalizeKeyboardChipIcon, orbFillDepthBoxShadow, orbOuterGlowBoxShadow, resolveChipIconColor, resolveFiodosOrbMarkBars, resolveInnerBorderColor, resolveOrbBackgroundColor, } from '@fiodos/core';
11
+ import { chipBorderRadius, chipIconBaseSizePx, chipIconCenterOffset, chipIconSpec, computeOutwardBorderLayout, normalizeChipIconScale, normalizeKeyboardChipIcon, orbFillDepthBoxShadow, orbOuterGlowBoxShadow, resolveChipIconColor, fiodosOrbMarkIdleHalfHeight, resolveFiodosOrbMarkAnimatedBars, resolveInnerBorderColor, resolveOrbBackgroundColor, } from '@fiodos/core';
12
12
  const SVG_NS = 'http://www.w3.org/2000/svg';
13
13
  export const DEFAULT_ORB_APPEARANCE = {
14
14
  accentColor: '#4f8cff',
@@ -73,9 +73,15 @@ function buildDoubleBorder(opts) {
73
73
  root.appendChild(mid);
74
74
  return { root, fill };
75
75
  }
76
- // ── Brand mark (port of FiodosOrbMark) ───────────────────────────────────────
77
- function buildOrbMark(orbSize) {
78
- const { bars, color, radius } = resolveFiodosOrbMarkBars(orbSize);
76
+ /**
77
+ * Fiodos brand mark (idle, no logo). Breathes very gently on web — a
78
+ * direct port of the landing hero orbs' waveform easing (see
79
+ * `fiodosOrbMarkIdleHalfHeight` in @fiodos/core) — deliberately subtler than
80
+ * and distinct from the volume-reactive waveform shown while listening.
81
+ * Tinted with the same "audio color" (waveformColor) as that waveform.
82
+ */
83
+ function buildOrbMark(orbSize, markColor) {
84
+ const { bars, color, radius } = resolveFiodosOrbMarkAnimatedBars(orbSize, markColor);
79
85
  const svg = svgEl('svg', {
80
86
  width: orbSize,
81
87
  height: orbSize,
@@ -83,10 +89,31 @@ function buildOrbMark(orbSize) {
83
89
  'aria-hidden': 'true',
84
90
  });
85
91
  svg.style.display = 'block';
86
- for (const b of bars) {
87
- svg.appendChild(svgEl('rect', { x: b.x, y: b.y, width: b.width, height: b.height, rx: radius, ry: radius, fill: color }));
88
- }
89
- return svg;
92
+ const rects = bars.map((b) => {
93
+ const rect = svgEl('rect', { x: b.x, y: b.y, width: b.width, height: b.height, rx: radius, ry: radius, fill: color });
94
+ svg.appendChild(rect);
95
+ return rect;
96
+ });
97
+ let raf = 0;
98
+ return {
99
+ el: svg,
100
+ start() {
101
+ const t0 = performance.now();
102
+ const loop = (now) => {
103
+ raf = requestAnimationFrame(loop);
104
+ const t = (now - t0) / 1000;
105
+ bars.forEach((b, i) => {
106
+ const half = fiodosOrbMarkIdleHalfHeight(t, i, b.baseHalfHeight, b.maxHalfHeight);
107
+ rects[i].setAttribute('y', (b.centerY - half).toFixed(2));
108
+ rects[i].setAttribute('height', (2 * half).toFixed(2));
109
+ });
110
+ };
111
+ raf = requestAnimationFrame(loop);
112
+ },
113
+ stop() {
114
+ cancelAnimationFrame(raf);
115
+ },
116
+ };
90
117
  }
91
118
  // ── Waveform (port of VoiceWaveform: 9 bars, bell profile, rAF) ───────────────
92
119
  const WAVE_BARS = 9;
@@ -377,6 +404,7 @@ export function createOrbVisual(initial) {
377
404
  let volume = 0;
378
405
  let fillEl = null;
379
406
  let waveform = null;
407
+ let markAnim = null;
380
408
  function rebuild() {
381
409
  const size = appearance.size ?? DEFAULT_ORB_APPEARANCE.size;
382
410
  const fill = resolveOrbBackgroundColor({
@@ -388,6 +416,8 @@ export function createOrbVisual(initial) {
388
416
  const interiorColor = resolveInnerBorderColor(appearance.innerBorderColor);
389
417
  waveform?.stop();
390
418
  waveform = null;
419
+ markAnim?.stop();
420
+ markAnim = null;
391
421
  wrapper.replaceChildren();
392
422
  const { root, fill: fillNode } = buildDoubleBorder({
393
423
  fillDiameter: size,
@@ -409,6 +439,8 @@ export function createOrbVisual(initial) {
409
439
  const waveColor = appearance.waveformColor ?? '#ffffff';
410
440
  waveform?.stop();
411
441
  waveform = null;
442
+ markAnim?.stop();
443
+ markAnim = null;
412
444
  fillEl.replaceChildren();
413
445
  // The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
414
446
  // rings + dot when there's no photo) to mirror the dashboard editor, so the
@@ -453,7 +485,9 @@ export function createOrbVisual(initial) {
453
485
  return;
454
486
  }
455
487
  if (state === 'idle') {
456
- fillEl.appendChild(buildOrbMark(size));
488
+ markAnim = buildOrbMark(size, appearance.waveformColor);
489
+ fillEl.appendChild(markAnim.el);
490
+ markAnim.start();
457
491
  }
458
492
  }
459
493
  rebuild();
@@ -60,7 +60,9 @@ export interface WatchPublishedConfigOptions {
60
60
  }
61
61
  /**
62
62
  * Starts watching the published config. Returns an unsubscribe function.
63
- * Best-effort: a failed fetch reports `null` (the orb keeps its current/default
64
- * look) and never throws into the host app.
63
+ * Best-effort: a failed fetch is SILENTLY skipped — the orb keeps its
64
+ * last-known-good (or cached) look instead of reverting to the default — and
65
+ * never throws into the host app. `onChange(null)` is reported ONLY when the
66
+ * backend confirms nothing is published.
65
67
  */
66
68
  export declare function watchPublishedConfig(controller: AgentController, options: WatchPublishedConfigOptions): () => void;
@@ -11,6 +11,38 @@
11
11
  */
12
12
  import { mapPublishedConfigToOrbProps, resolveBubbleTheme, } from '@fiodos/core';
13
13
  import { DEFAULT_ORB_APPEARANCE } from './orbView.js';
14
+ /**
15
+ * Last-known-good published config, persisted so the customized orb survives
16
+ * page reloads during a backend outage and shows instantly on the next visit.
17
+ * Best-effort: storage failures (private mode, quota) are ignored.
18
+ */
19
+ const PUBLISHED_CONFIG_CACHE_KEY = 'fyodos:published-config:v1';
20
+ function readCachedPublishedConfig() {
21
+ try {
22
+ if (typeof window === 'undefined' || !window.localStorage)
23
+ return null;
24
+ const raw = window.localStorage.getItem(PUBLISHED_CONFIG_CACHE_KEY);
25
+ return raw ? JSON.parse(raw) : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function writeCachedPublishedConfig(published) {
32
+ try {
33
+ if (typeof window === 'undefined' || !window.localStorage)
34
+ return;
35
+ if (published) {
36
+ window.localStorage.setItem(PUBLISHED_CONFIG_CACHE_KEY, JSON.stringify(published));
37
+ }
38
+ else {
39
+ window.localStorage.removeItem(PUBLISHED_CONFIG_CACHE_KEY);
40
+ }
41
+ }
42
+ catch {
43
+ /* best-effort cache */
44
+ }
45
+ }
14
46
  function toResolved(props, published) {
15
47
  const enabled = published.orbEnabled !== false;
16
48
  const theme = props.theme;
@@ -45,8 +77,10 @@ function toResolved(props, published) {
45
77
  }
46
78
  /**
47
79
  * Starts watching the published config. Returns an unsubscribe function.
48
- * Best-effort: a failed fetch reports `null` (the orb keeps its current/default
49
- * look) and never throws into the host app.
80
+ * Best-effort: a failed fetch is SILENTLY skipped — the orb keeps its
81
+ * last-known-good (or cached) look instead of reverting to the default — and
82
+ * never throws into the host app. `onChange(null)` is reported ONLY when the
83
+ * backend confirms nothing is published.
50
84
  */
51
85
  export function watchPublishedConfig(controller, options) {
52
86
  const fetcher = controller.config.backend.fetchPublishedConfig;
@@ -55,18 +89,33 @@ export function watchPublishedConfig(controller, options) {
55
89
  return () => { };
56
90
  }
57
91
  let cancelled = false;
92
+ // Hydrate from the local cache so the customized orb shows instantly (and
93
+ // survives page loads while the backend is unreachable). The first network
94
+ // response below overwrites it as the source of truth.
95
+ const cached = readCachedPublishedConfig();
96
+ if (cached) {
97
+ options.onChange(toResolved(mapPublishedConfigToOrbProps(cached), cached));
98
+ }
58
99
  const load = async () => {
59
100
  try {
60
101
  const res = await fetcher.call(controller.config.backend);
61
102
  if (cancelled)
62
103
  return;
63
- options.onChange(res.published
64
- ? toResolved(mapPublishedConfigToOrbProps(res.published), res.published)
65
- : null);
104
+ if (res.published) {
105
+ options.onChange(toResolved(mapPublishedConfigToOrbProps(res.published), res.published));
106
+ writeCachedPublishedConfig(res.published);
107
+ }
108
+ else {
109
+ // A SUCCESSFUL response with nothing published: the project truly has
110
+ // no custom design, so the defaults are correct.
111
+ options.onChange(null);
112
+ writeCachedPublishedConfig(null);
113
+ }
66
114
  }
67
115
  catch {
68
- if (!cancelled)
69
- options.onChange(null);
116
+ // Transient failure (timeout, network blip after hours open, backend
117
+ // redeploy…): keep the current appearance. The next successful poll
118
+ // refreshes it.
70
119
  }
71
120
  };
72
121
  void load();
@@ -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.12";
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.12';
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.12",
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": {
@@ -23,16 +23,18 @@
23
23
  "LICENSE"
24
24
  ],
25
25
  "scripts": {
26
- "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node ../../scripts/fix-esm-extensions.mjs dist/esm && node ../../scripts/mark-dual.mjs",
26
+ "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node ../../scripts/fix-esm-extensions.mjs dist/esm && node ../../scripts/mark-dual.mjs && npm run build:embed",
27
+ "build:embed": "esbuild src/embed/global.ts --bundle --format=iife --target=es2019 --minify --outfile=dist/embed/fiodos-embed.js",
27
28
  "typecheck": "tsc -p tsconfig.json --noEmit",
28
29
  "test": "node --import tsx --test test/*.test.ts",
29
30
  "prepublishOnly": "node ../../scripts/sync-sdk-version.mjs && npm run build"
30
31
  },
31
32
  "dependencies": {
32
- "@fiodos/core": "^0.1.9"
33
+ "@fiodos/core": "^0.1.10"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/node": "^22.0.0",
37
+ "esbuild": "^0.28.1",
36
38
  "tsx": "^4.19.0",
37
39
  "typescript": "^5.6.0"
38
40
  }