From 5e37a436c1d677c634c7db6cf531671c7acd9ddd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 23:08:36 +0300 Subject: [PATCH] feat(chat): soften the committed-block reveal into a materialize cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 140ms fade read as a pop next to the scroll glide. The reveal now runs 320ms on an ease-out-quint curve with a 10px rise and a touch of blur resolving — paint-only properties throughout, so row measurement is untouched — and blocks committed in the same tick cascade with a 55ms stagger instead of appearing together. Reduced motion still disables the whole thing. --- .../src/components/chat/MarkdownRendererImpl.tsx | 10 +++++++++- packages/ui/src/index.css | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 943e91c6..7a43ead2 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -843,6 +843,7 @@ const useMorphdomMarkdown = ({ // Reconcile per block: only re-morph blocks whose content changed, leaving // stable leading blocks untouched. Keeps per-stream-step DOM work bounded // to the trailing (growing) block instead of the whole message. + let enteredThisPass = 0; blocks.forEach((block, index) => { let el = existing[index]; let isNewBlock = false; @@ -863,9 +864,16 @@ const useMorphdomMarkdown = ({ // goes on the block's children — the wrapper is display:contents // and cannot animate — and the transform never changes layout, so // row measurement stays exact. Skipped for the first block so a - // full initial render does not shimmer. + // full initial render does not shimmer. Several blocks committed + // in one tick cascade with a small stagger instead of popping in + // together. + const delayMs = Math.min(enteredThisPass, 4) * 55; + enteredThisPass += 1; for (const child of Array.from(temp.children)) { child.classList.add('oc-md-block-enter'); + if (delayMs > 0 && child instanceof HTMLElement) { + child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`); + } } } const hadMermaidBlock = shouldRefreshMermaidViewers(el); diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 4b631c6b..a9f2fdc6 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1378,21 +1378,29 @@ html:not(.dark) .chat-scroll { } } -/* A block committed mid-stream enters with a short fade-and-rise; the - transform is paint-only, so virtualized row measurement is unaffected. */ +/* A block committed mid-stream materializes: fade, a gentle rise, and a + touch of blur resolving. Transform and filter are paint-only, so + virtualized row measurement is unaffected. Blocks committed in the same + tick cascade via --oc-md-enter-delay set inline by the renderer. */ @keyframes oc-md-block-enter { from { opacity: 0; - transform: translateY(4px); + transform: translateY(10px); + filter: blur(2px); + } + 60% { + filter: blur(0); } to { opacity: 1; transform: none; + filter: none; } } .oc-md-block-enter { - animation: oc-md-block-enter 140ms ease-out both; + animation: oc-md-block-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: var(--oc-md-enter-delay, 0ms); } @media (prefers-reduced-motion: reduce) {