@runtypelabs/persona 3.5.2 → 3.7.0

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 (53) hide show
  1. package/dist/index.cjs +46 -46
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +44 -0
  4. package/dist/index.d.ts +44 -0
  5. package/dist/index.global.js +70 -70
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +46 -46
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +18015 -0
  10. package/dist/theme-editor.d.cts +3888 -0
  11. package/dist/theme-editor.d.ts +3888 -0
  12. package/dist/theme-editor.js +17909 -0
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +33 -0
  15. package/dist/theme-reference.d.ts +33 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +69 -25
  18. package/package.json +9 -7
  19. package/src/components/artifact-card.ts +1 -1
  20. package/src/components/composer-builder.ts +16 -29
  21. package/src/components/demo-carousel.ts +5 -5
  22. package/src/components/event-stream-view.test.ts +142 -0
  23. package/src/components/event-stream-view.ts +68 -29
  24. package/src/components/header-builder.ts +2 -2
  25. package/src/components/launcher.ts +9 -0
  26. package/src/components/message-bubble.ts +9 -3
  27. package/src/components/suggestions.ts +1 -1
  28. package/src/defaults.ts +24 -9
  29. package/src/scroll-to-bottom-defaults.test.ts +13 -0
  30. package/src/styles/widget.css +69 -25
  31. package/src/theme-editor/color-utils.ts +252 -0
  32. package/src/theme-editor/index.ts +131 -0
  33. package/src/theme-editor/presets.ts +144 -0
  34. package/src/theme-editor/preview-utils.ts +265 -0
  35. package/src/theme-editor/preview.ts +445 -0
  36. package/src/theme-editor/role-mappings.ts +343 -0
  37. package/src/theme-editor/sections.test.ts +43 -0
  38. package/src/theme-editor/sections.ts +994 -0
  39. package/src/theme-editor/state.ts +298 -0
  40. package/src/theme-editor/types.ts +177 -0
  41. package/src/theme-editor.ts +2 -0
  42. package/src/theme-reference.ts +8 -0
  43. package/src/types/theme.ts +11 -0
  44. package/src/types.ts +22 -0
  45. package/src/ui.scroll.test.ts +554 -0
  46. package/src/ui.ts +223 -133
  47. package/src/utils/auto-follow.test.ts +110 -0
  48. package/src/utils/auto-follow.ts +112 -0
  49. package/src/utils/plugins.ts +1 -1
  50. package/src/utils/theme.test.ts +44 -8
  51. package/src/utils/theme.ts +11 -11
  52. package/src/utils/tokens.ts +137 -41
  53. package/widget.css +0 -1
package/src/ui.ts CHANGED
@@ -33,6 +33,13 @@ import { renderLucideIcon } from "./utils/icons";
33
33
  import { createElement, createElementInDocument } from "./utils/dom";
34
34
  import { morphMessages } from "./utils/morph";
35
35
  import { computeMessageFingerprint, createMessageCache, getCachedWrapper, setCachedWrapper, pruneCache } from "./utils/message-fingerprint";
36
+ import {
37
+ createFollowStateController,
38
+ getScrollBottomOffset,
39
+ isElementNearBottom,
40
+ resolveFollowStateFromScroll,
41
+ resolveFollowStateFromWheel
42
+ } from "./utils/auto-follow";
36
43
  import { statusCopy } from "./utils/constants";
37
44
  import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
38
45
  import { createLauncherButton } from "./components/launcher";
@@ -545,6 +552,7 @@ export const createAgentExperience = (
545
552
  let showReasoning = config.features?.showReasoning ?? true;
546
553
  let showToolCalls = config.features?.showToolCalls ?? true;
547
554
  let showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
555
+ let scrollToBottomFeature = config.features?.scrollToBottom ?? {};
548
556
  const persistKeyPrefix = (typeof config.persistState === 'object' ? config.persistState?.keyPrefix : undefined) ?? "persona-";
549
557
  const eventStreamDbName = `${persistKeyPrefix}event-stream`;
550
558
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
@@ -655,6 +663,49 @@ export const createAgentExperience = (
655
663
  let attachmentButtonWrapper: HTMLElement | null = panelElements.attachmentButtonWrapper;
656
664
  let attachmentInput: HTMLInputElement | null = panelElements.attachmentInput;
657
665
  let attachmentPreviewsContainer: HTMLElement | null = panelElements.attachmentPreviewsContainer;
666
+ container.classList.add("persona-relative");
667
+ body.classList.add("persona-relative");
668
+ const SCROLL_TO_BOTTOM_EDGE_OFFSET = 12;
669
+
670
+ const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
671
+ const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
672
+ const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
673
+ const scrollToBottomButton = createElement(
674
+ "button",
675
+ "persona-scroll-to-bottom-indicator persona-absolute persona-bottom-3 persona-left-1/2 persona-z-10 persona-flex persona-items-center persona-gap-1 persona-text-xs persona-transform persona--translate-x-1/2 persona-cursor-pointer"
676
+ ) as HTMLButtonElement;
677
+ scrollToBottomButton.type = "button";
678
+ scrollToBottomButton.style.display = "none";
679
+ scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom", "true");
680
+ const scrollToBottomIcon = createElement("span", "persona-flex persona-items-center");
681
+ const scrollToBottomLabel = createElement("span", "");
682
+ scrollToBottomButton.append(scrollToBottomIcon, scrollToBottomLabel);
683
+ container.appendChild(scrollToBottomButton);
684
+
685
+ const updateScrollToBottomButtonOffset = () => {
686
+ const footerHidden = footer.style.display === "none";
687
+ const footerHeight = footerHidden ? 0 : footer.offsetHeight;
688
+ scrollToBottomButton.style.bottom = `${footerHeight + SCROLL_TO_BOTTOM_EDGE_OFFSET}px`;
689
+ };
690
+ updateScrollToBottomButtonOffset();
691
+
692
+ const renderScrollToBottomButton = () => {
693
+ const hasLabel = Boolean(getScrollToBottomLabel());
694
+ scrollToBottomButton.setAttribute("aria-label", getScrollToBottomLabel() || "Jump to latest");
695
+ scrollToBottomButton.title = getScrollToBottomLabel();
696
+ scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom-has-label", hasLabel ? "true" : "false");
697
+ scrollToBottomIcon.innerHTML = "";
698
+ const icon = renderLucideIcon(getScrollToBottomIconName(), "14px", "currentColor", 2);
699
+ if (icon) {
700
+ scrollToBottomIcon.appendChild(icon);
701
+ scrollToBottomIcon.style.display = "";
702
+ } else {
703
+ scrollToBottomIcon.style.display = "none";
704
+ }
705
+ scrollToBottomLabel.textContent = getScrollToBottomLabel();
706
+ scrollToBottomLabel.style.display = hasLabel ? "" : "none";
707
+ };
708
+ renderScrollToBottomButton();
658
709
 
659
710
  // Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
660
711
  let attachmentManager: AttachmentManager | null = null;
@@ -703,9 +754,7 @@ export const createAgentExperience = (
703
754
  eventStreamView.update();
704
755
  }
705
756
  if (eventStreamToggleBtn) {
706
- eventStreamToggleBtn.classList.remove("persona-text-persona-muted");
707
- eventStreamToggleBtn.classList.add("persona-text-persona-accent");
708
- eventStreamToggleBtn.style.boxShadow = "inset 0 0 0 1.5px var(--persona-accent, #3b82f6)";
757
+ eventStreamToggleBtn.style.boxShadow = `inset 0 0 0 1.5px ${HEADER_THEME_CSS.actionIconColor}`;
709
758
  const activeClasses = config.features?.eventStream?.classNames?.toggleButtonActive;
710
759
  if (activeClasses) activeClasses.split(/\s+/).forEach(c => c && eventStreamToggleBtn!.classList.add(c));
711
760
  }
@@ -721,6 +770,7 @@ export const createAgentExperience = (
721
770
  };
722
771
  eventStreamLastUpdate = 0;
723
772
  eventStreamRAF = requestAnimationFrame(rafLoop);
773
+ syncScrollToBottomButton();
724
774
  eventBus.emit("eventStream:opened", { timestamp: Date.now() });
725
775
  };
726
776
 
@@ -732,8 +782,6 @@ export const createAgentExperience = (
732
782
  }
733
783
  body.style.display = "";
734
784
  if (eventStreamToggleBtn) {
735
- eventStreamToggleBtn.classList.remove("persona-text-persona-accent");
736
- eventStreamToggleBtn.classList.add("persona-text-persona-muted");
737
785
  eventStreamToggleBtn.style.boxShadow = "";
738
786
  const activeClasses = config.features?.eventStream?.classNames?.toggleButtonActive;
739
787
  if (activeClasses) activeClasses.split(/\s+/).forEach(c => c && eventStreamToggleBtn!.classList.remove(c));
@@ -743,6 +791,7 @@ export const createAgentExperience = (
743
791
  cancelAnimationFrame(eventStreamRAF);
744
792
  eventStreamRAF = null;
745
793
  }
794
+ syncScrollToBottomButton();
746
795
  eventBus.emit("eventStream:closed", { timestamp: Date.now() });
747
796
  };
748
797
 
@@ -750,10 +799,11 @@ export const createAgentExperience = (
750
799
  let eventStreamToggleBtn: HTMLButtonElement | null = null;
751
800
  if (showEventStreamToggle) {
752
801
  const esClassNames = config.features?.eventStream?.classNames;
753
- const toggleBtnClasses = "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full persona-text-persona-muted hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-1" + (esClassNames?.toggleButton ? " " + esClassNames.toggleButton : "");
802
+ const toggleBtnClasses = "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-opacity-80 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-1" + (esClassNames?.toggleButton ? " " + esClassNames.toggleButton : "");
754
803
  eventStreamToggleBtn = createElement("button", toggleBtnClasses) as HTMLButtonElement;
755
804
  eventStreamToggleBtn.style.width = "28px";
756
805
  eventStreamToggleBtn.style.height = "28px";
806
+ eventStreamToggleBtn.style.color = HEADER_THEME_CSS.actionIconColor;
757
807
  eventStreamToggleBtn.type = "button";
758
808
  eventStreamToggleBtn.setAttribute("aria-label", "Event Stream");
759
809
  eventStreamToggleBtn.title = "Event Stream";
@@ -877,13 +927,18 @@ export const createAgentExperience = (
877
927
  ensureComposerAttachmentSurface(footer);
878
928
  bindComposerRefsFromFooter(footer);
879
929
 
880
- // Apply contentMaxWidth to composer form if configured
930
+ // Apply contentMaxWidth to composer form and attachment previews if configured
881
931
  const contentMaxWidth = config.layout?.contentMaxWidth;
882
932
  if (contentMaxWidth && composerForm) {
883
933
  composerForm.style.maxWidth = contentMaxWidth;
884
934
  composerForm.style.marginLeft = "auto";
885
935
  composerForm.style.marginRight = "auto";
886
936
  }
937
+ if (contentMaxWidth && attachmentPreviewsContainer) {
938
+ attachmentPreviewsContainer.style.maxWidth = contentMaxWidth;
939
+ attachmentPreviewsContainer.style.marginLeft = "auto";
940
+ attachmentPreviewsContainer.style.marginRight = "auto";
941
+ }
887
942
 
888
943
  if (config.attachments?.enabled && attachmentInput && attachmentPreviewsContainer) {
889
944
  attachmentManager = AttachmentManager.fromConfig(config.attachments);
@@ -1843,18 +1898,13 @@ export const createAgentExperience = (
1843
1898
  let isStreaming = false;
1844
1899
  const messageCache = createMessageCache();
1845
1900
  let configVersion = 0;
1846
- let shouldAutoScroll = true;
1901
+ const autoFollow = createFollowStateController();
1847
1902
  let lastScrollTop = 0;
1848
- let lastAutoScrollTime = 0;
1849
1903
  let scrollRAF: number | null = null;
1850
- let isAutoScrollBlocked = false;
1851
- let blockUntilTime = 0;
1852
1904
  let isAutoScrolling = false;
1853
1905
 
1854
- const AUTO_SCROLL_THROTTLE = 125;
1855
- const AUTO_SCROLL_BLOCK_TIME = 2000;
1856
- const USER_SCROLL_THRESHOLD = 5;
1857
- const BOTTOM_THRESHOLD = 50;
1906
+ const USER_SCROLL_THRESHOLD = 1;
1907
+ const BOTTOM_THRESHOLD = 8;
1858
1908
  const messageState = new Map<
1859
1909
  string,
1860
1910
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -1944,75 +1994,91 @@ export const createAgentExperience = (
1944
1994
  }
1945
1995
  }
1946
1996
 
1947
- const scheduleAutoScroll = (force = false) => {
1948
- if (!shouldAutoScroll) return;
1997
+ // Track ongoing smooth scroll animation
1998
+ let smoothScrollRAF: number | null = null;
1949
1999
 
1950
- const now = Date.now();
2000
+ // Get the scrollable container using its unique ID
2001
+ const getScrollableContainer = (): HTMLElement => {
2002
+ // Use the unique ID for reliable selection
2003
+ const scrollable = wrapper.querySelector('#persona-scroll-container') as HTMLElement;
2004
+ // Fallback to body if ID not found (shouldn't happen, but safe fallback)
2005
+ return scrollable || body;
2006
+ };
1951
2007
 
1952
- if (isAutoScrollBlocked && now < blockUntilTime) {
1953
- if (!force) return;
2008
+ const cancelSmoothScroll = () => {
2009
+ if (smoothScrollRAF !== null) {
2010
+ cancelAnimationFrame(smoothScrollRAF);
2011
+ smoothScrollRAF = null;
1954
2012
  }
2013
+ isAutoScrolling = false;
2014
+ };
1955
2015
 
1956
- if (isAutoScrollBlocked && now >= blockUntilTime) {
1957
- isAutoScrollBlocked = false;
2016
+ const cancelAutoScroll = () => {
2017
+ if (scrollRAF !== null) {
2018
+ cancelAnimationFrame(scrollRAF);
2019
+ scrollRAF = null;
1958
2020
  }
2021
+ cancelSmoothScroll();
2022
+ };
1959
2023
 
1960
- if (!force && !isStreaming) return;
2024
+ const syncScrollToBottomButton = () => {
2025
+ if (!isScrollToBottomEnabled() || eventStreamVisible) {
2026
+ if (scrollToBottomButton.parentNode) {
2027
+ scrollToBottomButton.remove();
2028
+ }
2029
+ scrollToBottomButton.style.display = "none";
2030
+ return;
2031
+ }
2032
+ if (scrollToBottomButton.parentNode !== container) {
2033
+ container.appendChild(scrollToBottomButton);
2034
+ }
2035
+ updateScrollToBottomButtonOffset();
2036
+ scrollToBottomButton.style.display = autoFollow.isFollowing() ? "none" : "";
2037
+ };
1961
2038
 
1962
- if (now - lastAutoScrollTime < AUTO_SCROLL_THROTTLE) return;
1963
- lastAutoScrollTime = now;
2039
+ const pauseAutoScroll = () => {
2040
+ if (!autoFollow.pause()) return;
2041
+ cancelAutoScroll();
2042
+ syncScrollToBottomButton();
2043
+ };
1964
2044
 
1965
- if (scrollRAF) {
1966
- cancelAnimationFrame(scrollRAF);
1967
- }
2045
+ const resumeAutoScroll = () => {
2046
+ autoFollow.resume();
2047
+ syncScrollToBottomButton();
2048
+ };
2049
+
2050
+ const scheduleAutoScroll = (force = false) => {
2051
+ if (!autoFollow.isFollowing()) return;
2052
+
2053
+ if (!force && !isStreaming) return;
2054
+
2055
+ cancelAutoScroll();
1968
2056
 
1969
2057
  scrollRAF = requestAnimationFrame(() => {
1970
- if (isAutoScrollBlocked || !shouldAutoScroll) return;
1971
- isAutoScrolling = true;
1972
- body.scrollTop = body.scrollHeight;
1973
- lastScrollTop = body.scrollTop;
1974
- requestAnimationFrame(() => {
1975
- isAutoScrolling = false;
1976
- });
1977
2058
  scrollRAF = null;
2059
+ if (!autoFollow.isFollowing()) return;
2060
+ smoothScrollToBottom(getScrollableContainer(), force ? 220 : 140);
1978
2061
  });
1979
2062
  };
1980
2063
 
1981
- // Track ongoing smooth scroll animation
1982
- let smoothScrollRAF: number | null = null;
1983
-
1984
- // Get the scrollable container using its unique ID
1985
- const getScrollableContainer = (): HTMLElement => {
1986
- // Use the unique ID for reliable selection
1987
- const scrollable = wrapper.querySelector('#persona-scroll-container') as HTMLElement;
1988
- // Fallback to body if ID not found (shouldn't happen, but safe fallback)
1989
- return scrollable || body;
1990
- };
1991
-
1992
2064
  // Custom smooth scroll animation with easing
1993
2065
  const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
1994
2066
  const start = element.scrollTop;
1995
- const clientHeight = element.clientHeight;
1996
2067
  // Recalculate target dynamically to handle layout changes
1997
- let target = element.scrollHeight;
2068
+ let target = getScrollBottomOffset(element);
1998
2069
  let distance = target - start;
1999
2070
 
2000
- // Check if already at bottom: scrollTop + clientHeight should be >= scrollHeight
2001
- // Add a small threshold (2px) to account for rounding/subpixel differences
2002
- const isAtBottom = start + clientHeight >= target - 2;
2003
-
2004
2071
  // If already at bottom or very close, skip animation to prevent glitch
2005
- if (isAtBottom || Math.abs(distance) < 5) {
2072
+ if (Math.abs(distance) < 1) {
2073
+ lastScrollTop = element.scrollTop;
2006
2074
  return;
2007
2075
  }
2008
2076
 
2009
2077
  // Cancel any ongoing smooth scroll animation
2010
- if (smoothScrollRAF !== null) {
2011
- cancelAnimationFrame(smoothScrollRAF);
2012
- smoothScrollRAF = null;
2013
- }
2078
+ cancelSmoothScroll();
2014
2079
 
2015
2080
  const startTime = performance.now();
2081
+ isAutoScrolling = true;
2016
2082
 
2017
2083
  // Easing function: ease-out cubic for smooth deceleration
2018
2084
  const easeOutCubic = (t: number): number => {
@@ -2020,8 +2086,13 @@ export const createAgentExperience = (
2020
2086
  };
2021
2087
 
2022
2088
  const animate = (currentTime: number) => {
2089
+ if (!autoFollow.isFollowing()) {
2090
+ cancelSmoothScroll();
2091
+ return;
2092
+ }
2093
+
2023
2094
  // Recalculate target each frame in case scrollHeight changed
2024
- const currentTarget = element.scrollHeight;
2095
+ const currentTarget = getScrollBottomOffset(element);
2025
2096
  if (currentTarget !== target) {
2026
2097
  target = currentTarget;
2027
2098
  distance = target - start;
@@ -2033,13 +2104,16 @@ export const createAgentExperience = (
2033
2104
 
2034
2105
  const currentScroll = start + distance * eased;
2035
2106
  element.scrollTop = currentScroll;
2107
+ lastScrollTop = element.scrollTop;
2036
2108
 
2037
2109
  if (progress < 1) {
2038
2110
  smoothScrollRAF = requestAnimationFrame(animate);
2039
2111
  } else {
2040
2112
  // Ensure we end exactly at the target
2041
- element.scrollTop = element.scrollHeight;
2113
+ element.scrollTop = target;
2114
+ lastScrollTop = element.scrollTop;
2042
2115
  smoothScrollRAF = null;
2116
+ isAutoScrolling = false;
2043
2117
  }
2044
2118
  };
2045
2119
 
@@ -2388,7 +2462,6 @@ export const createAgentExperience = (
2388
2462
  "persona-shadow-sm",
2389
2463
  "persona-bg-persona-surface",
2390
2464
  "persona-border",
2391
- "persona-border-persona-message-border",
2392
2465
  "persona-text-persona-primary",
2393
2466
  "persona-px-5",
2394
2467
  "persona-py-3"
@@ -2400,6 +2473,7 @@ export const createAgentExperience = (
2400
2473
  "persona-text-persona-primary"
2401
2474
  ].join(" ");
2402
2475
  typingBubble.setAttribute("data-typing-indicator", "true");
2476
+ typingBubble.style.borderColor = "var(--persona-message-assistant-border, var(--persona-border, #e5e7eb))";
2403
2477
 
2404
2478
  typingBubble.appendChild(typingIndicator);
2405
2479
 
@@ -2479,16 +2553,6 @@ export const createAgentExperience = (
2479
2553
 
2480
2554
  // Use idiomorph to morph the container contents
2481
2555
  morphMessages(container, tempContainer);
2482
- // Defer scroll to next frame for smoother animation and to prevent jolt
2483
- // This allows the browser to update layout (e.g., typing indicator removal) before scrolling
2484
- // Use double RAF to ensure layout has fully settled before starting scroll animation
2485
- // Get the scrollable container using its unique ID (#persona-scroll-container)
2486
- requestAnimationFrame(() => {
2487
- requestAnimationFrame(() => {
2488
- const scrollableContainer = getScrollableContainer();
2489
- smoothScrollToBottom(scrollableContainer);
2490
- });
2491
- });
2492
2556
  };
2493
2557
 
2494
2558
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
@@ -2982,17 +3046,16 @@ export const createAgentExperience = (
2982
3046
  iconSize: parseFloat(voiceConfig.iconSize ?? config.sendButton?.size ?? "40") || 24,
2983
3047
  };
2984
3048
 
2985
- // Apply recording state styles from config
2986
- const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
3049
+ // Apply recording state styles from config or theme tokens
3050
+ const recordingBackgroundColor = voiceConfig.recordingBackgroundColor;
2987
3051
  const recordingIconColor = voiceConfig.recordingIconColor;
2988
3052
  const recordingBorderColor = voiceConfig.recordingBorderColor;
2989
-
3053
+
2990
3054
  micButton.classList.add("persona-voice-recording");
2991
- micButton.style.backgroundColor = recordingBackgroundColor;
2992
-
3055
+ micButton.style.backgroundColor = recordingBackgroundColor ?? "var(--persona-voice-recording-bg, #ef4444)";
3056
+ micButton.style.color = recordingIconColor ?? "var(--persona-voice-recording-indicator, #ffffff)";
3057
+
2993
3058
  if (recordingIconColor) {
2994
- micButton.style.color = recordingIconColor;
2995
- // Update SVG stroke color if present
2996
3059
  const svg = micButton.querySelector("svg");
2997
3060
  if (svg) {
2998
3061
  svg.setAttribute("stroke", recordingIconColor);
@@ -3092,30 +3155,27 @@ export const createAgentExperience = (
3092
3155
  micButton.style.fontSize = "18px";
3093
3156
  micButton.style.lineHeight = "1";
3094
3157
 
3095
- // Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
3158
+ // Set mic button foreground from config or theme token
3159
+ if (iconColor) {
3160
+ micButton.style.color = iconColor;
3161
+ } else {
3162
+ micButton.style.color = "var(--persona-text, #111827)";
3163
+ }
3164
+
3165
+ // Use Lucide mic icon (stroke width 1.5 for minimalist outline style)
3096
3166
  const iconColorValue = iconColor || "currentColor";
3097
3167
  const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
3098
3168
  if (micIconSvg) {
3099
3169
  micButton.appendChild(micIconSvg);
3100
- micButton.style.color = iconColorValue;
3101
3170
  } else {
3102
- // Fallback to text if icon fails
3103
3171
  micButton.textContent = "🎤";
3104
- micButton.style.color = iconColorValue;
3105
3172
  }
3106
-
3173
+
3107
3174
  // Apply background color
3108
3175
  if (backgroundColor) {
3109
3176
  micButton.style.backgroundColor = backgroundColor;
3110
3177
  } else {
3111
- micButton.classList.add("persona-bg-persona-primary");
3112
- }
3113
-
3114
- // Apply icon/text color
3115
- if (iconColor) {
3116
- micButton.style.color = iconColor;
3117
- } else if (!iconColor && !sendButtonConfig?.textColor) {
3118
- micButton.classList.add("persona-text-white");
3178
+ micButton.style.backgroundColor = "";
3119
3179
  }
3120
3180
 
3121
3181
  // Apply border styling
@@ -3187,14 +3247,14 @@ export const createAgentExperience = (
3187
3247
  if (!micButton) return;
3188
3248
  storeOriginalMicStyles();
3189
3249
  const voiceConfig = config.voiceRecognition ?? {};
3190
- const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
3250
+ const recordingBackgroundColor = voiceConfig.recordingBackgroundColor;
3191
3251
  const recordingIconColor = voiceConfig.recordingIconColor;
3192
3252
  const recordingBorderColor = voiceConfig.recordingBorderColor;
3193
3253
  removeAllVoiceStateClasses();
3194
3254
  micButton.classList.add("persona-voice-recording");
3195
- micButton.style.backgroundColor = recordingBackgroundColor;
3255
+ micButton.style.backgroundColor = recordingBackgroundColor ?? "var(--persona-voice-recording-bg, #ef4444)";
3256
+ micButton.style.color = recordingIconColor ?? "var(--persona-voice-recording-indicator, #ffffff)";
3196
3257
  if (recordingIconColor) {
3197
- micButton.style.color = recordingIconColor;
3198
3258
  const svg = micButton.querySelector("svg");
3199
3259
  if (svg) svg.setAttribute("stroke", recordingIconColor);
3200
3260
  }
@@ -3240,7 +3300,7 @@ export const createAgentExperience = (
3240
3300
  const iconColor = voiceConfig.speakingIconColor
3241
3301
  ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingIconColor ?? originalMicStyles?.color ?? "") : (originalMicStyles?.color ?? ""));
3242
3302
  const bgColor = voiceConfig.speakingBackgroundColor
3243
- ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBackgroundColor ?? "#ef4444") : (originalMicStyles?.backgroundColor ?? ""));
3303
+ ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBackgroundColor ?? "var(--persona-voice-recording-bg, #ef4444)") : (originalMicStyles?.backgroundColor ?? ""));
3244
3304
  const borderColor = voiceConfig.speakingBorderColor
3245
3305
  ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBorderColor ?? "") : (originalMicStyles?.borderColor ?? ""));
3246
3306
 
@@ -3510,6 +3570,7 @@ export const createAgentExperience = (
3510
3570
  } finally {
3511
3571
  // applyFullHeightStyles() assigns wrapper.style.cssText (e.g. display:flex !important), which
3512
3572
  // overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
3573
+ updateScrollToBottomButtonOffset();
3513
3574
  updateOpenState();
3514
3575
  }
3515
3576
  };
@@ -3518,37 +3579,68 @@ export const createAgentExperience = (
3518
3579
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
3519
3580
  ownerWindow.addEventListener("resize", recalcPanelHeight);
3520
3581
  destroyCallbacks.push(() => ownerWindow.removeEventListener("resize", recalcPanelHeight));
3582
+ if (typeof ResizeObserver !== "undefined") {
3583
+ const footerResizeObserver = new ResizeObserver(() => {
3584
+ updateScrollToBottomButtonOffset();
3585
+ });
3586
+ footerResizeObserver.observe(footer);
3587
+ destroyCallbacks.push(() => footerResizeObserver.disconnect());
3588
+ }
3521
3589
 
3522
3590
  lastScrollTop = body.scrollTop;
3523
3591
 
3524
3592
  const handleScroll = () => {
3525
3593
  const scrollTop = body.scrollTop;
3526
- const scrollHeight = body.scrollHeight;
3527
- const clientHeight = body.clientHeight;
3528
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
3529
- const delta = Math.abs(scrollTop - lastScrollTop);
3530
- lastScrollTop = scrollTop;
3531
-
3532
- if (isAutoScrolling) return;
3533
- if (delta <= USER_SCROLL_THRESHOLD) return;
3534
-
3535
- if (!shouldAutoScroll && distanceFromBottom < BOTTOM_THRESHOLD) {
3536
- isAutoScrollBlocked = false;
3537
- shouldAutoScroll = true;
3594
+ const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
3595
+ following: autoFollow.isFollowing(),
3596
+ currentScrollTop: scrollTop,
3597
+ lastScrollTop,
3598
+ nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3599
+ userScrollThreshold: USER_SCROLL_THRESHOLD,
3600
+ isAutoScrolling,
3601
+ pauseOnUpwardScroll: true,
3602
+ pauseWhenAwayFromBottom: false,
3603
+ resumeRequiresDownwardScroll: true
3604
+ });
3605
+ lastScrollTop = nextLastScrollTop;
3606
+
3607
+ if (action === "resume") {
3608
+ resumeAutoScroll();
3538
3609
  return;
3539
3610
  }
3540
3611
 
3541
- if (shouldAutoScroll && distanceFromBottom > BOTTOM_THRESHOLD) {
3542
- isAutoScrollBlocked = true;
3543
- blockUntilTime = Date.now() + AUTO_SCROLL_BLOCK_TIME;
3544
- shouldAutoScroll = false;
3612
+ if (action === "pause") {
3613
+ pauseAutoScroll();
3545
3614
  }
3546
3615
  };
3547
3616
 
3548
3617
  body.addEventListener("scroll", handleScroll, { passive: true });
3549
3618
  destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
3619
+ const handleWheel = (event: WheelEvent) => {
3620
+ const action = resolveFollowStateFromWheel({
3621
+ following: autoFollow.isFollowing(),
3622
+ deltaY: event.deltaY,
3623
+ nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3624
+ resumeWhenNearBottom: true
3625
+ });
3626
+
3627
+ if (action === "pause") {
3628
+ pauseAutoScroll();
3629
+ } else if (action === "resume") {
3630
+ resumeAutoScroll();
3631
+ }
3632
+ };
3633
+ body.addEventListener("wheel", handleWheel, { passive: true });
3634
+ destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
3635
+ scrollToBottomButton.addEventListener("click", () => {
3636
+ body.scrollTop = body.scrollHeight;
3637
+ lastScrollTop = body.scrollTop;
3638
+ resumeAutoScroll();
3639
+ scheduleAutoScroll(true);
3640
+ });
3641
+ destroyCallbacks.push(() => scrollToBottomButton.remove());
3550
3642
  destroyCallbacks.push(() => {
3551
- if (scrollRAF) cancelAnimationFrame(scrollRAF);
3643
+ cancelAutoScroll();
3552
3644
  });
3553
3645
 
3554
3646
  const refreshCloseButton = () => {
@@ -3695,6 +3787,9 @@ export const createAgentExperience = (
3695
3787
  autoExpand = config.launcher?.autoExpand ?? false;
3696
3788
  showReasoning = config.features?.showReasoning ?? true;
3697
3789
  showToolCalls = config.features?.showToolCalls ?? true;
3790
+ scrollToBottomFeature = config.features?.scrollToBottom ?? {};
3791
+ renderScrollToBottomButton();
3792
+ syncScrollToBottomButton();
3698
3793
  const prevShowEventStreamToggle = showEventStreamToggle;
3699
3794
  showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
3700
3795
 
@@ -3718,10 +3813,11 @@ export const createAgentExperience = (
3718
3813
  // Add header toggle button if not present
3719
3814
  if (!eventStreamToggleBtn && header) {
3720
3815
  const dynEsClassNames = config.features?.eventStream?.classNames;
3721
- const dynToggleBtnClasses = "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full persona-text-persona-muted hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-1" + (dynEsClassNames?.toggleButton ? " " + dynEsClassNames.toggleButton : "");
3816
+ const dynToggleBtnClasses = "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-opacity-80 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-1" + (dynEsClassNames?.toggleButton ? " " + dynEsClassNames.toggleButton : "");
3722
3817
  eventStreamToggleBtn = createElement("button", dynToggleBtnClasses) as HTMLButtonElement;
3723
3818
  eventStreamToggleBtn.style.width = "28px";
3724
3819
  eventStreamToggleBtn.style.height = "28px";
3820
+ eventStreamToggleBtn.style.color = HEADER_THEME_CSS.actionIconColor;
3725
3821
  eventStreamToggleBtn.type = "button";
3726
3822
  eventStreamToggleBtn.setAttribute("aria-label", "Event Stream");
3727
3823
  eventStreamToggleBtn.title = "Event Stream";
@@ -3872,6 +3968,8 @@ export const createAgentExperience = (
3872
3968
  if (footer) {
3873
3969
  footer.style.display = showFooter ? "" : "none";
3874
3970
  }
3971
+ updateScrollToBottomButtonOffset();
3972
+ syncScrollToBottomButton();
3875
3973
 
3876
3974
  // Only update open state if launcher enabled state changed or autoExpand value changed
3877
3975
  const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
@@ -4488,22 +4586,18 @@ export const createAgentExperience = (
4488
4586
  micButton.textContent = "🎤";
4489
4587
  }
4490
4588
 
4491
- // Update colors
4589
+ // Update colors from config or theme tokens
4492
4590
  const backgroundColor = voiceConfig.backgroundColor ?? sendButtonConfig.backgroundColor;
4493
4591
  if (backgroundColor) {
4494
4592
  micButton.style.backgroundColor = backgroundColor;
4495
- micButton.classList.remove("persona-bg-persona-primary");
4496
4593
  } else {
4497
4594
  micButton.style.backgroundColor = "";
4498
- micButton.classList.add("persona-bg-persona-primary");
4499
4595
  }
4500
-
4596
+
4501
4597
  if (iconColor) {
4502
4598
  micButton.style.color = iconColor;
4503
- micButton.classList.remove("persona-text-white");
4504
- } else if (!iconColor && !sendButtonConfig.textColor) {
4505
- micButton.style.color = "";
4506
- micButton.classList.add("persona-text-white");
4599
+ } else {
4600
+ micButton.style.color = "var(--persona-text, #111827)";
4507
4601
  }
4508
4602
 
4509
4603
  // Update border styling
@@ -4729,30 +4823,25 @@ export const createAgentExperience = (
4729
4823
  // Clear existing content
4730
4824
  sendButton.innerHTML = "";
4731
4825
 
4826
+ // Set foreground color from config or theme token
4827
+ if (textColor) {
4828
+ sendButton.style.color = textColor;
4829
+ } else {
4830
+ sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
4831
+ }
4832
+
4732
4833
  // Use Lucide icon if iconName is provided, otherwise fall back to iconText
4733
4834
  if (iconName) {
4734
4835
  const iconSize = parseFloat(buttonSize) || 24;
4735
- const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
4836
+ const iconColor = textColor?.trim() || "currentColor";
4736
4837
  const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
4737
4838
  if (iconSvg) {
4738
4839
  sendButton.appendChild(iconSvg);
4739
- sendButton.style.color = iconColor;
4740
4840
  } else {
4741
- // Fallback to text if icon fails to render
4742
4841
  sendButton.textContent = iconText;
4743
- if (textColor) {
4744
- sendButton.style.color = textColor;
4745
- } else {
4746
- sendButton.classList.add("persona-text-white");
4747
- }
4748
4842
  }
4749
4843
  } else {
4750
4844
  sendButton.textContent = iconText;
4751
- if (textColor) {
4752
- sendButton.style.color = textColor;
4753
- } else {
4754
- sendButton.classList.add("persona-text-white");
4755
- }
4756
4845
  }
4757
4846
 
4758
4847
  // Update classes
@@ -4762,6 +4851,7 @@ export const createAgentExperience = (
4762
4851
  sendButton.style.backgroundColor = backgroundColor;
4763
4852
  sendButton.classList.remove("persona-bg-persona-primary");
4764
4853
  } else {
4854
+ sendButton.style.backgroundColor = "";
4765
4855
  sendButton.classList.add("persona-bg-persona-primary");
4766
4856
  }
4767
4857
  } else {