History

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

Theo Browne committed 4 months ago

Commit 2fdc7041 parent 3513fa09 files · +96 −29Browse files

Unified · Split

9 files
  1. apps/web/src/components/ConnectionStatusDot.tsx +1 −1
  2. apps/web/src/components/Sidebar.tsx +4 −2
  3. apps/web/src/components/ThreadStatusIndicators.tsx +5 −3
  4. apps/web/src/components/chat/MessagesTimeline.tsx +3 −3
  5. apps/web/src/components/preview/AgentBrowserCursor.tsx +1 −1
  6. apps/web/src/components/preview/PreviewChromeRow.tsx +1 −1
  7. apps/web/src/components/preview/PreviewLocalServerCard.tsx +1 −1
  8. apps/web/src/components/ui/sidebar.tsx +4 −4
  9. apps/web/src/index.css +76 −13
apps/web/src/components/ConnectionStatusDot.tsx+1−1
@@ -17,7 +17,7 @@ export function ConnectionStatusDot({ · 16 unmodified lines
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 · 770 unmodified lines
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 · 1448 unmodified lines
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({ · 148 unmodified lines
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({ · 14 unmodified lines
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 · 99 unmodified lines
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 · 1054 unmodified lines
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: { · 65 unmodified lines
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({ · 265 unmodified lines
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 { · 35 unmodified lines
3636 function 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({ · 215 unmodified lines
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({ · 11 unmodified lines
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({ · 63 unmodified lines
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">) { · 311 unmodified lines
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 @@ · 35 unmodified lines
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 @@ · 36 unmodified lines
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 @@ · 67 unmodified lines
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 @@ · 5 unmodified lines
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 @@ · 14 unmodified lines
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 @@ · 13 unmodified lines
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 { · 99 unmodified lines
320377 padding-top: max(env(safe-area-inset-top), 0px);
321378 }
322379
323−body::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+
391+body {
392+ background-image: var(--surface-grain);
330393 background-repeat: repeat;
331394 background-size: 256px 256px;
332395 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t