@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
@@ -30,8 +30,23 @@ const CSS = `
30
30
  .fyodos-orb-btn img{pointer-events:none;-webkit-user-drag:none;user-select:none;}
31
31
  .fyodos-orb-btn:focus-visible{outline:2px solid #8ab6ff;outline-offset:4px;border-radius:50%;}
32
32
  .fyodos-orb-anchor{position:relative;display:inline-flex;}
33
- .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;}
34
- .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;}
33
+ .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;}
34
+ .fyodos-bubble--expanded{width:min(560px,calc(100vw - 32px));max-height:min(620px,75vh);cursor:auto;}
35
+ .fyodos-bubble-user,.fyodos-bubble-reply{cursor:text;}
36
+ .fyodos-bubble-row{cursor:default;}
37
+ .fyodos-bubble--expanded .fyodos-bubble-scroll{padding:22px 24px 8px;gap:10px;}
38
+ .fyodos-bubble--expanded .fyodos-bubble-exchanges{gap:14px;}
39
+ .fyodos-bubble--expanded .fyodos-bubble-exchange{gap:9px;}
40
+ .fyodos-bubble--expanded .fyodos-bubble-exchange:first-child .fyodos-bubble-user{padding-right:0;}
41
+ .fyodos-bubble--expanded .fyodos-bubble-user{font-size:14.5px;line-height:21px;}
42
+ .fyodos-bubble--expanded .fyodos-bubble-reply{font-size:17px;line-height:25px;}
43
+ .fyodos-bubble--expanded .fyodos-bubble-row{padding:12px 16px;gap:10px;}
44
+ .fyodos-bubble--expanded .fyodos-bubble-busy{padding:8px 24px;}
45
+ .fyodos-bubble--expanded .fyodos-bubble-row input{padding:12px 14px;font-size:16px;}
46
+ .fyodos-bubble--expanded .fyodos-bubble-mic,.fyodos-bubble--expanded .fyodos-bubble-row button{width:38px;height:38px;min-width:38px;font-size:19px;}
47
+ .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;}
48
+ .fyodos-bubble-close{right:6px;}
49
+ .fyodos-bubble-unexpand{right:38px;display:none;}
35
50
  .fyodos-bubble-user-row{display:flex;flex-direction:row;align-items:flex-start;gap:6px;}
36
51
  .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;}
37
52
  .fyodos-bubble-scroll::-webkit-scrollbar{display:none;width:0;height:0;}
@@ -198,12 +213,13 @@ function mountOrb(controller, options = {}) {
198
213
  const prompt = resolveCurrentBubblePrompt();
199
214
  openTextPanel(prompt
200
215
  ? (0, core_1.bubblePromptToAffirmative)(prompt, {
201
- locale: controller.config.locale,
216
+ locale: currentBubbleLocale(),
202
217
  messages: controller.messages,
203
218
  })
204
219
  : undefined);
205
220
  return;
206
221
  }
222
+ userTriedVoice = true;
207
223
  void controller.toggleListening();
208
224
  });
209
225
  thoughtBubbleEl.addEventListener('keydown', (event) => {
@@ -373,10 +389,22 @@ function mountOrb(controller, options = {}) {
373
389
  btn.addEventListener('lostpointercapture', onPointerUp);
374
390
  // ── Text bubble ─────────────────────────────────────────────────────────────
375
391
  let bubbleOpen = false;
392
+ // Reduced (default) size stays exactly as before. Tapping any EMPTY area of
393
+ // the panel (not a message, not the input row, not a button — so text stays
394
+ // selectable/copyable) grows it to the expanded layout; shrinking back is an
395
+ // explicit tap on `unexpandBtn` next to the close button.
396
+ let bubbleExpanded = false;
376
397
  // When the mic is denied/unavailable, tapping the orb still puts it in the
377
398
  // SAME "active/listening" visual state and reveals the keyboard chip — the
378
399
  // user is never blocked, text is always an open avenue (Change 1).
379
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;
380
408
  const bubble = document.createElement('div');
381
409
  bubble.className = 'fyodos-bubble';
382
410
  bubble.style.display = 'none';
@@ -409,6 +437,19 @@ function mountOrb(controller, options = {}) {
409
437
  closeBtn.textContent = '×';
410
438
  closeBtn.setAttribute('aria-label', 'Close');
411
439
  closeBtn.addEventListener('click', () => setBubble(false));
440
+ // Collapse button — only shown while expanded, right next to the close
441
+ // button (same minimalist style). Tapping an empty area of the panel is
442
+ // what EXPANDS it; shrinking back is always an explicit, discoverable tap.
443
+ const unexpandBtn = document.createElement('button');
444
+ unexpandBtn.type = 'button';
445
+ unexpandBtn.className = 'fyodos-bubble-unexpand';
446
+ unexpandBtn.innerHTML =
447
+ '<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>';
448
+ unexpandBtn.setAttribute('aria-label', 'Collapse');
449
+ unexpandBtn.addEventListener('click', (e) => {
450
+ e.stopPropagation();
451
+ setBubbleExpanded(false);
452
+ });
412
453
  // Loading row shown at the top while an older page of the session history
413
454
  // streams in (scroll-to-top pagination).
414
455
  const loaderRow = document.createElement('div');
@@ -432,6 +473,12 @@ function mountOrb(controller, options = {}) {
432
473
  signature.endsWith(renderedSignature);
433
474
  const fromBottom = exchangeScroll.scrollHeight - exchangeScroll.scrollTop;
434
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 ?? {}) };
435
482
  exchangeList.replaceChildren();
436
483
  for (const ex of exchanges) {
437
484
  const block = document.createElement('div');
@@ -439,11 +486,13 @@ function mountOrb(controller, options = {}) {
439
486
  const user = document.createElement('div');
440
487
  user.className = 'fyodos-bubble-user';
441
488
  user.textContent = ex.userText;
489
+ user.style.color = t.bodyColor;
442
490
  block.append(user);
443
491
  if (ex.replyText) {
444
492
  const reply = document.createElement('div');
445
493
  reply.className = 'fyodos-bubble-reply';
446
494
  reply.textContent = ex.replyText;
495
+ reply.style.color = t.titleColor;
447
496
  block.append(reply);
448
497
  }
449
498
  exchangeList.append(block);
@@ -550,7 +599,8 @@ function mountOrb(controller, options = {}) {
550
599
  if (active) {
551
600
  micBtn.style.background = accent;
552
601
  micBtn.style.border = 'none';
553
- 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);
554
604
  micBtn.style.borderRadius = sendRadius;
555
605
  }
556
606
  else {
@@ -562,8 +612,71 @@ function mountOrb(controller, options = {}) {
562
612
  micBtn.setAttribute('aria-label', active ? ui.micStopLabel : ui.micLabel);
563
613
  micBtn.title = active ? ui.micStopLabel : ui.micLabel;
564
614
  }
565
- bubble.append(closeBtn, exchangeScroll, busyRow, inputRow);
615
+ bubble.append(closeBtn, unexpandBtn, exchangeScroll, busyRow, inputRow);
566
616
  anchor.appendChild(bubble);
617
+ function setBubbleExpanded(expanded) {
618
+ if (bubbleExpanded === expanded)
619
+ return;
620
+ bubbleExpanded = expanded;
621
+ bubble.classList.toggle('fyodos-bubble--expanded', expanded);
622
+ unexpandBtn.style.display = expanded ? 'flex' : 'none';
623
+ positionBubble();
624
+ }
625
+ // Tap-to-expand: a plain click on the panel grows it. Text selection is
626
+ // never broken: a drag-selection is a move (threshold guard), a double/
627
+ // triple-click word/paragraph selection cancels the pending expand (detail
628
+ // guard + delay), and any active selection blocks it. Controls (input row,
629
+ // buttons) never expand.
630
+ let bubbleTapX = 0;
631
+ let bubbleTapY = 0;
632
+ let pendingExpandTimer = null;
633
+ const TAP_MOVE_THRESHOLD = 8;
634
+ const hasActiveSelection = () => {
635
+ const selection = typeof window !== 'undefined' ? window.getSelection?.() : null;
636
+ return !!selection && !selection.isCollapsed && selection.toString().length > 0;
637
+ };
638
+ const cancelPendingExpand = () => {
639
+ if (pendingExpandTimer != null) {
640
+ window.clearTimeout(pendingExpandTimer);
641
+ pendingExpandTimer = null;
642
+ }
643
+ };
644
+ bubble.addEventListener('pointerdown', (e) => {
645
+ bubbleTapX = e.clientX;
646
+ bubbleTapY = e.clientY;
647
+ });
648
+ bubble.addEventListener('click', (e) => {
649
+ if (bubbleExpanded)
650
+ return;
651
+ const target = e.target;
652
+ if (target?.closest('.fyodos-bubble-row, .fyodos-bubble-close, .fyodos-bubble-unexpand, button, input, a')) {
653
+ return;
654
+ }
655
+ const moved = Math.hypot(e.clientX - bubbleTapX, e.clientY - bubbleTapY);
656
+ if (moved > TAP_MOVE_THRESHOLD)
657
+ return;
658
+ // Double/triple click is a word/paragraph selection gesture, never expand
659
+ // (it also cancels the pending expand from its own first click).
660
+ if (e.detail > 1) {
661
+ cancelPendingExpand();
662
+ return;
663
+ }
664
+ cancelPendingExpand();
665
+ const overText = !!target?.closest('.fyodos-bubble-user, .fyodos-bubble-reply');
666
+ if (!overText && !hasActiveSelection()) {
667
+ // Genuinely empty area, nothing selected — expand immediately.
668
+ setBubbleExpanded(true);
669
+ return;
670
+ }
671
+ // Over message text, or a selection may still be collapsing: expand after
672
+ // a short delay, re-checking the selection right before — a double-click's
673
+ // second click or a real selection cancels it.
674
+ pendingExpandTimer = window.setTimeout(() => {
675
+ pendingExpandTimer = null;
676
+ if (!hasActiveSelection())
677
+ setBubbleExpanded(true);
678
+ }, 280);
679
+ });
567
680
  // Initial placement (after the bubble exists, so applyDeployment can lay it out).
568
681
  reposition();
569
682
  function applyBubbleTheme() {
@@ -574,6 +687,7 @@ function mountOrb(controller, options = {}) {
574
687
  if (modalTheme?.borderRadius != null)
575
688
  bubble.style.borderRadius = `${modalTheme.borderRadius}px`;
576
689
  closeBtn.style.color = t.bodyColor;
690
+ unexpandBtn.style.color = t.bodyColor;
577
691
  exchangeList.querySelectorAll('.fyodos-bubble-user').forEach((node) => {
578
692
  node.style.color = t.bodyColor;
579
693
  });
@@ -585,6 +699,9 @@ function mountOrb(controller, options = {}) {
585
699
  textInput.style.color = (0, core_1.fiodosInputInkColor)(inputBg);
586
700
  textInput.style.setProperty('--fyodos-input-placeholder', (0, core_1.fiodosInputMutedInkColor)(inputBg));
587
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);
588
705
  sendBtn.style.borderRadius = `${modalTheme?.sendButtonRadius ?? 10}px`;
589
706
  syncMicButton();
590
707
  busyRow.querySelectorAll('span').forEach((s) => {
@@ -608,8 +725,18 @@ function mountOrb(controller, options = {}) {
608
725
  controller.resetBubbleWindow();
609
726
  // Closing the panel returns the orb to idle (also clears mic fallback).
610
727
  micFallbackActive = false;
728
+ // Always re-open at the reduced size.
729
+ cancelPendingExpand();
730
+ setBubbleExpanded(false);
611
731
  }
612
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());
613
740
  }
614
741
  function submitText() {
615
742
  // While a turn is in flight the button is a STOP button; never start a new
@@ -674,8 +801,22 @@ function mountOrb(controller, options = {}) {
674
801
  applyDeployment();
675
802
  }
676
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
+ }
677
818
  function resolveCurrentBubblePrompt() {
678
- 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 });
679
820
  }
680
821
  // Render the CTA on ONE line at its NATURAL size. When the phrase is too long
681
822
  // to fit the max width, scroll it (marquee, right-to-left, looping) instead of
@@ -710,29 +851,17 @@ function mountOrb(controller, options = {}) {
710
851
  track.appendChild(second);
711
852
  thoughtBubbleEl.appendChild(track);
712
853
  }
713
- function applyThoughtBubbleTheme() {
854
+ function applyThoughtBubbleTheme(widthPx) {
714
855
  const t = bubbleTheme;
715
856
  const { fontPx, padH, approxHeight } = (0, core_1.resolveBubblePadding)(t);
716
- const shapeCss = (0, core_1.bubbleShapeToCss)(t, approxHeight);
857
+ const shapeCss = (0, core_1.bubbleShapeToCss)(t, approxHeight, 1, widthPx);
717
858
  thoughtBubbleEl.style.fontSize = `${fontPx}px`;
718
859
  thoughtBubbleEl.style.height = `${approxHeight}px`;
719
860
  thoughtBubbleEl.style.padding = `0 ${padH}px`;
720
861
  thoughtBubbleEl.style.color = t.textColor;
721
862
  thoughtBubbleEl.style.background = t.backgroundColor;
722
863
  thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
723
- const setImportant = (prop, value) => {
724
- if (!value) {
725
- thoughtBubbleEl.style.removeProperty(prop);
726
- return;
727
- }
728
- thoughtBubbleEl.style.setProperty(prop, value, 'important');
729
- };
730
- setImportant('border-radius', shapeCss.borderRadius);
731
- setImportant('border-top-left-radius', shapeCss.borderTopLeftRadius);
732
- setImportant('border-top-right-radius', shapeCss.borderTopRightRadius);
733
- setImportant('border-bottom-left-radius', shapeCss.borderBottomLeftRadius);
734
- setImportant('border-bottom-right-radius', shapeCss.borderBottomRightRadius);
735
- setImportant('clip-path', shapeCss.clipPath);
864
+ (0, core_1.applyBubbleShapeCss)(thoughtBubbleEl, shapeCss);
736
865
  }
737
866
  function positionThoughtBubble() {
738
867
  const dep = (0, core_1.resolveOrbDeployment)(effectivePosition());
@@ -767,6 +896,11 @@ function mountOrb(controller, options = {}) {
767
896
  thoughtBubbleEl.style.display = 'flex';
768
897
  applyThoughtBubbleTheme();
769
898
  layoutThoughtContent(prompt);
899
+ if (bubbleTheme.shape === 'cloud') {
900
+ const widthPx = thoughtBubbleEl.offsetWidth;
901
+ if (widthPx > 0)
902
+ applyThoughtBubbleTheme(widthPx);
903
+ }
770
904
  positionThoughtBubble();
771
905
  }
772
906
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
@@ -897,6 +1031,7 @@ function mountOrb(controller, options = {}) {
897
1031
  // denied/blocked, in which case we skip voice and go straight to the keyboard.
898
1032
  if (controller.voiceAvailable && voiceInputEnabled) {
899
1033
  micFallbackActive = false;
1034
+ userTriedVoice = true;
900
1035
  void controller.toggleListening();
901
1036
  return;
902
1037
  }
@@ -908,6 +1043,7 @@ function mountOrb(controller, options = {}) {
908
1043
  openTextPanel();
909
1044
  return;
910
1045
  }
1046
+ userTriedVoice = true;
911
1047
  void controller.toggleListening();
912
1048
  };
913
1049
  sendBtn.onclick = () => {
@@ -946,11 +1082,15 @@ function mountOrb(controller, options = {}) {
946
1082
  // Track phase transitions to fire haptics: start/stop listening + response.
947
1083
  let lastPhase = null;
948
1084
  function render(state) {
949
- // Mic just became unusable mid-attempt (no Permissions API): fall back to the
950
- // 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.
951
1091
  if (state.voiceBlocked && !lastVoiceBlocked) {
952
1092
  lastVoiceBlocked = true;
953
- if (enableTextInput && !bubbleOpen && !state.isBusy) {
1093
+ if (userTriedVoice && enableTextInput && !bubbleOpen && !state.isBusy) {
954
1094
  openTextPanel();
955
1095
  return;
956
1096
  }
@@ -1019,9 +1159,11 @@ function mountOrb(controller, options = {}) {
1019
1159
  }
1020
1160
  const unsubscribe = controller.subscribe(render);
1021
1161
  render(controller.getState());
1022
- // Track the current route so the thought bubble re-resolves its CTA on
1023
- // navigation. No universal navigation event exists, so poll + listen to
1024
- // 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();
1025
1167
  const readRoute = () => {
1026
1168
  let next = null;
1027
1169
  try {
@@ -1030,8 +1172,10 @@ function mountOrb(controller, options = {}) {
1030
1172
  catch {
1031
1173
  /* navigation read is best-effort */
1032
1174
  }
1033
- if (next !== currentRoute) {
1175
+ const nextLocale = currentBubbleLocale();
1176
+ if (next !== currentRoute || nextLocale !== lastBubbleLocale) {
1034
1177
  currentRoute = next;
1178
+ lastBubbleLocale = nextLocale;
1035
1179
  syncThoughtBubble(controller.getState());
1036
1180
  }
1037
1181
  };
@@ -78,9 +78,15 @@ function buildDoubleBorder(opts) {
78
78
  root.appendChild(mid);
79
79
  return { root, fill };
80
80
  }
81
- // ── Brand mark (port of FiodosOrbMark) ───────────────────────────────────────
82
- function buildOrbMark(orbSize) {
83
- const { bars, color, radius } = (0, core_1.resolveFiodosOrbMarkBars)(orbSize);
81
+ /**
82
+ * Fiodos brand mark (idle, no logo). Breathes very gently on web — a
83
+ * direct port of the landing hero orbs' waveform easing (see
84
+ * `fiodosOrbMarkIdleHalfHeight` in @fiodos/core) — deliberately subtler than
85
+ * and distinct from the volume-reactive waveform shown while listening.
86
+ * Tinted with the same "audio color" (waveformColor) as that waveform.
87
+ */
88
+ function buildOrbMark(orbSize, markColor) {
89
+ const { bars, color, radius } = (0, core_1.resolveFiodosOrbMarkAnimatedBars)(orbSize, markColor);
84
90
  const svg = svgEl('svg', {
85
91
  width: orbSize,
86
92
  height: orbSize,
@@ -88,10 +94,31 @@ function buildOrbMark(orbSize) {
88
94
  'aria-hidden': 'true',
89
95
  });
90
96
  svg.style.display = 'block';
91
- for (const b of bars) {
92
- svg.appendChild(svgEl('rect', { x: b.x, y: b.y, width: b.width, height: b.height, rx: radius, ry: radius, fill: color }));
93
- }
94
- return svg;
97
+ const rects = bars.map((b) => {
98
+ const rect = svgEl('rect', { x: b.x, y: b.y, width: b.width, height: b.height, rx: radius, ry: radius, fill: color });
99
+ svg.appendChild(rect);
100
+ return rect;
101
+ });
102
+ let raf = 0;
103
+ return {
104
+ el: svg,
105
+ start() {
106
+ const t0 = performance.now();
107
+ const loop = (now) => {
108
+ raf = requestAnimationFrame(loop);
109
+ const t = (now - t0) / 1000;
110
+ bars.forEach((b, i) => {
111
+ const half = (0, core_1.fiodosOrbMarkIdleHalfHeight)(t, i, b.baseHalfHeight, b.maxHalfHeight);
112
+ rects[i].setAttribute('y', (b.centerY - half).toFixed(2));
113
+ rects[i].setAttribute('height', (2 * half).toFixed(2));
114
+ });
115
+ };
116
+ raf = requestAnimationFrame(loop);
117
+ },
118
+ stop() {
119
+ cancelAnimationFrame(raf);
120
+ },
121
+ };
95
122
  }
96
123
  // ── Waveform (port of VoiceWaveform: 9 bars, bell profile, rAF) ───────────────
97
124
  const WAVE_BARS = 9;
@@ -382,6 +409,7 @@ function createOrbVisual(initial) {
382
409
  let volume = 0;
383
410
  let fillEl = null;
384
411
  let waveform = null;
412
+ let markAnim = null;
385
413
  function rebuild() {
386
414
  const size = appearance.size ?? exports.DEFAULT_ORB_APPEARANCE.size;
387
415
  const fill = (0, core_1.resolveOrbBackgroundColor)({
@@ -393,6 +421,8 @@ function createOrbVisual(initial) {
393
421
  const interiorColor = (0, core_1.resolveInnerBorderColor)(appearance.innerBorderColor);
394
422
  waveform?.stop();
395
423
  waveform = null;
424
+ markAnim?.stop();
425
+ markAnim = null;
396
426
  wrapper.replaceChildren();
397
427
  const { root, fill: fillNode } = buildDoubleBorder({
398
428
  fillDiameter: size,
@@ -414,6 +444,8 @@ function createOrbVisual(initial) {
414
444
  const waveColor = appearance.waveformColor ?? '#ffffff';
415
445
  waveform?.stop();
416
446
  waveform = null;
447
+ markAnim?.stop();
448
+ markAnim = null;
417
449
  fillEl.replaceChildren();
418
450
  // The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
419
451
  // rings + dot when there's no photo) to mirror the dashboard editor, so the
@@ -458,7 +490,9 @@ function createOrbVisual(initial) {
458
490
  return;
459
491
  }
460
492
  if (state === 'idle') {
461
- fillEl.appendChild(buildOrbMark(size));
493
+ markAnim = buildOrbMark(size, appearance.waveformColor);
494
+ fillEl.appendChild(markAnim.el);
495
+ markAnim.start();
462
496
  }
463
497
  }
464
498
  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;
@@ -14,6 +14,38 @@ exports.watchPublishedConfig = watchPublishedConfig;
14
14
  */
15
15
  const core_1 = require("@fiodos/core");
16
16
  const orbView_1 = require("./orbView");
17
+ /**
18
+ * Last-known-good published config, persisted so the customized orb survives
19
+ * page reloads during a backend outage and shows instantly on the next visit.
20
+ * Best-effort: storage failures (private mode, quota) are ignored.
21
+ */
22
+ const PUBLISHED_CONFIG_CACHE_KEY = 'fyodos:published-config:v1';
23
+ function readCachedPublishedConfig() {
24
+ try {
25
+ if (typeof window === 'undefined' || !window.localStorage)
26
+ return null;
27
+ const raw = window.localStorage.getItem(PUBLISHED_CONFIG_CACHE_KEY);
28
+ return raw ? JSON.parse(raw) : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function writeCachedPublishedConfig(published) {
35
+ try {
36
+ if (typeof window === 'undefined' || !window.localStorage)
37
+ return;
38
+ if (published) {
39
+ window.localStorage.setItem(PUBLISHED_CONFIG_CACHE_KEY, JSON.stringify(published));
40
+ }
41
+ else {
42
+ window.localStorage.removeItem(PUBLISHED_CONFIG_CACHE_KEY);
43
+ }
44
+ }
45
+ catch {
46
+ /* best-effort cache */
47
+ }
48
+ }
17
49
  function toResolved(props, published) {
18
50
  const enabled = published.orbEnabled !== false;
19
51
  const theme = props.theme;
@@ -48,8 +80,10 @@ function toResolved(props, published) {
48
80
  }
49
81
  /**
50
82
  * Starts watching the published config. Returns an unsubscribe function.
51
- * Best-effort: a failed fetch reports `null` (the orb keeps its current/default
52
- * look) and never throws into the host app.
83
+ * Best-effort: a failed fetch is SILENTLY skipped — the orb keeps its
84
+ * last-known-good (or cached) look instead of reverting to the default — and
85
+ * never throws into the host app. `onChange(null)` is reported ONLY when the
86
+ * backend confirms nothing is published.
53
87
  */
54
88
  function watchPublishedConfig(controller, options) {
55
89
  const fetcher = controller.config.backend.fetchPublishedConfig;
@@ -58,18 +92,33 @@ function watchPublishedConfig(controller, options) {
58
92
  return () => { };
59
93
  }
60
94
  let cancelled = false;
95
+ // Hydrate from the local cache so the customized orb shows instantly (and
96
+ // survives page loads while the backend is unreachable). The first network
97
+ // response below overwrites it as the source of truth.
98
+ const cached = readCachedPublishedConfig();
99
+ if (cached) {
100
+ options.onChange(toResolved((0, core_1.mapPublishedConfigToOrbProps)(cached), cached));
101
+ }
61
102
  const load = async () => {
62
103
  try {
63
104
  const res = await fetcher.call(controller.config.backend);
64
105
  if (cancelled)
65
106
  return;
66
- options.onChange(res.published
67
- ? toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published), res.published)
68
- : null);
107
+ if (res.published) {
108
+ options.onChange(toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published), res.published));
109
+ writeCachedPublishedConfig(res.published);
110
+ }
111
+ else {
112
+ // A SUCCESSFUL response with nothing published: the project truly has
113
+ // no custom design, so the defaults are correct.
114
+ options.onChange(null);
115
+ writeCachedPublishedConfig(null);
116
+ }
69
117
  }
70
118
  catch {
71
- if (!cancelled)
72
- options.onChange(null);
119
+ // Transient failure (timeout, network blip after hours open, backend
120
+ // redeploy…): keep the current appearance. The next successful poll
121
+ // refreshes it.
73
122
  }
74
123
  };
75
124
  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";
@@ -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.12';
11
+ exports.SDK_VERSION = '0.1.15';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';