Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/renderer/components/providers/syncMaskScanPhase.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
16 changes: 8 additions & 8 deletions src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand All @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions src/renderer/thinkingAnimator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
12 changes: 6 additions & 6 deletions src/renderer/thinkingAnimator.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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());
}
Expand Down