🫧 refactor: Fading Pre-Response Dot, Legacy Cursor CSS Removed (#14772)

* 🫧 refactor: Fading Pre-Response Dot, Legacy Cursor CSS Removed

The pre-response dot keeps its classic 12px shape but now enters on the
streamed-text fade curve (lc-fade-in) and breathes on opacity, replacing
the legacy pulseSize scale throb that read as jittery. One dot for all
users — the interim result-thinking-fade variant and its per-component
class wiring are removed, and the dot goes static under
prefers-reduced-motion.

Clears out the legacy cursor CSS while here: the scaleX/scale3d mixed
keyframes, -webkit- prefixed animation/transform/backface duplicates,
translateZ and will-change layer hacks, subpixel font-smoothing, the
duplicate #0d0d0d fallback, the unused .blink utility, and all three
duplicate @keyframes blink definitions (the auth logo uses Tailwind's
logo-blink; nothing references these).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 📝 docs: Explain the Pre-Response Dot's Custom-CSS Exception

Per AGENTS.md, custom CSS needs a stated reason a shared primitive cannot
express the requirement: the dot is a pseudo-element gated on an ancestor's
`.submitting` state plus its own `:empty`, kept in CSS so it costs no React
renders per streamed token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Danny Avila 2026-08-12 22:29:05 -04:00 committed by GitHub
parent 9980b6221f
commit 8f1f43f33e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 38 additions and 121 deletions

View file

@ -6,7 +6,7 @@ import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import { FADE_HYDRATION_THRESHOLD } from './animate';
import { useMessageContext } from '~/Providers';
import MarkdownBlocks from './MarkdownBlocks';
import { preprocessLaTeX, cn } from '~/utils';
import { preprocessLaTeX } from '~/utils';
import store from '~/store';
type TContentProps = {
@ -50,12 +50,7 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont
return (
<div className="absolute">
<p className="relative">
<span
className={cn(
isLatestMessage && 'result-thinking',
isLatestMessage && smoothStreaming && 'result-thinking-fade',
)}
/>
<span className={isLatestMessage ? 'result-thinking' : ''} />
</p>
</div>
);

View file

@ -27,21 +27,17 @@ const parseThinkingContent = (text: string) => {
};
};
const LoadingFallback = () => {
const smoothStreaming = useSmoothStreaming();
return (
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<p className="submitting relative">
<span className={cn('result-thinking', smoothStreaming && 'result-thinking-fade')} />
</p>
</div>
const LoadingFallback = () => (
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<p className="submitting relative">
<span className="result-thinking" />
</p>
</div>
</div>
);
};
</div>
);
const ErrorBox = ({
children,

View file

@ -1,17 +1,13 @@
import { memo } from 'react';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { cn } from '~/utils';
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
const EmptyTextPart = memo(() => {
const smoothStreaming = useSmoothStreaming();
return (
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<p className="submitting relative">
<span className={cn('result-thinking', smoothStreaming && 'result-thinking-fade')} />
<span className="result-thinking" />
</p>
</div>
</div>

View file

@ -640,39 +640,6 @@ pre {
transform: scale(0.95);
}
.blink {
animation: blink 1s linear infinite;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes blink {
0% {
opacity: 1;
}
79% {
opacity: 1;
}
80% {
opacity: 0;
}
99% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.prose {
color: var(--tw-prose-body);
max-width: 65ch;
@ -1642,16 +1609,6 @@ html {
text-decoration-line: underline;
text-underline-offset: 2px;
}
@-webkit-keyframes blink {
to {
visibility: hidden;
}
}
@keyframes blink {
to {
visibility: hidden;
}
}
.hidden-visibility {
visibility: hidden;
}
@ -1719,49 +1676,46 @@ html {
}
}
@-webkit-keyframes pulseSize {
/* Pre-response dot: enters on the streamed-text fade curve, then breathes
on opacity replaces the legacy scale throb so waiting and text arrival
share one motion language.
Custom CSS exception: this indicator is a `::after` pseudo-element on the
`.result-thinking` hook, shown only while an ancestor carries `.submitting`
and the hook itself is `:empty`. No `@librechat/client` primitive can
express "render only while an ancestor is in state X and I have no
children" a component would have to own that state, which the streaming
markdown path deliberately keeps in the DOM so the dot costs no React
renders per token. Scoped to the one selector, colored from
`--text-primary` so both themes follow, and disabled under
prefers-reduced-motion. */
@keyframes lc-dot-breathe {
0%,
to {
-webkit-transform: scaleX(1);
transform: scaleX(1);
100% {
opacity: 1;
}
50% {
-webkit-transform: scale3d(1.25, 1.25, 1);
transform: scale3d(1.25, 1.25, 1);
}
}
@keyframes pulseSize {
0%,
to {
-webkit-transform: scaleX(1);
transform: scaleX(1);
}
50% {
-webkit-transform: scale3d(1.25, 1.25, 1);
transform: scale3d(1.25, 1.25, 1);
opacity: 0.3;
}
}
.submitting .result-thinking:empty:last-child:after {
-webkit-font-smoothing: subpixel-antialiased;
-webkit-animation: pulseSize 1.25s ease-in-out infinite;
animation: pulseSize 1.25s ease-in-out infinite;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
background-color: #0d0d0d;
animation:
lc-fade-in 250ms ease-out both,
lc-dot-breathe 1.5s ease-in-out 250ms infinite;
background-color: rgb(var(--text-primary));
border-radius: 50%;
box-sizing: border-box;
content: ' ';
display: block;
height: 12px;
position: absolute;
top: -11px;
-webkit-transform: translateZ(0);
transform: translateZ(0);
-webkit-transform-origin: center;
transform-origin: center;
width: 12px;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.submitting .result-thinking:empty:last-child:after {
animation: none;
}
}
.shadow-stroke {
@ -1930,30 +1884,6 @@ html {
}
}
/* Pre-first-token dot, restyled to match the word fade: it fades in on the
same curve rather than popping, then breathes on opacity instead of the
classic size throb, so "run starting" and "text arriving" read as one
system. Only applied while the fade is active; without it the dot keeps
its original pulseSize behavior. */
@keyframes lc-dot-breathe {
0%,
100% {
opacity: 0.35;
transform: translateZ(0) scale(0.85);
}
50% {
opacity: 1;
transform: translateZ(0) scale(1);
}
}
.submitting .result-thinking.result-thinking-fade:empty:last-child:after {
animation:
lc-fade-in 250ms ease-out both,
lc-dot-breathe 1.6s ease-in-out 250ms infinite;
will-change: opacity, transform;
}
.webkit-dark-styles,
.webkit-dark-styles:focus {
background-clip: content-box;