From 61dd0448aee9d6a4dff7895366f6c0b99b9a6cd5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 14 Jul 2026 15:19:22 -0700 Subject: [PATCH 1/3] perf(web): duty-cycle status animations and un-fix noise overlay Infinite animate-pulse/animate-ping indicators forced the compositor to produce a full frame every vsync (120fps on high-refresh displays), and the fixed full-viewport noise overlay made each of those frames expensive by requiring a re-blend over the whole window. Together these pegged the GPU at 30%+ while the app was visually idle. - Replace animate-pulse/animate-ping at infinite indicator sites with duty-cycled status-pulse/status-ping keyframes: long flat opacity holds with short stepped ramps, so frames are only produced while the animated value changes (~20% of the cycle). - Move the feTurbulence grain from a fixed body::after overlay into the body background (opacity baked into the SVG), taking it out of the per-frame blend path. Measured on a 120Hz display: idle GPU utilization drops from 30%+ to ~4% with indicators active. Co-Authored-By: Claude Fable 5 --- .../src/components/ConnectionStatusDot.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 6 ++- .../src/components/ThreadStatusIndicators.tsx | 8 ++-- .../src/components/chat/MessagesTimeline.tsx | 6 +-- .../components/preview/AgentBrowserCursor.tsx | 2 +- .../components/preview/PreviewChromeRow.tsx | 2 +- .../preview/PreviewLocalServerCard.tsx | 2 +- apps/web/src/index.css | 48 ++++++++++++++++--- 8 files changed, 57 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index b12acf1ba40..0c22f1702e5 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -17,7 +17,7 @@ export function ConnectionStatusDot({ {pingClassName ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..bb6b2752a17 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -771,7 +771,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr /> } > - + {terminalStatus.label} @@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 3e85920d190..55f9fbfdc04 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -149,7 +149,7 @@ export function ThreadStatusLabel({ > @@ -170,7 +170,7 @@ export function ThreadStatusLabel({ > {status.label} @@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {terminalStatus.label} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..61d7855844d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract
- - - + + + {row.createdAt ? ( diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index ca6c2ff7235..bc89daee459 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: { {event.phase === "click" ? ( ) : null} {recording ? ( - + ) : null} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index 54a020cbf65..c7b08ad2893 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string { function PulsingDot() { return ( - + ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3ae42a3e61c..edf1d51c838 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -36,6 +36,11 @@ @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; + /* Duty-cycled indicator animations: long opacity holds with short stepped + ramps, so the compositor only produces frames while the value changes + (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */ + --animate-status-pulse: status-pulse 2s infinite; + --animate-status-ping: status-ping 2s infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -78,6 +83,36 @@ background-position: -200% 0; } } + @keyframes status-pulse { + 0%, + 40% { + opacity: 1; + animation-timing-function: steps(6); + } + 50%, + 90% { + opacity: 0.5; + animation-timing-function: steps(6); + } + 100% { + opacity: 1; + } + } + @keyframes status-ping { + /* Burst first (immediate feedback for click ripples), then hold + invisible for the rest of the cycle. Mirrors animate-ping's + 75%-scale start. */ + 0% { + opacity: 0.9; + scale: 0.75; + animation-timing-function: steps(8); + } + 40%, + 100% { + opacity: 0; + scale: 2; + } + } } @layer base { @@ -320,13 +355,12 @@ body { padding-top: max(env(safe-area-inset-top), 0px); } -body::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - opacity: 0.035; - 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"); +/* App-chrome grain. Baked into the body background (behind content) rather + than a fixed overlay on top: a full-viewport overlay forces the compositor + to re-blend every frame any animation produces, which multiplied idle GPU + cost. The overlay's 0.035 opacity lives in the SVG rect instead. */ +body { + 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)' opacity='0.035'/%3E%3C/svg%3E"); background-repeat: repeat; background-size: 256px 256px; } From baa34bb5af66989def581e731fa549a8216c7e60 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 14 Jul 2026 15:52:18 -0700 Subject: [PATCH 2/3] fix(web): restore grain on floating chrome surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the noise from a fixed overlay to the body background removed it from surfaces that paint their own background above the body — the inset main card and the sidebar. The light-on-dark grain brightened those surfaces by ~3.5%, so its loss made them visibly darker and exposed the m-2 seam between the main card and the body edge. Extract the noise into a --surface-grain variable, add a surface-grain utility layering it over an element's own background color, and apply it to SidebarInset and the sidebar containers. Grain baked into each surface's cached raster keeps the perf win — no per-frame blend. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ui/sidebar.tsx | 8 ++++---- apps/web/src/index.css | 26 +++++++++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 097568f77f0..50fb652a495 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -216,7 +216,7 @@ function Sidebar({
@@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { return (
Date: Tue, 14 Jul 2026 16:02:38 -0700 Subject: [PATCH 3/3] fix(web): tint composer lower chrome from background, not card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lower chrome strip (branch toolbar row) painted a translucent wash of --card over the main view. In dark mode --card is the background lightened 2% toward white, so the strip rendered ~1% lighter than the surface around it — a subtle full-width gray bar above the bottom edge, visible even on prod, most noticeable when no timeline content is scrolled underneath. Mixing from --background instead makes the tint invisible against the main view while the backdrop blur still carries the frosted effect when content scrolls under it. The composer glass keeps its card tint — it reads as a card and is bounded by a border. Co-Authored-By: Claude Fable 5 --- apps/web/src/index.css | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ce1986164c4..5a0d1f394c0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -186,11 +186,18 @@ padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); } - .chat-composer-glass, - .chat-composer-lower-chrome { + .chat-composer-glass { background: color-mix(in srgb, var(--card) 20%, transparent); } + /* Mix from --background (not --card): the lower chrome is a full-width + strip over the main view, and a card-tinted wash reads as a lighter + seam against it when no content is scrolled underneath. The backdrop + blur, not the tint, carries the frosted effect. */ + .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--background) 20%, transparent); + } + .chat-composer-glass { box-shadow: 0 18px 48px -20px rgb(0 0 0 / 28%), @@ -202,11 +209,14 @@ backdrop-filter: blur(16px); } - .dark .chat-composer-glass, - .dark .chat-composer-lower-chrome { + .dark .chat-composer-glass { background: color-mix(in srgb, var(--card) 45%, transparent); } + .dark .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--background) 45%, transparent); + } + .dark .chat-composer-glass { box-shadow: 0 18px 48px -20px rgb(0 0 0 / 60%), @@ -227,10 +237,13 @@ } @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - .chat-composer-glass, - .chat-composer-lower-chrome { + .chat-composer-glass { background: var(--card); } + + .chat-composer-lower-chrome { + background: var(--background); + } } }