From bdd27d28ed5fc315df31d887c6a174f2fc610e22 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Tue, 16 Jun 2026 14:35:09 -0700 Subject: [PATCH] perf(renderer): raise thinking and icon scan animations to 20fps - Bump shared thinkingAnimator timer from 15fps to 20fps (50ms ticks) - Increase provider icon mask-scan steps from 24 to 32 in CSS - Align syncMaskScanPhase comments and test expectations --- .../components/providers/syncMaskScanPhase.ts | 8 ++++---- src/renderer/styles.css | 16 ++++++++-------- src/renderer/thinkingAnimator.test.tsx | 6 +++--- src/renderer/thinkingAnimator.ts | 12 ++++++------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/renderer/components/providers/syncMaskScanPhase.ts b/src/renderer/components/providers/syncMaskScanPhase.ts index 48192892..eadad5b6 100644 --- a/src/renderer/components/providers/syncMaskScanPhase.ts +++ b/src/renderer/components/providers/syncMaskScanPhase.ts @@ -1,18 +1,18 @@ // Phase-locks the working-icon shine sweep across every on-screen instance. // -// The sweep is a `steps()` (≈15fps) transform animation on the +// The sweep is a `steps()` (≈20fps) transform animation on the // `.lightcode-provider-icon__mask-scan::before` pseudo-element (see styles.css). // A `steps()` animation only produces a compositor frame when its value -// actually changes, so a single icon redraws ~15×/s. But a working thread shows +// actually changes, so a single icon redraws ~20×/s. But a working thread shows // its icon in several places at once (sidebar row + recent-threads row + ...), // and each CSS animation starts when its element mounts — so the instances step -// at slightly different instants and the compositor ends up drawing ~15fps × +// at slightly different instants and the compositor ends up drawing ~20fps × // (number of instances). // // Pinning every instance's `startTime` to the document timeline origin makes // them all share one clock: identical duration + identical steps + identical // phase ⇒ they change value at the exact same instants, so the compositor -// coalesces them into a single redraw per step (true ~15fps total, regardless +// coalesces them into a single redraw per step (true ~20fps total, regardless // of how many working icons are visible). const MASK_SCAN_ANIMATION_NAME = "lightcode-provider-icon-mask-scan"; diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 9341c815..72263967 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1361,12 +1361,12 @@ html[data-platform="darwin"] .lightcode-content-over-drag-region--drag { transparent 100% ); transform: translate3d(-33.333%, 0, 0); - /* Throttle the shine to 15fps with steps() rather than advancing every display + /* Throttle the shine to 20fps with steps() rather than advancing every display refresh. The compositor only produces a new frame when the stepped transform - actually changes, so a 120Hz ProMotion panel draws ~15 frames/s here instead - of ~120 — an ~8x cut in GPU compositing for a sweep this subtle and slow. - 24 steps / 1.6s = 15fps. Raise the step count for a smoother sweep. */ - animation: lightcode-provider-icon-mask-scan 1.6s steps(24, jump-end) infinite; + actually changes, so a 120Hz ProMotion panel draws ~20 frames/s here instead + of ~120 — a ~6x cut in GPU compositing for a sweep this subtle and slow. + 32 steps / 1.6s = 20fps. Raise the step count for a smoother sweep. */ + animation: lightcode-provider-icon-mask-scan 1.6s steps(32, jump-end) infinite; will-change: transform; } @@ -1528,7 +1528,7 @@ html[data-app-unfocused] .lightcode-provider-icon--finished { filter: drop-shadow(0 0 0.1rem color-mix(in oklab, currentColor 32%, transparent)); } -/* The per-path "firing" opacities are driven from a shared 15fps timer +/* The per-path "firing" opacities are driven from a shared 20fps timer (thinkingAnimator.ts), NOT a CSS animation: a continuously-active opacity animation on SVG paths can't composite, so it would force a main-thread style recalc + frame on every display refresh (~120fps). 0.55 is the static @@ -1541,8 +1541,8 @@ html[data-app-unfocused] .lightcode-provider-icon--finished { the brain. The highlight sweeps via `background-position` — a main-thread *paint* property that can't composite, so an always-active CSS animation here repaints AND recalcs style every display refresh (~120fps). Instead the sweep - is driven from a shared 15fps timer (thinkingAnimator.ts) that writes - `background-position-x` only ~15×/s, letting the frame pipeline idle between + is driven from a shared 20fps timer (thinkingAnimator.ts) that writes + `background-position-x` only ~20×/s, letting the frame pipeline idle between ticks. `contain: paint` keeps each repaint scoped to the label box; the `background-position: 0% 0` below is the static fallback before the timer runs. */ diff --git a/src/renderer/thinkingAnimator.test.tsx b/src/renderer/thinkingAnimator.test.tsx index 301d2a38..c3dcf671 100644 --- a/src/renderer/thinkingAnimator.test.tsx +++ b/src/renderer/thinkingAnimator.test.tsx @@ -40,9 +40,9 @@ describe("thinkingAnimator", () => { expect(el.style.backgroundPositionX).toMatch(/%$/); expect(parseFloat(el.style.backgroundPositionX)).toBeCloseTo(0, 5); - // One 67ms tick → now=67 → -200 * (67/2200) ≈ -6.1%. - vi.advanceTimersByTime(67); - expect(parseFloat(el.style.backgroundPositionX)).toBeCloseTo(-6.1, 1); + // One 50ms tick → now=50 → -200 * (50/2200) ≈ -4.5%. + vi.advanceTimersByTime(50); + expect(parseFloat(el.style.backgroundPositionX)).toBeCloseTo(-4.5, 1); }); it("stops writing once the element unmounts", () => { diff --git a/src/renderer/thinkingAnimator.ts b/src/renderer/thinkingAnimator.ts index b828a804..b8b3881d 100644 --- a/src/renderer/thinkingAnimator.ts +++ b/src/renderer/thinkingAnimator.ts @@ -1,4 +1,4 @@ -// Shared 15fps driver for the "thinking" UI animations — the +// Shared 20fps driver for the "thinking" UI animations — the // `.lightcode-thinking-text` shimmer ("Working for"/"Thinking"/"Compacting"/ // "Proposed plan") and the `.lightcode-brain-thinking` firing. // @@ -14,8 +14,8 @@ // paint but NOT that per-frame recalc loop, because the animation is still // "active" every frame. // -// Driving the exact same values from a single `setInterval` at 15fps means the -// element is only dirtied ~15×/s; between ticks nothing is invalidated, so the +// Driving the exact same values from a single `setInterval` at 20fps means the +// element is only dirtied ~20×/s; between ticks nothing is invalidated, so the // renderer's frame pipeline goes idle. The visuals are identical (same gradient // sweep, same staggered brain firing), and a shared wall-clock phase keeps every // instance perfectly in sync. The timer pauses while the window is hidden or @@ -25,8 +25,8 @@ import { useEffect, useRef } from "react"; import type { RefObject } from "react"; -const FPS = 15; -const TICK_MS = Math.round(1000 / FPS); // ~67ms +const FPS = 20; +const TICK_MS = Math.round(1000 / FPS); // 50ms const SHIMMER_PERIOD_MS = 2200; // matches the previous 2.2s background-position sweep const BRAIN_PERIOD_MS = 1800; // matches the previous 1.8s opacity pulse const BRAIN_GROUP_DELAY_MS = 600; // matches the previous 0s / 0.6s / 1.2s stagger @@ -85,7 +85,7 @@ function paintFrame(now: number): void { function tick(): void { // Freeze (skip writes) while backgrounded/unfocused — nothing to drive, and - // the static frozen frame is fine. Next tick resumes within ~67ms on refocus. + // the static frozen frame is fine. Next tick resumes within ~50ms on refocus. if (!isAppActive()) return; paintFrame(Date.now()); }