feat(chat): soften the committed-block reveal into a materialize cascade

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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 23:08:36 +03:00
parent d71bcb5025
commit 5e37a436c1
2 changed files with 21 additions and 5 deletions
@@ -843,6 +843,7 @@ const useMorphdomMarkdown = ({
// Reconcile per block: only re-morph blocks whose content changed, leaving // Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded // stable leading blocks untouched. Keeps per-stream-step DOM work bounded
// to the trailing (growing) block instead of the whole message. // to the trailing (growing) block instead of the whole message.
let enteredThisPass = 0;
blocks.forEach((block, index) => { blocks.forEach((block, index) => {
let el = existing[index]; let el = existing[index];
let isNewBlock = false; let isNewBlock = false;
@@ -863,9 +864,16 @@ const useMorphdomMarkdown = ({
// goes on the block's children — the wrapper is display:contents // goes on the block's children — the wrapper is display:contents
// and cannot animate — and the transform never changes layout, so // and cannot animate — and the transform never changes layout, so
// row measurement stays exact. Skipped for the first block so a // 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)) { for (const child of Array.from(temp.children)) {
child.classList.add('oc-md-block-enter'); 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); const hadMermaidBlock = shouldRefreshMermaidViewers(el);
+12 -4
View File
@@ -1378,21 +1378,29 @@ html:not(.dark) .chat-scroll {
} }
} }
/* A block committed mid-stream enters with a short fade-and-rise; the /* A block committed mid-stream materializes: fade, a gentle rise, and a
transform is paint-only, so virtualized row measurement is unaffected. */ 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 { @keyframes oc-md-block-enter {
from { from {
opacity: 0; opacity: 0;
transform: translateY(4px); transform: translateY(10px);
filter: blur(2px);
}
60% {
filter: blur(0);
} }
to { to {
opacity: 1; opacity: 1;
transform: none; transform: none;
filter: none;
} }
} }
.oc-md-block-enter { .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) { @media (prefers-reduced-motion: reduce) {