From 8320943ba53263fb4bd2976cf2433948fdf78f1d Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:58:22 +0200
Subject: [PATCH] feat(client): add view-transition morph helper for composer
surfaces
Wrap a state update in startViewTransition (with a synchronous flush) so
elements sharing a view-transition-name animate their position, size and
shape between two states instead of swapping.
The composer band gets its own named, opaque snapshot group stacked above
the morphing element: without it a surface travelling between the thread
and the composer paints over the input and bleeds through the strip below
it. Falls back to a plain update where the API is unavailable and for
reduced-motion users.
---
client/src/components/Chat/ChatView.tsx | 7 ++++++-
client/src/style.css | 14 ++++++++++++++
client/src/utils/index.ts | 1 +
client/src/utils/morph.ts | 21 +++++++++++++++++++++
4 files changed, 42 insertions(+), 1 deletion(-)
create mode 100644 client/src/utils/morph.ts
diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx
index d37ca81042..88300ca491 100644
--- a/client/src/components/Chat/ChatView.tsx
+++ b/client/src/components/Chat/ChatView.tsx
@@ -126,9 +126,14 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
)}
>
{content}
+ {/* Named + opaque so a view transition (the ask_user_question
+ popover ⇄ chat-card morph) paints the whole composer band
+ over the travelling card instead of letting it show
+ through below the composer. The background matches the
+ page, so normal rendering is unchanged. */}
diff --git a/client/src/style.css b/client/src/style.css
index 91b4a47561..917a802209 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -3027,3 +3027,17 @@ html {
.sharepoint-picker-bg {
background-color: #f5f5f5;
}
+
+/* ask_user_question popover <-> chat-card morph (view transition) */
+::view-transition-group(ask-question),
+::view-transition-old(ask-question),
+::view-transition-new(ask-question) {
+ animation-duration: 280ms;
+ animation-timing-function: cubic-bezier(0.32, 0.72, 0.22, 1);
+}
+
+/* The composer keeps its own snapshot group stacked above the morphing
+ question card, so the card travels behind the ChatForm, not over it. */
+::view-transition-group(chat-form) {
+ z-index: 2;
+}
diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts
index 0078d1666a..58e4674984 100644
--- a/client/src/utils/index.ts
+++ b/client/src/utils/index.ts
@@ -11,6 +11,7 @@ export * from './share';
export * from './files';
export * from './latex';
export * from './tilde';
+export * from './morph';
export * from './forms';
export * from './roles';
export * from './errors';
diff --git a/client/src/utils/morph.ts b/client/src/utils/morph.ts
new file mode 100644
index 0000000000..8ffa2b4f08
--- /dev/null
+++ b/client/src/utils/morph.ts
@@ -0,0 +1,21 @@
+import { flushSync } from 'react-dom';
+
+/**
+ * Runs a state update inside a same-document view transition when supported,
+ * so elements sharing a `view-transition-name` morph (position, size, shape)
+ * between the two states instead of swapping. Falls back to applying the
+ * update directly. Call from user-event handlers only: the update is flushed
+ * synchronously so the browser can snapshot the old and new layouts.
+ */
+export function morphTransition(update: () => void): void {
+ if (
+ typeof document.startViewTransition !== 'function' ||
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ ) {
+ update();
+ return;
+ }
+ document.startViewTransition(() => {
+ flushSync(update);
+ });
+}