authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-15 00:08:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-15 09:08:33+02:00
log2fdc704bbb2c6b9e0ba01edbc21f3aa2c956d698
tree293e9ff402198af735e85707afb1370d49b95a31
parent3513fa04fbf12c1d4fa2b8d07cfc7f0905714d31
signature Signed by PGP key B5690EEEBB952194

perf(web): duty-cycle status animations and remove fixed noise overlay (#3978)


9 files changed, 96 insertions(+), 29 deletions(-)

apps/web/src/components/ConnectionStatusDot.tsx+1-1
......@@ -17,7 +17,7 @@ export function ConnectionStatusDot({
1717 {pingClassName ? (
1818 <span
1919 className={cn(
20 "absolute inline-flex h-full w-full animate-ping rounded-full",
20 "absolute inline-flex h-full w-full animate-status-ping rounded-full",
2121 pingClassName,
2222 )}
2323 />
apps/web/src/components/Sidebar.tsx+4-2
......@@ -771,7 +771,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
771771 />
772772 }
773773 >
774 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
774 <TerminalIcon
775 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
776 />
775777 </TooltipTrigger>
776778 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
777779 </Tooltip>
......@@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
22262228 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
22272229 <span
22282230 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
2229 projectStatus.pulse ? "animate-pulse" : ""
2231 projectStatus.pulse ? "animate-status-pulse" : ""
22302232 }`}
22312233 />
22322234 </span>
apps/web/src/components/ThreadStatusIndicators.tsx+5-3
......@@ -149,7 +149,7 @@ export function ThreadStatusLabel({
149149 >
150150 <span
151151 className={`size-[9px] rounded-full ${status.dotClass} ${
152 status.pulse ? "animate-pulse" : ""
152 status.pulse ? "animate-status-pulse" : ""
153153 }`}
154154 />
155155 </TooltipTrigger>
......@@ -170,7 +170,7 @@ export function ThreadStatusLabel({
170170 >
171171 <span
172172 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
173 status.pulse ? "animate-pulse" : ""
173 status.pulse ? "animate-status-pulse" : ""
174174 }`}
175175 />
176176 <span className="hidden md:inline">{status.label}</span>
......@@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
276276 />
277277 }
278278 >
279 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
279 <TerminalIcon
280 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
281 />
280282 </TooltipTrigger>
281283 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
282284 </Tooltip>
apps/web/src/components/chat/MessagesTimeline.tsx+3-3
......@@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
10551055 <div className="py-0.5 pl-1.5">
10561056 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">
10571057 <span className="inline-flex items-center gap-[3px]">
1058 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
1059 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
1060 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
1058 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
1059 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
1060 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
10611061 </span>
10621062 <span>
10631063 {row.createdAt ? (
apps/web/src/components/preview/AgentBrowserCursor.tsx+1-1
......@@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: {
6666 {event.phase === "click" ? (
6767 <span
6868 key={event.sequence}
69 className="absolute left-0.5 top-0.5 size-4 animate-ping rounded-full bg-primary/25 motion-reduce:animate-none"
69 className="absolute left-0.5 top-0.5 size-4 animate-status-ping rounded-full bg-primary/25 motion-reduce:animate-none"
7070 />
7171 ) : null}
7272 <MousePointer2
apps/web/src/components/preview/PreviewChromeRow.tsx+1-1
......@@ -266,7 +266,7 @@ export function PreviewChromeRow({
266266 >
267267 <Camera className={cn(recording && "text-destructive")} />
268268 {recording ? (
269 <span className="absolute right-0.5 top-0.5 size-1.5 animate-pulse rounded-full bg-destructive" />
269 <span className="absolute right-0.5 top-0.5 size-1.5 animate-status-pulse rounded-full bg-destructive" />
270270 ) : null}
271271 </TooltipTrigger>
272272 <TooltipPopup>
apps/web/src/components/preview/PreviewLocalServerCard.tsx+1-1
......@@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string {
3636function PulsingDot() {
3737 return (
3838 <span aria-label="Listening" className="relative inline-flex size-2 shrink-0">
39 <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60" />
39 <span className="absolute inset-0 animate-status-ping rounded-full bg-success opacity-60" />
4040 <span className="relative inline-flex size-2 rounded-full bg-success" />
4141 </span>
4242 );
apps/web/src/components/ui/sidebar.tsx+4-4
......@@ -216,7 +216,7 @@ function Sidebar({
216216 <SidebarInstanceContext value={instanceContextValue}>
217217 <div
218218 className={cn(
219 "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
219 "flex h-full w-(--sidebar-width) flex-col bg-sidebar surface-grain text-sidebar-foreground",
220220 className,
221221 )}
222222 data-slot="sidebar"
......@@ -234,7 +234,7 @@ function Sidebar({
234234 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
235235 <SheetPopup
236236 className={cn(
237 "w-(--sidebar-width) max-w-none bg-sidebar p-0 text-sidebar-foreground",
237 "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground",
238238 className,
239239 )}
240240 data-mobile="true"
......@@ -304,7 +304,7 @@ function Sidebar({
304304 {...props}
305305 >
306306 <div
307 className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5"
307 className="flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5"
308308 data-sidebar="sidebar"
309309 data-slot="sidebar-inner"
310310 >
......@@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
622622 return (
623623 <main
624624 className={cn(
625 "relative flex min-w-0 w-full flex-1 flex-col bg-background",
625 "relative flex min-w-0 w-full flex-1 flex-col bg-background surface-grain",
626626 "md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm/5",
627627 className,
628628 )}
apps/web/src/index.css+76-13
......@@ -36,6 +36,11 @@
3636
3737@theme inline {
3838 --animate-skeleton: skeleton 2s -1s infinite linear;
39 /* Duty-cycled indicator animations: long opacity holds with short stepped
40 ramps, so the compositor only produces frames while the value changes
41 (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */
42 --animate-status-pulse: status-pulse 2s infinite;
43 --animate-status-ping: status-ping 2s infinite;
3944 --font-sans:
4045 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
4146 sans-serif;
......@@ -78,6 +83,36 @@
7883 background-position: -200% 0;
7984 }
8085 }
86 @keyframes status-pulse {
87 0%,
88 40% {
89 opacity: 1;
90 animation-timing-function: steps(6);
91 }
92 50%,
93 90% {
94 opacity: 0.5;
95 animation-timing-function: steps(6);
96 }
97 100% {
98 opacity: 1;
99 }
100 }
101 @keyframes status-ping {
102 /* Burst first (immediate feedback for click ripples), then hold
103 invisible for the rest of the cycle. Mirrors animate-ping's
104 75%-scale start. */
105 0% {
106 opacity: 0.9;
107 scale: 0.75;
108 animation-timing-function: steps(8);
109 }
110 40%,
111 100% {
112 opacity: 0;
113 scale: 2;
114 }
115 }
81116}
82117
83118@layer base {
......@@ -151,11 +186,18 @@
151186 padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem);
152187 }
153188
154 .chat-composer-glass,
155 .chat-composer-lower-chrome {
189 .chat-composer-glass {
156190 background: color-mix(in srgb, var(--card) 20%, transparent);
157191 }
158192
193 /* Mix from --background (not --card): the lower chrome is a full-width
194 strip over the main view, and a card-tinted wash reads as a lighter
195 seam against it when no content is scrolled underneath. The backdrop
196 blur, not the tint, carries the frosted effect. */
197 .chat-composer-lower-chrome {
198 background: color-mix(in srgb, var(--background) 20%, transparent);
199 }
200
159201 .chat-composer-glass {
160202 box-shadow:
161203 0 18px 48px -20px rgb(0 0 0 / 28%),
......@@ -167,11 +209,14 @@
167209 backdrop-filter: blur(16px);
168210 }
169211
170 .dark .chat-composer-glass,
171 .dark .chat-composer-lower-chrome {
212 .dark .chat-composer-glass {
172213 background: color-mix(in srgb, var(--card) 45%, transparent);
173214 }
174215
216 .dark .chat-composer-lower-chrome {
217 background: color-mix(in srgb, var(--background) 45%, transparent);
218 }
219
175220 .dark .chat-composer-glass {
176221 box-shadow:
177222 0 18px 48px -20px rgb(0 0 0 / 60%),
......@@ -192,10 +237,13 @@
192237 }
193238
194239 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
195 .chat-composer-glass,
196 .chat-composer-lower-chrome {
240 .chat-composer-glass {
197241 background: var(--card);
198242 }
243
244 .chat-composer-lower-chrome {
245 background: var(--background);
246 }
199247 }
200248}
201249
......@@ -215,6 +263,15 @@
215263 padding-right: max(env(safe-area-inset-right), 0px);
216264}
217265
266/* Grain texture for chrome surfaces that float above the body (see the
267 --surface-grain note). Layered over the element's background-color, so it
268 composes with bg-* utilities. */
269@utility surface-grain {
270 background-image: var(--surface-grain);
271 background-repeat: repeat;
272 background-size: 256px 256px;
273}
274
218275/* Suppress all transitions during theme changes */
219276.no-transitions,
220277.no-transitions *,
......@@ -320,13 +377,19 @@ body {
320377 padding-top: max(env(safe-area-inset-top), 0px);
321378}
322379
323body::after {
324 content: "";
325 position: fixed;
326 inset: 0;
327 pointer-events: none;
328 opacity: 0.035;
329 background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
380/* App-chrome grain. Baked into each surface's own background (behind
381 content) rather than a fixed overlay on top: a full-viewport overlay
382 forces the compositor to re-blend every frame any animation produces,
383 which multiplied idle GPU cost. The overlay's 0.035 opacity lives in the
384 SVG rect instead. Surfaces that float over the body (e.g. the inset main
385 card) must opt in via the surface-grain utility, or they lose the
386 grain's subtle brightening and stand out against the body. */
387:root {
388 --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
389}
390
391body {
392 background-image: var(--surface-grain);
330393 background-repeat: repeat;
331394 background-size: 256px 256px;
332395}