@fiodos/web-core 0.1.12 → 0.1.14

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 (42) 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.js +14 -0
  4. package/dist/cjs/config/types.d.ts +15 -2
  5. package/dist/cjs/controller/AgentController.d.ts +14 -0
  6. package/dist/cjs/controller/AgentController.js +63 -0
  7. package/dist/cjs/dropin/createFiodosAgent.d.ts +27 -1
  8. package/dist/cjs/dropin/createFiodosAgent.js +32 -1
  9. package/dist/cjs/embed/dynamics.d.ts +61 -0
  10. package/dist/cjs/embed/dynamics.js +173 -0
  11. package/dist/cjs/embed/global.d.ts +45 -0
  12. package/dist/cjs/embed/global.js +48 -0
  13. package/dist/cjs/index.d.ts +3 -0
  14. package/dist/cjs/index.js +8 -1
  15. package/dist/cjs/orb/mountOrb.js +111 -18
  16. package/dist/cjs/orb/orbView.js +40 -7
  17. package/dist/cjs/orb/publishedConfig.d.ts +4 -2
  18. package/dist/cjs/orb/publishedConfig.js +56 -7
  19. package/dist/cjs/version.d.ts +1 -1
  20. package/dist/cjs/version.js +1 -1
  21. package/dist/embed/fiodos-embed.js +66 -0
  22. package/dist/esm/adapters/webCredentialAutofillAdapter.d.ts +16 -0
  23. package/dist/esm/adapters/webCredentialAutofillAdapter.js +44 -0
  24. package/dist/esm/api/backendClient.js +14 -0
  25. package/dist/esm/config/types.d.ts +15 -2
  26. package/dist/esm/controller/AgentController.d.ts +14 -0
  27. package/dist/esm/controller/AgentController.js +63 -0
  28. package/dist/esm/dropin/createFiodosAgent.d.ts +27 -1
  29. package/dist/esm/dropin/createFiodosAgent.js +32 -1
  30. package/dist/esm/embed/dynamics.d.ts +61 -0
  31. package/dist/esm/embed/dynamics.js +168 -0
  32. package/dist/esm/embed/global.d.ts +45 -0
  33. package/dist/esm/embed/global.js +46 -0
  34. package/dist/esm/index.d.ts +3 -0
  35. package/dist/esm/index.js +3 -0
  36. package/dist/esm/orb/mountOrb.js +112 -19
  37. package/dist/esm/orb/orbView.js +41 -8
  38. package/dist/esm/orb/publishedConfig.d.ts +4 -2
  39. package/dist/esm/orb/publishedConfig.js +56 -7
  40. package/dist/esm/version.d.ts +1 -1
  41. package/dist/esm/version.js +1 -1
  42. package/package.json +5 -3
@@ -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;}
@@ -370,6 +385,11 @@ export function mountOrb(controller, options = {}) {
370
385
  btn.addEventListener('lostpointercapture', onPointerUp);
371
386
  // ── Text bubble ─────────────────────────────────────────────────────────────
372
387
  let bubbleOpen = false;
388
+ // Reduced (default) size stays exactly as before. Tapping any EMPTY area of
389
+ // the panel (not a message, not the input row, not a button — so text stays
390
+ // selectable/copyable) grows it to the expanded layout; shrinking back is an
391
+ // explicit tap on `unexpandBtn` next to the close button.
392
+ let bubbleExpanded = false;
373
393
  // When the mic is denied/unavailable, tapping the orb still puts it in the
374
394
  // SAME "active/listening" visual state and reveals the keyboard chip — the
375
395
  // user is never blocked, text is always an open avenue (Change 1).
@@ -406,6 +426,19 @@ export function mountOrb(controller, options = {}) {
406
426
  closeBtn.textContent = '×';
407
427
  closeBtn.setAttribute('aria-label', 'Close');
408
428
  closeBtn.addEventListener('click', () => setBubble(false));
429
+ // Collapse button — only shown while expanded, right next to the close
430
+ // button (same minimalist style). Tapping an empty area of the panel is
431
+ // what EXPANDS it; shrinking back is always an explicit, discoverable tap.
432
+ const unexpandBtn = document.createElement('button');
433
+ unexpandBtn.type = 'button';
434
+ unexpandBtn.className = 'fyodos-bubble-unexpand';
435
+ unexpandBtn.innerHTML =
436
+ '<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>';
437
+ unexpandBtn.setAttribute('aria-label', 'Collapse');
438
+ unexpandBtn.addEventListener('click', (e) => {
439
+ e.stopPropagation();
440
+ setBubbleExpanded(false);
441
+ });
409
442
  // Loading row shown at the top while an older page of the session history
410
443
  // streams in (scroll-to-top pagination).
411
444
  const loaderRow = document.createElement('div');
@@ -559,8 +592,71 @@ export function mountOrb(controller, options = {}) {
559
592
  micBtn.setAttribute('aria-label', active ? ui.micStopLabel : ui.micLabel);
560
593
  micBtn.title = active ? ui.micStopLabel : ui.micLabel;
561
594
  }
562
- bubble.append(closeBtn, exchangeScroll, busyRow, inputRow);
595
+ bubble.append(closeBtn, unexpandBtn, exchangeScroll, busyRow, inputRow);
563
596
  anchor.appendChild(bubble);
597
+ function setBubbleExpanded(expanded) {
598
+ if (bubbleExpanded === expanded)
599
+ return;
600
+ bubbleExpanded = expanded;
601
+ bubble.classList.toggle('fyodos-bubble--expanded', expanded);
602
+ unexpandBtn.style.display = expanded ? 'flex' : 'none';
603
+ positionBubble();
604
+ }
605
+ // Tap-to-expand: a plain click on the panel grows it. Text selection is
606
+ // never broken: a drag-selection is a move (threshold guard), a double/
607
+ // triple-click word/paragraph selection cancels the pending expand (detail
608
+ // guard + delay), and any active selection blocks it. Controls (input row,
609
+ // buttons) never expand.
610
+ let bubbleTapX = 0;
611
+ let bubbleTapY = 0;
612
+ let pendingExpandTimer = null;
613
+ const TAP_MOVE_THRESHOLD = 8;
614
+ const hasActiveSelection = () => {
615
+ const selection = typeof window !== 'undefined' ? window.getSelection?.() : null;
616
+ return !!selection && !selection.isCollapsed && selection.toString().length > 0;
617
+ };
618
+ const cancelPendingExpand = () => {
619
+ if (pendingExpandTimer != null) {
620
+ window.clearTimeout(pendingExpandTimer);
621
+ pendingExpandTimer = null;
622
+ }
623
+ };
624
+ bubble.addEventListener('pointerdown', (e) => {
625
+ bubbleTapX = e.clientX;
626
+ bubbleTapY = e.clientY;
627
+ });
628
+ bubble.addEventListener('click', (e) => {
629
+ if (bubbleExpanded)
630
+ return;
631
+ const target = e.target;
632
+ if (target?.closest('.fyodos-bubble-row, .fyodos-bubble-close, .fyodos-bubble-unexpand, button, input, a')) {
633
+ return;
634
+ }
635
+ const moved = Math.hypot(e.clientX - bubbleTapX, e.clientY - bubbleTapY);
636
+ if (moved > TAP_MOVE_THRESHOLD)
637
+ return;
638
+ // Double/triple click is a word/paragraph selection gesture, never expand
639
+ // (it also cancels the pending expand from its own first click).
640
+ if (e.detail > 1) {
641
+ cancelPendingExpand();
642
+ return;
643
+ }
644
+ cancelPendingExpand();
645
+ const overText = !!target?.closest('.fyodos-bubble-user, .fyodos-bubble-reply');
646
+ if (!overText && !hasActiveSelection()) {
647
+ // Genuinely empty area, nothing selected — expand immediately.
648
+ setBubbleExpanded(true);
649
+ return;
650
+ }
651
+ // Over message text, or a selection may still be collapsing: expand after
652
+ // a short delay, re-checking the selection right before — a double-click's
653
+ // second click or a real selection cancels it.
654
+ pendingExpandTimer = window.setTimeout(() => {
655
+ pendingExpandTimer = null;
656
+ if (!hasActiveSelection())
657
+ setBubbleExpanded(true);
658
+ }, 280);
659
+ });
564
660
  // Initial placement (after the bubble exists, so applyDeployment can lay it out).
565
661
  reposition();
566
662
  function applyBubbleTheme() {
@@ -571,6 +667,7 @@ export function mountOrb(controller, options = {}) {
571
667
  if (modalTheme?.borderRadius != null)
572
668
  bubble.style.borderRadius = `${modalTheme.borderRadius}px`;
573
669
  closeBtn.style.color = t.bodyColor;
670
+ unexpandBtn.style.color = t.bodyColor;
574
671
  exchangeList.querySelectorAll('.fyodos-bubble-user').forEach((node) => {
575
672
  node.style.color = t.bodyColor;
576
673
  });
@@ -605,6 +702,9 @@ export function mountOrb(controller, options = {}) {
605
702
  controller.resetBubbleWindow();
606
703
  // Closing the panel returns the orb to idle (also clears mic fallback).
607
704
  micFallbackActive = false;
705
+ // Always re-open at the reduced size.
706
+ cancelPendingExpand();
707
+ setBubbleExpanded(false);
608
708
  }
609
709
  syncChip(controller.getState());
610
710
  }
@@ -707,29 +807,17 @@ export function mountOrb(controller, options = {}) {
707
807
  track.appendChild(second);
708
808
  thoughtBubbleEl.appendChild(track);
709
809
  }
710
- function applyThoughtBubbleTheme() {
810
+ function applyThoughtBubbleTheme(widthPx) {
711
811
  const t = bubbleTheme;
712
812
  const { fontPx, padH, approxHeight } = resolveBubblePadding(t);
713
- const shapeCss = bubbleShapeToCss(t, approxHeight);
813
+ const shapeCss = bubbleShapeToCss(t, approxHeight, 1, widthPx);
714
814
  thoughtBubbleEl.style.fontSize = `${fontPx}px`;
715
815
  thoughtBubbleEl.style.height = `${approxHeight}px`;
716
816
  thoughtBubbleEl.style.padding = `0 ${padH}px`;
717
817
  thoughtBubbleEl.style.color = t.textColor;
718
818
  thoughtBubbleEl.style.background = t.backgroundColor;
719
819
  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);
820
+ applyBubbleShapeCss(thoughtBubbleEl, shapeCss);
733
821
  }
734
822
  function positionThoughtBubble() {
735
823
  const dep = resolveOrbDeployment(effectivePosition());
@@ -764,6 +852,11 @@ export function mountOrb(controller, options = {}) {
764
852
  thoughtBubbleEl.style.display = 'flex';
765
853
  applyThoughtBubbleTheme();
766
854
  layoutThoughtContent(prompt);
855
+ if (bubbleTheme.shape === 'cloud') {
856
+ const widthPx = thoughtBubbleEl.offsetWidth;
857
+ if (widthPx > 0)
858
+ applyThoughtBubbleTheme(widthPx);
859
+ }
767
860
  positionThoughtBubble();
768
861
  }
769
862
  // ── Overlays (confirmation + consent) ────────────────────────────────────────
@@ -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,14 @@ function buildDoubleBorder(opts) {
73
73
  root.appendChild(mid);
74
74
  return { root, fill };
75
75
  }
76
- // ── Brand mark (port of FiodosOrbMark) ───────────────────────────────────────
76
+ /**
77
+ * Neutral 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
+ */
77
82
  function buildOrbMark(orbSize) {
78
- const { bars, color, radius } = resolveFiodosOrbMarkBars(orbSize);
83
+ const { bars, color, radius } = resolveFiodosOrbMarkAnimatedBars(orbSize);
79
84
  const svg = svgEl('svg', {
80
85
  width: orbSize,
81
86
  height: orbSize,
@@ -83,10 +88,31 @@ function buildOrbMark(orbSize) {
83
88
  'aria-hidden': 'true',
84
89
  });
85
90
  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;
91
+ const rects = bars.map((b) => {
92
+ const rect = svgEl('rect', { x: b.x, y: b.y, width: b.width, height: b.height, rx: radius, ry: radius, fill: color });
93
+ svg.appendChild(rect);
94
+ return rect;
95
+ });
96
+ let raf = 0;
97
+ return {
98
+ el: svg,
99
+ start() {
100
+ const t0 = performance.now();
101
+ const loop = (now) => {
102
+ raf = requestAnimationFrame(loop);
103
+ const t = (now - t0) / 1000;
104
+ bars.forEach((b, i) => {
105
+ const half = fiodosOrbMarkIdleHalfHeight(t, i, b.baseHalfHeight, b.maxHalfHeight);
106
+ rects[i].setAttribute('y', (b.centerY - half).toFixed(2));
107
+ rects[i].setAttribute('height', (2 * half).toFixed(2));
108
+ });
109
+ };
110
+ raf = requestAnimationFrame(loop);
111
+ },
112
+ stop() {
113
+ cancelAnimationFrame(raf);
114
+ },
115
+ };
90
116
  }
91
117
  // ── Waveform (port of VoiceWaveform: 9 bars, bell profile, rAF) ───────────────
92
118
  const WAVE_BARS = 9;
@@ -377,6 +403,7 @@ export function createOrbVisual(initial) {
377
403
  let volume = 0;
378
404
  let fillEl = null;
379
405
  let waveform = null;
406
+ let markAnim = null;
380
407
  function rebuild() {
381
408
  const size = appearance.size ?? DEFAULT_ORB_APPEARANCE.size;
382
409
  const fill = resolveOrbBackgroundColor({
@@ -388,6 +415,8 @@ export function createOrbVisual(initial) {
388
415
  const interiorColor = resolveInnerBorderColor(appearance.innerBorderColor);
389
416
  waveform?.stop();
390
417
  waveform = null;
418
+ markAnim?.stop();
419
+ markAnim = null;
391
420
  wrapper.replaceChildren();
392
421
  const { root, fill: fillNode } = buildDoubleBorder({
393
422
  fillDiameter: size,
@@ -409,6 +438,8 @@ export function createOrbVisual(initial) {
409
438
  const waveColor = appearance.waveformColor ?? '#ffffff';
410
439
  waveform?.stop();
411
440
  waveform = null;
441
+ markAnim?.stop();
442
+ markAnim = null;
412
443
  fillEl.replaceChildren();
413
444
  // The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
414
445
  // rings + dot when there's no photo) to mirror the dashboard editor, so the
@@ -453,7 +484,9 @@ export function createOrbVisual(initial) {
453
484
  return;
454
485
  }
455
486
  if (state === 'idle') {
456
- fillEl.appendChild(buildOrbMark(size));
487
+ markAnim = buildOrbMark(size);
488
+ fillEl.appendChild(markAnim.el);
489
+ markAnim.start();
457
490
  }
458
491
  }
459
492
  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.14";
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.14';
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.14",
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
  }