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); + }); +}