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.
This commit is contained in:
Marco Beretta 2026-07-31 18:58:22 +02:00
parent db34b7163b
commit 8320943ba5
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 42 additions and 1 deletions

View file

@ -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. */}
<div
className={cn(
'w-full',
'w-full bg-presentation [view-transition-name:chat-form]',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>

View file

@ -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;
}

View file

@ -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';

21
client/src/utils/morph.ts Normal file
View file

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