From de05b89e9d808c79328b37e2e6739520345c8910 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Thu, 8 Oct 2026 12:01:50 -0300 Subject: [PATCH 01/10] build: bump development version to 5.2.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 699d6e0..0a35ce7 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.vaadin.addons.flowingcode chat-assistant-addon - 5.1.1-SNAPSHOT + 5.2.0-SNAPSHOT Chat Assistant Add-on Chat Assistant Add-on for Vaadin Flow https://www.flowingcode.com/en/open-source/ From 2fb04804e2cb863174ab705b20ed28c365ac6058 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 6 Oct 2026 17:12:15 -0300 Subject: [PATCH 02/10] fix: find the chat window from its own root node The resize and constraint scripts looked the popover overlay up in the document, which misses it when the assistant renders inside a shadow root. --- .../frontend/fc-chat-assistant-resize.js | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js index 2c9f537..821f126 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js @@ -297,7 +297,7 @@ window.fcChatAssistantResize = (root, item, container, popoverTag, sizeRaw, maxS function fetchOverlay() { if (!overlay || !overlay.isConnected) { contentPart = null; // a new overlay means the old content part is stale too - overlay = resolveOverlay(popoverTag); + overlay = resolveOverlay(popoverTag, container); if (overlay) { observeOverlayStyle(); } @@ -385,21 +385,25 @@ window.fcChatAssistantResize = (root, item, container, popoverTag, sizeRaw, maxS // Resolves the popover's overlay element across Vaadin 24 (the overlay carries the class directly) and // Vaadin 25 (the overlay lives inside the popover's shadow root). Shared by fetchOverlay() and // fcChatAssistantContentPart() so the selector chain is defined in one place. -function resolveOverlay(popoverTag) { +function resolveOverlay(popoverTag, from) { const overlayTag = "vaadin-popover-overlay".toUpperCase(); - return document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) - || [...document.getElementsByClassName(popoverTag)].find(p => p.tagName === overlayTag); + // Search from the caller's own root instead of the document, so the lookup also succeeds when the + // chat lives inside a shadow root. getRootNode() returns the document + // in the ordinary case, leaving that behaviour unchanged. + const scope = from?.getRootNode?.() ?? document; + return scope.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) + || [...scope.querySelectorAll(`.${popoverTag}`)].find(p => p.tagName === overlayTag); } // Resolves the popover overlay's [part='content'] element, retrying briefly because the overlay is -// (re)created lazily when the popover opens. -function fcChatAssistantContentPart(popoverTag, callback, attempts = 0) { - const overlay = resolveOverlay(popoverTag); +// (re)created lazily when the popover opens. `from` is any element of the chat, used as the lookup root. +function fcChatAssistantContentPart(popoverTag, from, callback, attempts = 0) { + const overlay = resolveOverlay(popoverTag, from); const contentPart = overlay?.shadowRoot?.querySelector('[part="content"]'); if (contentPart) { callback(contentPart); } else if (attempts < 20) { - setTimeout(() => fcChatAssistantContentPart(popoverTag, callback, attempts + 1), 100); + setTimeout(() => fcChatAssistantContentPart(popoverTag, from, callback, attempts + 1), 100); } } @@ -446,7 +450,7 @@ window.fcChatAssistantApplyConstraints = (overlayDiv, popoverTag) => { return result + 'px'; }; - fcChatAssistantContentPart(popoverTag, (contentPart) => { + fcChatAssistantContentPart(popoverTag, overlayDiv, (contentPart) => { if (minWidth) contentPart.style.minWidth = minWidth; if (minHeight) contentPart.style.minHeight = minHeight; if (maxWidth) contentPart.style.maxWidth = maxWidth; From 1f0266874b087f0cab1f11eabc88eef3fa47acb0 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 6 Oct 2026 17:12:15 -0300 Subject: [PATCH 03/10] fix: keep an anchored FAB inside its shadow root Portaling the FAB to the body strips it of the styles scoped to a shadow root. Inside one it now relies on position:fixed alone, so an assistant rendered in a shadow root can be anchored and dragged. --- .../META-INF/resources/frontend/fc-chat-assistant-movement.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index d60f9f8..1fbd3d6 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -54,7 +54,9 @@ if (!customElements.get('animated-fab')) { // would otherwise trap the wrapper. When it is not anchored, the wrapper is returned to its home // slot so position:absolute stays relative to its container. Idempotent. window.fcChatAssistantPortalFab = (item, anchored) => { - if (anchored) { + // Inside a shadow root the portal would strip + // the FAB of the styles scoped to that root, so it stays put and relies on position:fixed alone. + if (anchored && (item.__fcHome ? item.__fcHome.parent : item.parentNode)?.getRootNode() === document) { if (item.parentNode !== document.body) { // Remember where the wrapper lived so it can be put back on teardown / un-anchor. item.__fcHome = item.__fcHome || { parent: item.parentNode, next: item.nextSibling }; From b43e91d30033f820f2e6d72e1b7f2e3b1b60cb87 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 6 Oct 2026 17:14:26 -0300 Subject: [PATCH 04/10] feat: remember the dragged FAB position across page loads setFabPositionMemory(Duration) keeps the position in the tab's session storage for as long as the user returns within the timeout after leaving the page; resetting the position or switching modes forgets it. It is off by default. The position is now tracked as a proportion of the room the FAB can move in, so resizing the window no longer leaves a FAB dragged to one side stranded in the middle. Close #74 --- .../addons/chatassistant/ChatAssistant.java | 46 +++++- .../frontend/fc-chat-assistant-movement.js | 135 ++++++++++++++++-- .../chatassistant/ChatAssistantLogicTest.java | 22 +++ 3 files changed, 191 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index ac7e5f6..ec71b97 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -62,6 +62,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.Serializable; +import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Base64; @@ -163,6 +164,7 @@ public class ChatAssistant extends Div { private Component headerComponent; private Component footerContainer; private FabPosition fabPosition = DEFAULT_POSITION; + private Duration fabPositionMemory = Duration.ZERO; private FabVariant activeSizeVariant = null; private Span whoIsTyping; private Registration defaultSubmitListenerRegistration; @@ -206,6 +208,7 @@ public ChatAssistant(List messages, boolean markdownEnabled) { null, null, null, + null, null); } @@ -259,6 +262,8 @@ public ChatAssistant(boolean markdownEnabled) { * the default) * @param maxWidth the chat window maximum width ({@code null} keeps the default) * @param maxHeight the chat window maximum height ({@code null} keeps the default) + * @param fabPositionMemory how long the FAB keeps a dragged position after the user leaves the + * page ({@code null} keeps it off, see {@link #setFabPositionMemory(Duration)}) */ @Builder private ChatAssistant( @@ -277,7 +282,8 @@ private ChatAssistant( String width, String height, String maxWidth, - String maxHeight) { + String maxHeight, + Duration fabPositionMemory) { if (messages != null) { this.messages.addAll(messages); } @@ -300,6 +306,9 @@ private ChatAssistant( this.initializeFooter(); this.initializeContent(markdownEnabled); this.initializeChatWindow(); + if (fabPositionMemory != null) { + setFabPositionMemory(fabPositionMemory); + } } /** Seeds the builder defaults for the flags whose real default is not {@code false}. */ @@ -967,6 +976,41 @@ public void setFabPosition(FabPosition fabPosition) { resetFabPosition(); } + /** + * Makes the FAB keep the position the user dragged it to across page loads, as long as the user + * comes back within the given time after leaving the page. Useful in applications where every + * navigation loads a new page. The position is kept per browser tab, in its + * session storage, as a proportion of the room the FAB can move in, so it survives a different + * window size. {@link #resetFabPosition()}, {@link #setFabPosition(FabPosition)} and switching + * between desktop and mobile mode forget it. Assistants on the same origin share it unless they + * have different ids. {@link Duration#ZERO}, the default, turns it off. + * + * @since 5.2.0 + * @param timeout how long the position outlives the page; not negative + */ + public void setFabPositionMemory(Duration timeout) { + Objects.requireNonNull(timeout, "Timeout cannot be null"); + if (timeout.isNegative()) { + throw new IllegalArgumentException("Timeout cannot be negative"); + } + this.fabPositionMemory = timeout; + if (timeout.isZero()) { + fab.getElement().removeAttribute("position-memory"); + } else { + fab.getElement().setAttribute("position-memory", String.valueOf(timeout.toMillis())); + } + } + + /** + * Returns how long the FAB keeps a dragged position after the user leaves the page. + * + * @since 5.2.0 + * @see #setFabPositionMemory(Duration) + */ + public Duration getFabPositionMemory() { + return fabPositionMemory; + } + /** * Returns the FAB's configured corner. * diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index 1fbd3d6..f69e60b 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -117,6 +117,75 @@ function fcChatAssistantCornerPosition(item, fab, corner, margin) { } } +function fcChatAssistantClamp01(value) { + return Math.min(1, Math.max(0, Number(value) || 0)); +} + +// Remembers where the user dragged the FAB across page loads, in the tab's session storage, while +// the FAB carries a position-memory attribute (a timeout in milliseconds). The timestamp is refreshed +// when the page is left, so the timeout measures how long the user was away rather than how long +// the page stayed open. FABs on the same origin share the entry unless their hosts have an id. +function fcChatAssistantPositionMemory(root, fab) { + const key = 'fc-chat-assistant-fab-position' + (root.id ? ':' + root.id : ''); + const timeout = () => parseFloat(fab.getAttribute('position-memory')) || 0; + const storage = () => { + try { + return window.sessionStorage; + } catch (e) { + return null; // storage blocked, e.g. by privacy settings: the FAB simply forgets + } + }; + const read = () => { + try { + return JSON.parse(storage()?.getItem(key)); + } catch (e) { + return null; + } + }; + const write = (entry) => { + try { + storage()?.setItem(key, JSON.stringify(entry)); + } catch (e) { + // quota or blocked storage: nothing to remember then + } + }; + const forget = () => { + try { + storage()?.removeItem(key); + } catch (e) { + // blocked storage holds nothing to forget + } + }; + return { + save(ratio) { + if (timeout() > 0) { + write({ x: ratio.x, y: ratio.y, at: Date.now() }); + } + }, + // Called when the page is left, so the next page measures the time away from this moment. + touch() { + const entry = read(); + if (entry && timeout() > 0) { + write({ ...entry, at: Date.now() }); + } + }, + restore(ratio) { + const entry = read(); + if (!entry || timeout() <= 0) { + return false; + } + if (!(Date.now() - entry.at <= timeout())) { + forget(); // expired: drop it, or leaving this page would revive it + return false; + } + ratio.x = fcChatAssistantClamp01(entry.x); + ratio.y = fcChatAssistantClamp01(entry.y); + return true; + }, + forget, + }; +} + window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, positionRaw) => { // Prevent duplicate initialization const guard = `__fcChatAssistantMovement`; @@ -130,8 +199,11 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po const snapTransition = 'all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)'; const position = { x: margin, y: margin }; const initialPosition = { x: margin, y: margin }; - // Expose the live position so the reset hook can move the FAB after initialization. - item.__fcPosition = position; + // Where the FAB sits within the room it can move in, from 0 (right/bottom edge) to 1 (left/top + // edge). A resize places the FAB from this ratio rather than from its pixel offsets, so a FAB + // dragged to the left stays on the left when the window shrinks and grows back. + const ratio = { x: 0, y: 0 }; + const memory = fcChatAssistantPositionMemory(root, fab); let screenWidth = window.innerWidth; let screenHeight = window.innerHeight; @@ -144,13 +216,16 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po screenHeight = window.innerHeight; // The popover content part clamps itself to the viewport (max-height/width: 100%), so no - // manual container shrinking is needed here. Just keep the FAB within the new screen bounds. - snapToBoundary(); + // manual container shrinking is needed here. Just place the FAB within the new screen bounds. + applyRatio(); }; window.addEventListener("resize", resizeHandler); + const pageHideHandler = () => memory.touch(); + window.addEventListener("pagehide", pageHideHandler); (root.__fcCleanups = root.__fcCleanups || []).push(() => { window.removeEventListener("resize", resizeHandler); + window.removeEventListener("pagehide", pageHideHandler); window.fcChatAssistantMobileModeOff?.(root); window.fcChatAssistantScreenSizeOffAll?.(root); // Put the wrapper back in its home slot so it is removed with the detached host, not left @@ -170,6 +245,38 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po item.style.bottom = position.y + 'px'; } + // The room the FAB can move in along each axis, between the margins. + function room() { + const size = fcChatAssistantSize(fab); + return { + x: Math.max(0, screenWidth - size.width - 2 * margin), + y: Math.max(0, screenHeight - size.height - 2 * margin), + }; + } + + function syncRatio() { + const available = room(); + ratio.x = available.x > 0 ? fcChatAssistantClamp01((position.x - margin) / available.x) : 0; + ratio.y = available.y > 0 ? fcChatAssistantClamp01((position.y - margin) / available.y) : 0; + } + + function applyRatio() { + const available = room(); + position.x = margin + ratio.x * available.x; + position.y = margin + ratio.y * available.y; + updatePosition(); + } + + // Lets the reset hook move the FAB and clear what it remembered after initialization. + item.__fcMovement = { + moveTo(x, y) { + position.x = x; + position.y = y; + syncRatio(); + memory.forget(); + }, + }; + // Ensure the item stays within the screen and margin bounds function snapToBoundary() { // Get current dimensions to account for transforms @@ -236,6 +343,9 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po snapToBoundary(); if (isClickOnlyEvent()) { root.$server?.onClick(); + } else { + syncRatio(); + memory.save(ratio); } } } @@ -250,12 +360,17 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po // before the first layout pass (and while the FAB lives in a hidden tab), which would push it // off-screen for any corner other than the bottom-right default. An IntersectionObserver fires // when the FAB becomes visible, so the size is known by then. + // A remembered position, if any, takes over from the corner. function applyCorner() { const start = fcChatAssistantCornerPosition(item, fab, positionRaw, margin); position.x = start.x; position.y = start.y; - initialPosition.x = start.x; - initialPosition.y = start.y; + syncRatio(); + if (fab.hasAttribute('movable') && memory.restore(ratio)) { + applyRatio(); + } + initialPosition.x = position.x; + initialPosition.y = position.y; updatePosition(); } if (fcChatAssistantSize(fab).width > 0) { @@ -288,11 +403,9 @@ window.fcChatAssistantResetPosition = (item, marginRaw, positionRaw) => { item.style.transition = resetTransition; item.style.right = target.x + 'px'; item.style.bottom = target.y + 'px'; - // Keep the live drag state in sync so the next drag starts from the reset position. - if (item.__fcPosition) { - item.__fcPosition.x = target.x; - item.__fcPosition.y = target.y; - } + // Keep the live drag state in sync so the next drag starts from the reset position, and drop + // any remembered position: the user asked for the corner. + item.__fcMovement?.moveTo(target.x, target.y); }; // Removes any active media-query listener, freezing the component in its current mode. Also clears diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java index e8023dc..172e435 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java @@ -29,6 +29,7 @@ import com.flowingcode.vaadin.addons.chatassistant.model.FabVariant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; import com.vaadin.flow.component.button.ButtonVariant; +import java.time.Duration; import org.junit.Test; /** @@ -174,4 +175,25 @@ public void isFabMovable_reflectsEffectiveDragState() { ca.setFabMovable(false); assertFalse(ca.isFabMovable()); } + + // fabPositionMemory -------------------------------------------------------- + + @Test + public void fabPositionMemory_isOffByDefault() { + assertEquals(Duration.ZERO, newChatAssistant().getFabPositionMemory()); + } + + @Test + public void fabPositionMemory_isSetFromTheBuilder() { + ChatAssistant chat = + ChatAssistant.builder().fabPositionMemory(Duration.ofMinutes(2)).build(); + assertEquals(Duration.ofMinutes(2), chat.getFabPositionMemory()); + } + + @Test + public void fabPositionMemory_rejectsNegativeTimeouts() { + assertThrows( + IllegalArgumentException.class, + () -> newChatAssistant().setFabPositionMemory(Duration.ofSeconds(-1))); + } } From 0703be7bbfc2e4f611bf226f1ef03529c6f454d8 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Thu, 8 Oct 2026 11:45:11 -0300 Subject: [PATCH 05/10] fix: harden the remembered FAB position A FAB positioned inside a container keeps its offsets on resize again, instead of being placed against the viewport. A remembered position is only restored onto a movable FAB anchored to the viewport, and only if it is well formed and still within the timeout; leaving a page no longer revives an expired entry. setFabPositionMemory rejects timeouts under one millisecond and leaves the assistant unchanged when a timeout is rejected. --- .../addons/chatassistant/ChatAssistant.java | 38 +++++--- .../frontend/fc-chat-assistant-movement.js | 92 +++++++++---------- .../frontend/fc-chat-assistant-resize.js | 4 +- .../chatassistant/ChatAssistantLogicTest.java | 39 +++++++- 4 files changed, 103 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index ec71b97..4ba86a6 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -283,7 +283,7 @@ private ChatAssistant( String height, String maxWidth, String maxHeight, - Duration fabPositionMemory) { + final Duration fabPositionMemory) { if (messages != null) { this.messages.addAll(messages); } @@ -945,8 +945,9 @@ public void setFabAnchoredToViewport(boolean anchoredToViewport) { fab.getElement().removeAttribute("anchored"); } // An anchored FAB is lifted to document.body so its position:fixed clears any ancestor - // containing block (a transformed/filtered container traps a fixed descendant otherwise); - // un-anchoring returns it to its container. Idempotent with the portal done at movement init. + // containing block (a transformed/filtered container traps a fixed descendant otherwise), + // except inside a shadow root, where it stays to keep its scoped styles; un-anchoring returns + // it to its container. Idempotent with the portal done at movement init. fabWrapper .getElement() .executeJs( @@ -978,26 +979,37 @@ public void setFabPosition(FabPosition fabPosition) { /** * Makes the FAB keep the position the user dragged it to across page loads, as long as the user - * comes back within the given time after leaving the page. Useful in applications where every - * navigation loads a new page. The position is kept per browser tab, in its - * session storage, as a proportion of the room the FAB can move in, so it survives a different - * window size. {@link #resetFabPosition()}, {@link #setFabPosition(FabPosition)} and switching - * between desktop and mobile mode forget it. Assistants on the same origin share it unless they - * have different ids. {@link Duration#ZERO}, the default, turns it off. + * comes back within the given time after leaving the page. The position is kept per browser tab, + * in its session storage, as a proportion of the room the FAB can move in, so it survives a + * different window size. Only a movable FAB anchored to the viewport restores it. + * {@link #resetFabPosition()}, {@link #setFabPosition(FabPosition)} and switching between + * desktop and mobile mode forget it. + * + *

Assistants on the same origin share the saved position unless they have different ids, so + * set the id before the assistant is attached. The timeout has millisecond precision. + * {@link Duration#ZERO}, the default, turns the memory off; a position saved earlier is then + * ignored. * * @since 5.2.0 - * @param timeout how long the position outlives the page; not negative + * @param timeout how long the position outlives the page; not {@code null}, not negative and, + * unless zero, at least one millisecond + * @throws IllegalArgumentException if the timeout is negative or under one millisecond + * @throws ArithmeticException if the timeout does not fit in a {@code long} of milliseconds */ - public void setFabPositionMemory(Duration timeout) { + public void setFabPositionMemory(final Duration timeout) { Objects.requireNonNull(timeout, "Timeout cannot be null"); if (timeout.isNegative()) { throw new IllegalArgumentException("Timeout cannot be negative"); } + final long millis = timeout.toMillis(); + if (millis == 0 && !timeout.isZero()) { + throw new IllegalArgumentException("Timeout cannot be under one millisecond"); + } this.fabPositionMemory = timeout; - if (timeout.isZero()) { + if (millis == 0) { fab.getElement().removeAttribute("position-memory"); } else { - fab.getElement().setAttribute("position-memory", String.valueOf(timeout.toMillis())); + fab.getElement().setAttribute("position-memory", String.valueOf(millis)); } } diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index f69e60b..ddefa2c 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -51,11 +51,11 @@ if (!customElements.get('animated-fab')) { // Lifts the FAB wrapper to while it is anchored to the viewport, so its position:fixed // resolves against the viewport rather than an ancestor containing block. Any ancestor with // transform/filter/backdrop-filter/perspective/contain/will-change (e.g. Aura's AppLayout navbar) -// would otherwise trap the wrapper. When it is not anchored, the wrapper is returned to its home -// slot so position:absolute stays relative to its container. Idempotent. +// would otherwise trap the wrapper. Inside a shadow root the wrapper stays in place, as the portal +// would strip its scoped styles, so such an ancestor can still trap it there. When it is not +// anchored, the wrapper is returned to its home slot so position:absolute stays relative to its +// container. Idempotent. window.fcChatAssistantPortalFab = (item, anchored) => { - // Inside a shadow root the portal would strip - // the FAB of the styles scoped to that root, so it stays put and relies on position:fixed alone. if (anchored && (item.__fcHome ? item.__fcHome.parent : item.parentNode)?.getRootNode() === document) { if (item.parentNode !== document.body) { // Remember where the wrapper lived so it can be put back on teardown / un-anchor. @@ -121,61 +121,50 @@ function fcChatAssistantClamp01(value) { return Math.min(1, Math.max(0, Number(value) || 0)); } -// Remembers where the user dragged the FAB across page loads, in the tab's session storage, while -// the FAB carries a position-memory attribute (a timeout in milliseconds). The timestamp is refreshed -// when the page is left, so the timeout measures how long the user was away rather than how long -// the page stayed open. FABs on the same origin share the entry unless their hosts have an id. +// Remembers where the user dragged the FAB, in the tab's session storage, while the FAB has a +// position-memory timeout in milliseconds. Refreshing it on pagehide makes the timeout measure the +// time away from the page. function fcChatAssistantPositionMemory(root, fab) { + // FABs on the same origin share the entry unless their hosts have an id const key = 'fc-chat-assistant-fab-position' + (root.id ? ':' + root.id : ''); - const timeout = () => parseFloat(fab.getAttribute('position-memory')) || 0; - const storage = () => { + const timeout = () => Number.parseFloat(fab.getAttribute('position-memory')) || 0; + // Storage may be blocked or full; that must never interrupt the FAB + const attempt = (action) => { try { - return window.sessionStorage; - } catch (e) { - return null; // storage blocked, e.g. by privacy settings: the FAB simply forgets - } - }; - const read = () => { - try { - return JSON.parse(storage()?.getItem(key)); + return action(window.sessionStorage); } catch (e) { return null; } }; - const write = (entry) => { - try { - storage()?.setItem(key, JSON.stringify(entry)); - } catch (e) { - // quota or blocked storage: nothing to remember then - } - }; - const forget = () => { - try { - storage()?.removeItem(key); - } catch (e) { - // blocked storage holds nothing to forget + const forget = () => attempt(storage => storage.removeItem(key)); + const isValid = (entry) => entry !== null && typeof entry === 'object' + && Number.isFinite(entry.x) && Number.isFinite(entry.y) + && Number.isFinite(entry.at) && entry.at <= Date.now(); + // The entry if it is well formed and within the timeout; anything else is dropped + const read = () => { + const entry = attempt(storage => JSON.parse(storage.getItem(key))); + if (isValid(entry) && Date.now() - entry.at <= timeout()) { + return entry; } + forget(); + return null; }; + const write = (entry) => attempt(storage => storage.setItem(key, JSON.stringify(entry))); return { save(ratio) { if (timeout() > 0) { write({ x: ratio.x, y: ratio.y, at: Date.now() }); } }, - // Called when the page is left, so the next page measures the time away from this moment. touch() { - const entry = read(); - if (entry && timeout() > 0) { + const entry = timeout() > 0 ? read() : null; + if (entry) { write({ ...entry, at: Date.now() }); } }, restore(ratio) { - const entry = read(); - if (!entry || timeout() <= 0) { - return false; - } - if (!(Date.now() - entry.at <= timeout())) { - forget(); // expired: drop it, or leaving this page would revive it + const entry = timeout() > 0 ? read() : null; + if (!entry) { return false; } ratio.x = fcChatAssistantClamp01(entry.x); @@ -199,9 +188,8 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po const snapTransition = 'all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)'; const position = { x: margin, y: margin }; const initialPosition = { x: margin, y: margin }; - // Where the FAB sits within the room it can move in, from 0 (right/bottom edge) to 1 (left/top - // edge). A resize places the FAB from this ratio rather than from its pixel offsets, so a FAB - // dragged to the left stays on the left when the window shrinks and grows back. + // Ratios run from 0 at the right/bottom edge to 1 at the left/top one, so a resize keeps the + // FAB's place const ratio = { x: 0, y: 0 }; const memory = fcChatAssistantPositionMemory(root, fab); @@ -216,8 +204,13 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po screenHeight = window.innerHeight; // The popover content part clamps itself to the viewport (max-height/width: 100%), so no - // manual container shrinking is needed here. Just place the FAB within the new screen bounds. - applyRatio(); + // manual container shrinking is needed here. Just keep the FAB within the new screen bounds. + // Only an anchored FAB is placed against the viewport; one in a container keeps its offsets. + if (fab.hasAttribute('anchored')) { + applyRatio(); + } else { + snapToBoundary(); + } }; window.addEventListener("resize", resizeHandler); const pageHideHandler = () => memory.touch(); @@ -236,7 +229,8 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po root['fc-chat-assistant-drag-listener'] = null; }); - // Escape any ancestor containing block by portaling the wrapper to while anchored. + // Escape any ancestor containing block by portaling the wrapper to while anchored, + // outside a shadow root. window.fcChatAssistantPortalFab(item, fab.hasAttribute('anchored')); // Update FAB position @@ -245,7 +239,6 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po item.style.bottom = position.y + 'px'; } - // The room the FAB can move in along each axis, between the margins. function room() { const size = fcChatAssistantSize(fab); return { @@ -267,7 +260,6 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po updatePosition(); } - // Lets the reset hook move the FAB and clear what it remembered after initialization. item.__fcMovement = { moveTo(x, y) { position.x = x; @@ -360,13 +352,12 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po // before the first layout pass (and while the FAB lives in a hidden tab), which would push it // off-screen for any corner other than the bottom-right default. An IntersectionObserver fires // when the FAB becomes visible, so the size is known by then. - // A remembered position, if any, takes over from the corner. function applyCorner() { const start = fcChatAssistantCornerPosition(item, fab, positionRaw, margin); position.x = start.x; position.y = start.y; syncRatio(); - if (fab.hasAttribute('movable') && memory.restore(ratio)) { + if (fab.hasAttribute('movable') && fab.hasAttribute('anchored') && memory.restore(ratio)) { applyRatio(); } initialPosition.x = position.x; @@ -403,8 +394,7 @@ window.fcChatAssistantResetPosition = (item, marginRaw, positionRaw) => { item.style.transition = resetTransition; item.style.right = target.x + 'px'; item.style.bottom = target.y + 'px'; - // Keep the live drag state in sync so the next drag starts from the reset position, and drop - // any remembered position: the user asked for the corner. + // Keep the live drag state in sync so the next drag starts from the reset position. item.__fcMovement?.moveTo(target.x, target.y); }; diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js index 821f126..4014186 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js @@ -387,9 +387,7 @@ window.fcChatAssistantResize = (root, item, container, popoverTag, sizeRaw, maxS // fcChatAssistantContentPart() so the selector chain is defined in one place. function resolveOverlay(popoverTag, from) { const overlayTag = "vaadin-popover-overlay".toUpperCase(); - // Search from the caller's own root instead of the document, so the lookup also succeeds when the - // chat lives inside a shadow root. getRootNode() returns the document - // in the ordinary case, leaving that behaviour unchanged. + // The popover may live in a shadow root, which document queries do not reach const scope = from?.getRootNode?.() ?? document; return scope.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) || [...scope.querySelectorAll(`.${popoverTag}`)].find(p => p.tagName === overlayTag); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java index 172e435..5fd63ca 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java @@ -185,15 +185,48 @@ public void fabPositionMemory_isOffByDefault() { @Test public void fabPositionMemory_isSetFromTheBuilder() { - ChatAssistant chat = + final ChatAssistant chat = ChatAssistant.builder().fabPositionMemory(Duration.ofMinutes(2)).build(); assertEquals(Duration.ofMinutes(2), chat.getFabPositionMemory()); } @Test public void fabPositionMemory_rejectsNegativeTimeouts() { + final ChatAssistant chat = newChatAssistant(); assertThrows( - IllegalArgumentException.class, - () -> newChatAssistant().setFabPositionMemory(Duration.ofSeconds(-1))); + IllegalArgumentException.class, () -> chat.setFabPositionMemory(Duration.ofSeconds(-1))); + } + + @Test + public void fabPositionMemory_rejectsTimeoutsUnderOneMillisecond() { + final ChatAssistant chat = newChatAssistant(); + assertThrows( + IllegalArgumentException.class, () -> chat.setFabPositionMemory(Duration.ofNanos(1))); + } + + @Test + public void fabPositionMemory_isSentToTheClientInMilliseconds() { + final ChatAssistant chat = newChatAssistant(); + chat.setFabPositionMemory(Duration.ofMinutes(2)); + assertEquals("120000", chat.fab.getElement().getAttribute("position-memory")); + } + + @Test + public void fabPositionMemory_zeroRemovesTheClientAttribute() { + final ChatAssistant chat = newChatAssistant(); + chat.setFabPositionMemory(Duration.ofMinutes(2)); + chat.setFabPositionMemory(Duration.ZERO); + assertFalse(chat.fab.getElement().hasAttribute("position-memory")); + } + + @Test + public void fabPositionMemory_isLeftUnchangedWhenTheTimeoutIsRejected() { + final ChatAssistant chat = newChatAssistant(); + chat.setFabPositionMemory(Duration.ofMinutes(2)); + assertThrows( + ArithmeticException.class, + () -> chat.setFabPositionMemory(Duration.ofSeconds(Long.MAX_VALUE))); + assertEquals(Duration.ofMinutes(2), chat.getFabPositionMemory()); + assertEquals("120000", chat.fab.getElement().getAttribute("position-memory")); } } From 726c7054d19d769ed6204b7f9ca2aa52b443cb0a Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Thu, 8 Oct 2026 15:15:41 -0300 Subject: [PATCH 06/10] fix: keep the remembered FAB position after a long stay Leaving the page refreshed the saved position through the same read that applies the timeout, so a stay longer than the timeout dropped the position just before the user left, and returning right away fell back to the corner. Leaving now only checks that the entry is well formed before refreshing it, and only when this page saved or restored it, so a page that never used it cannot revive an expired one. --- .../frontend/fc-chat-assistant-movement.js | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index ddefa2c..b63ac86 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -136,14 +136,23 @@ function fcChatAssistantPositionMemory(root, fab) { return null; } }; - const forget = () => attempt(storage => storage.removeItem(key)); + // Whether this page saved or restored the entry; only then does leaving the page refresh it + let held = false; + const forget = () => { + held = false; + attempt(storage => storage.removeItem(key)); + }; const isValid = (entry) => entry !== null && typeof entry === 'object' && Number.isFinite(entry.x) && Number.isFinite(entry.y) && Number.isFinite(entry.at) && entry.at <= Date.now(); + const readValid = () => { + const entry = attempt(storage => JSON.parse(storage.getItem(key))); + return isValid(entry) ? entry : null; + }; // The entry if it is well formed and within the timeout; anything else is dropped const read = () => { - const entry = attempt(storage => JSON.parse(storage.getItem(key))); - if (isValid(entry) && Date.now() - entry.at <= timeout()) { + const entry = readValid(); + if (entry && Date.now() - entry.at <= timeout()) { return entry; } forget(); @@ -154,10 +163,12 @@ function fcChatAssistantPositionMemory(root, fab) { save(ratio) { if (timeout() > 0) { write({ x: ratio.x, y: ratio.y, at: Date.now() }); + held = true; } }, + // The timeout runs from leaving the page, so a long stay must not expire the entry here touch() { - const entry = timeout() > 0 ? read() : null; + const entry = timeout() > 0 && held ? readValid() : null; if (entry) { write({ ...entry, at: Date.now() }); } @@ -169,6 +180,7 @@ function fcChatAssistantPositionMemory(root, fab) { } ratio.x = fcChatAssistantClamp01(entry.x); ratio.y = fcChatAssistantClamp01(entry.y); + held = true; return true; }, forget, From 8a6c0213b1caf5a9c614debb34a4f2a00150f85b Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Thu, 8 Oct 2026 15:15:41 -0300 Subject: [PATCH 07/10] docs: document that a corner set before attach keeps the position --- .../vaadin/addons/chatassistant/ChatAssistant.java | 3 ++- .../META-INF/resources/frontend/fc-chat-assistant-movement.js | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 4ba86a6..caeea95 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -983,7 +983,8 @@ public void setFabPosition(FabPosition fabPosition) { * in its session storage, as a proportion of the room the FAB can move in, so it survives a * different window size. Only a movable FAB anchored to the viewport restores it. * {@link #resetFabPosition()}, {@link #setFabPosition(FabPosition)} and switching between - * desktop and mobile mode forget it. + * desktop and mobile mode forget it once the FAB is on the page; configuring the corner or the + * mode before the assistant is attached does not, so a view can set them up on every load. * *

Assistants on the same origin share the saved position unless they have different ids, so * set the id before the assistant is attached. The timeout has millisecond precision. diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index b63ac86..ce85373 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -406,7 +406,9 @@ window.fcChatAssistantResetPosition = (item, marginRaw, positionRaw) => { item.style.transition = resetTransition; item.style.right = target.x + 'px'; item.style.bottom = target.y + 'px'; - // Keep the live drag state in sync so the next drag starts from the reset position. + // Keep the live drag state in sync so the next drag starts from the reset position. Before the + // movement is initialized the saved position is kept on purpose: a corner configured while the + // view is built must not discard it on every page load. item.__fcMovement?.moveTo(target.x, target.y); }; From fe6ee1c736c99874af6d2c1b81f2ce878ae1a058 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Thu, 8 Oct 2026 15:46:11 -0300 Subject: [PATCH 08/10] fix: leave the message input's padding to the theme The message input had its padding set to 0 2px, which in Lumo only tightens the room around its text area. In Aura and the base styles of Vaadin 25 the message input is the field itself, so that padding took the field's own: it shrank to 22px with the placeholder and the send button pressed against its border. Lumo's default padding also leaves room for the text area's focus ring, which the 2px kept from being cut. --- .../vaadin/addons/chatassistant/ChatAssistant.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index caeea95..3c99b46 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -1212,11 +1212,7 @@ protected void initializeHeader() { @SuppressWarnings("unchecked") protected void initializeFooter() { this.messageInput = new MessageInput(); - this.messageInput - .getStyle() - .setMaxHeight("80px") - .set("width", "100%") - .setPadding("0 2px"); // Account for border when focused (it will get cropped otherwise) + this.messageInput.getStyle().setMaxHeight("80px").set("width", "100%"); this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener( From cd38f696002a8668d96421de87641295aedccd9b Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Thu, 8 Oct 2026 15:46:32 -0300 Subject: [PATCH 09/10] fix: scroll a long message inside the message input The message input was capped at 80px as a whole, so a message of several lines grew its text area past the input: Lumo cut the text and the send button off, and in Aura they overflowed the field. The cap now applies to the text area, which scrolls once it is reached. --- .../vaadin/addons/chatassistant/ChatAssistant.java | 4 +++- .../resources/frontend/styles/fc-chat-assistant-style.css | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 3c99b46..3716cae 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -131,6 +131,7 @@ public class ChatAssistant extends Div { private static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; private static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; private static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; + private static final String DEFAULT_MESSAGE_INPUT_CLASS = "fc-chat-assistant-message-input"; private static final String RESIZE_INDICATOR_VISIBLE_CLASS = "fc-chat-assistant-resize-indicator-visible"; // Theme-agnostic token fallback chains (Lumo token, then Aura token, then a literal) so the badge // renders correctly under both the Lumo and Aura themes of Vaadin 25. @@ -1212,7 +1213,8 @@ protected void initializeHeader() { @SuppressWarnings("unchecked") protected void initializeFooter() { this.messageInput = new MessageInput(); - this.messageInput.getStyle().setMaxHeight("80px").set("width", "100%"); + this.messageInput.addClassName(DEFAULT_MESSAGE_INPUT_CLASS); + this.messageInput.getStyle().set("width", "100%"); this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener( diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css index ae879c3..881e434 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css @@ -42,6 +42,12 @@ transition: all 0.12s ease-out; } +/* A long message scrolls inside the text area; capping the whole input instead pushed the text + and the send button out of it */ +.fc-chat-assistant-message-input > vaadin-text-area { + max-height: 80px; +} + /* Scoped with the add-on class so this @CssImport does not restyle every popover in the host application. The Vaadin 24 (vaadin-popover-overlay) and Vaadin 25 (vaadin-popover) selectors share one rule. */ From 328a5ac9faa7399359161d0340d789d08c4012a7 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Thu, 8 Oct 2026 15:46:32 -0300 Subject: [PATCH 10/10] fix: use demo colors that also exist in Aura The FAB icons took their color from a Lumo property and turned black in Aura; they now follow the primary button's text color. The box of the FAB in a box demo also falls back to Lumo only when the base border color and radius are missing, as in Vaadin 24, so Aura draws it. --- .../vaadin/addons/chatassistant/ChatAssistantBoxDemo.java | 6 +++--- .../addons/chatassistant/ChatAssistantGenerativeDemo.java | 2 +- .../addons/chatassistant/ChatAssistantLazyLoadingDemo.java | 2 +- .../addons/chatassistant/ChatAssistantMarkdownDemo.java | 2 +- .../vaadin/addons/chatassistant/ChatAssistantModeDemo.java | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java index 2ecc261..3aec361 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java @@ -42,7 +42,7 @@ public class ChatAssistantBoxDemo extends VerticalLayout { public ChatAssistantBoxDemo() { SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); + icon.setColor("currentColor"); // With fabAnchoredToViewport(false) the FAB is positioned relative to its container instead of // the viewport, so it lives inside the box below rather than floating over the whole screen. @@ -77,8 +77,8 @@ public ChatAssistantBoxDemo() { Div box = new Div(chatAssistant); box.getStyle() .set("position", "relative") - .set("border", "2px dashed var(--lumo-contrast-30pct)") - .set("border-radius", "var(--lumo-border-radius-l)") + .set("border", "2px dashed var(--vaadin-border-color, var(--lumo-contrast-30pct))") + .set("border-radius", "var(--vaadin-radius-l, var(--lumo-border-radius-l))") .setWidth("600px") .setHeight("400px"); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java index eade5fe..7cb621e 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -66,7 +66,7 @@ public ChatAssistantGenerativeDemo() { // Create the assistant with all defaults and give it a custom FAB icon. ChatAssistant chatAssistant = new ChatAssistant<>(); SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); + icon.setColor("currentColor"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java index 1864773..133eee9 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java @@ -54,7 +54,7 @@ public ChatAssistantLazyLoadingDemo() { ChatAssistant chatAssistant = new ChatAssistant<>(); chatAssistant.setClassName("small"); SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); + icon.setColor("currentColor"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java index 85ea78c..331b58b 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java @@ -42,7 +42,7 @@ public ChatAssistantMarkdownDemo() { // Pass true to the constructor to render message content as Markdown. ChatAssistant chatAssistant = new ChatAssistant<>(true); SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); + icon.setColor("currentColor"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java index d0b924b..0205c28 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java @@ -41,7 +41,7 @@ public class ChatAssistantModeDemo extends VerticalLayout { public ChatAssistantModeDemo() { SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); + icon.setColor("currentColor"); // Build the assistant with auto-switching enabled: setting a breakpoint makes it switch to // mobile (full-screen dialog) below 768px and back to desktop (anchored popover) above it.