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({...@@ -17,7 +17,7 @@ export function ConnectionStatusDot({
17 {pingClassName ? (17 {pingClassName ? (
18 <span18 <span
19 className={cn(19 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",
21 pingClassName,21 pingClassName,
22 )}22 )}
23 />23 />
apps/web/src/components/Sidebar.tsx+4-2
...@@ -771,7 +771,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -771,7 +771,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
771 />771 />
772 }772 }
773 >773 >
774 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />774 <TerminalIcon
775 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
776 />
775 </TooltipTrigger>777 </TooltipTrigger>
776 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>778 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
777 </Tooltip>779 </Tooltip>
...@@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2226 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">2228 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
2227 <span2229 <span
2228 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${2230 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
2229 projectStatus.pulse ? "animate-pulse" : ""2231 projectStatus.pulse ? "animate-status-pulse" : ""
2230 }`}2232 }`}
2231 />2233 />
2232 </span>2234 </span>
apps/web/src/components/ThreadStatusIndicators.tsx+5-3
...@@ -149,7 +149,7 @@ export function ThreadStatusLabel({...@@ -149,7 +149,7 @@ export function ThreadStatusLabel({
149 >149 >
150 <span150 <span
151 className={`size-[9px] rounded-full ${status.dotClass} ${151 className={`size-[9px] rounded-full ${status.dotClass} ${
152 status.pulse ? "animate-pulse" : ""152 status.pulse ? "animate-status-pulse" : ""
153 }`}153 }`}
154 />154 />
155 </TooltipTrigger>155 </TooltipTrigger>
...@@ -170,7 +170,7 @@ export function ThreadStatusLabel({...@@ -170,7 +170,7 @@ export function ThreadStatusLabel({
170 >170 >
171 <span171 <span
172 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${172 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
173 status.pulse ? "animate-pulse" : ""173 status.pulse ? "animate-status-pulse" : ""
174 }`}174 }`}
175 />175 />
176 <span className="hidden md:inline">{status.label}</span>176 <span className="hidden md:inline">{status.label}</span>
...@@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma...@@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
276 />276 />
277 }277 }
278 >278 >
279 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />279 <TerminalIcon
280 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
281 />
280 </TooltipTrigger>282 </TooltipTrigger>
281 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>283 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
282 </Tooltip>284 </Tooltip>
apps/web/src/components/chat/MessagesTimeline.tsx+3-3
...@@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1055 <div className="py-0.5 pl-1.5">1055 <div className="py-0.5 pl-1.5">
1056 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">1056 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">
1057 <span className="inline-flex items-center gap-[3px]">1057 <span className="inline-flex items-center gap-[3px]">
1058 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />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-pulse [animation-delay:200ms]" />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-pulse [animation-delay:400ms]" />1060 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
1061 </span>1061 </span>
1062 <span>1062 <span>
1063 {row.createdAt ? (1063 {row.createdAt ? (
apps/web/src/components/preview/AgentBrowserCursor.tsx+1-1
...@@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: {...@@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: {
66 {event.phase === "click" ? (66 {event.phase === "click" ? (
67 <span67 <span
68 key={event.sequence}68 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"
70 />70 />
71 ) : null}71 ) : null}
72 <MousePointer272 <MousePointer2
apps/web/src/components/preview/PreviewChromeRow.tsx+1-1
...@@ -266,7 +266,7 @@ export function PreviewChromeRow({...@@ -266,7 +266,7 @@ export function PreviewChromeRow({
266 >266 >
267 <Camera className={cn(recording && "text-destructive")} />267 <Camera className={cn(recording && "text-destructive")} />
268 {recording ? (268 {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" />
270 ) : null}270 ) : null}
271 </TooltipTrigger>271 </TooltipTrigger>
272 <TooltipPopup>272 <TooltipPopup>
apps/web/src/components/preview/PreviewLocalServerCard.tsx+1-1
...@@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string {...@@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string {
36function PulsingDot() {36function PulsingDot() {
37 return (37 return (
38 <span aria-label="Listening" className="relative inline-flex size-2 shrink-0">38 <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" />
40 <span className="relative inline-flex size-2 rounded-full bg-success" />40 <span className="relative inline-flex size-2 rounded-full bg-success" />
41 </span>41 </span>
42 );42 );
apps/web/src/components/ui/sidebar.tsx+4-4
...@@ -216,7 +216,7 @@ function Sidebar({...@@ -216,7 +216,7 @@ function Sidebar({
216 <SidebarInstanceContext value={instanceContextValue}>216 <SidebarInstanceContext value={instanceContextValue}>
217 <div217 <div
218 className={cn(218 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",
220 className,220 className,
221 )}221 )}
222 data-slot="sidebar"222 data-slot="sidebar"
...@@ -234,7 +234,7 @@ function Sidebar({...@@ -234,7 +234,7 @@ function Sidebar({
234 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>234 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
235 <SheetPopup235 <SheetPopup
236 className={cn(236 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",
238 className,238 className,
239 )}239 )}
240 data-mobile="true"240 data-mobile="true"
...@@ -304,7 +304,7 @@ function Sidebar({...@@ -304,7 +304,7 @@ function Sidebar({
304 {...props}304 {...props}
305 >305 >
306 <div306 <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"
308 data-sidebar="sidebar"308 data-sidebar="sidebar"
309 data-slot="sidebar-inner"309 data-slot="sidebar-inner"
310 >310 >
...@@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {...@@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
622 return (622 return (
623 <main623 <main
624 className={cn(624 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",
626 "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",626 "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",
627 className,627 className,
628 )}628 )}
apps/web/src/index.css+76-13
...@@ -36,6 +36,11 @@...@@ -36,6 +36,11 @@
3636
37@theme inline {37@theme inline {
38 --animate-skeleton: skeleton 2s -1s infinite linear;38 --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;
39 --font-sans:44 --font-sans:
40 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,45 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
41 sans-serif;46 sans-serif;
...@@ -78,6 +83,36 @@...@@ -78,6 +83,36 @@
78 background-position: -200% 0;83 background-position: -200% 0;
79 }84 }
80 }85 }
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 }
81}116}
82117
83@layer base {118@layer base {
...@@ -151,11 +186,18 @@...@@ -151,11 +186,18 @@
151 padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem);186 padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem);
152 }187 }
153188
154 .chat-composer-glass,189 .chat-composer-glass {
155 .chat-composer-lower-chrome {
156 background: color-mix(in srgb, var(--card) 20%, transparent);190 background: color-mix(in srgb, var(--card) 20%, transparent);
157 }191 }
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
159 .chat-composer-glass {201 .chat-composer-glass {
160 box-shadow:202 box-shadow:
161 0 18px 48px -20px rgb(0 0 0 / 28%),203 0 18px 48px -20px rgb(0 0 0 / 28%),
...@@ -167,11 +209,14 @@...@@ -167,11 +209,14 @@
167 backdrop-filter: blur(16px);209 backdrop-filter: blur(16px);
168 }210 }
169211
170 .dark .chat-composer-glass,212 .dark .chat-composer-glass {
171 .dark .chat-composer-lower-chrome {
172 background: color-mix(in srgb, var(--card) 45%, transparent);213 background: color-mix(in srgb, var(--card) 45%, transparent);
173 }214 }
174215
216 .dark .chat-composer-lower-chrome {
217 background: color-mix(in srgb, var(--background) 45%, transparent);
218 }
219
175 .dark .chat-composer-glass {220 .dark .chat-composer-glass {
176 box-shadow:221 box-shadow:
177 0 18px 48px -20px rgb(0 0 0 / 60%),222 0 18px 48px -20px rgb(0 0 0 / 60%),
...@@ -192,10 +237,13 @@...@@ -192,10 +237,13 @@
192 }237 }
193238
194 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {239 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
195 .chat-composer-glass,240 .chat-composer-glass {
196 .chat-composer-lower-chrome {
197 background: var(--card);241 background: var(--card);
198 }242 }
243
244 .chat-composer-lower-chrome {
245 background: var(--background);
246 }
199 }247 }
200}248}
201249
...@@ -215,6 +263,15 @@...@@ -215,6 +263,15 @@
215 padding-right: max(env(safe-area-inset-right), 0px);263 padding-right: max(env(safe-area-inset-right), 0px);
216}264}
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
218/* Suppress all transitions during theme changes */275/* Suppress all transitions during theme changes */
219.no-transitions,276.no-transitions,
220.no-transitions *,277.no-transitions *,
...@@ -320,13 +377,19 @@ body {...@@ -320,13 +377,19 @@ body {
320 padding-top: max(env(safe-area-inset-top), 0px);377 padding-top: max(env(safe-area-inset-top), 0px);
321}378}
322379
323body::after {380/* App-chrome grain. Baked into each surface's own background (behind
324 content: "";381 content) rather than a fixed overlay on top: a full-viewport overlay
325 position: fixed;382 forces the compositor to re-blend every frame any animation produces,
326 inset: 0;383 which multiplied idle GPU cost. The overlay's 0.035 opacity lives in the
327 pointer-events: none;384 SVG rect instead. Surfaces that float over the body (e.g. the inset main
328 opacity: 0.035;385 card) must opt in via the surface-grain utility, or they lose the
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");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);
330 background-repeat: repeat;393 background-repeat: repeat;
331 background-size: 256px 256px;394 background-size: 256px 256px;
332}395}