From 0561f42b82a03fa6af5c1c0fa048f99215f84d2f Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 31 Oct 2026 17:02:47 -0300 Subject: [PATCH 1/2] fix(ui): stop recalculating styles between Wisp blinks Each blinking Wisp ran an endless CSS animation that only moves for 4% of its six-second cycle. Chromium still recalculated styles on every frame in between, so an untouched window with seven Wisps spent about 5% of its main thread on them and never went idle. A timer now starts a short animation only when a blink is due, so nothing runs between blinks and that window's cost drops to about 2.7%. Each Wisp keeps the same rhythm and its place in it, so the blink looks the same. Hidden windows and reduced motion skip it. --- src/components/wisp.tsx | 8 ++-- src/hooks/use-wisp-blink.test.tsx | 76 +++++++++++++++++++++++++++++++ src/hooks/use-wisp-blink.ts | 47 +++++++++++++++++++ styles.css | 12 ----- 4 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 src/hooks/use-wisp-blink.test.tsx create mode 100644 src/hooks/use-wisp-blink.ts diff --git a/src/components/wisp.tsx b/src/components/wisp.tsx index 08a1c21b..a33dfebe 100644 --- a/src/components/wisp.tsx +++ b/src/components/wisp.tsx @@ -1,10 +1,10 @@ -import { createElement, useId, type CSSProperties, type ComponentProps, type ReactNode } from "react"; +import { createElement, useId, useRef, type CSSProperties, type ComponentProps, type ReactNode } from "react"; import { DEFAULT_WISP_APPEARANCE, type WispAppearance } from "../../shared/wisp-appearance"; +import { useWispBlink } from "@/hooks/use-wisp-blink"; import { cn } from "@/lib/utils"; import { BODY_GEOMETRY, - hashSeed, NO_TRAIL_OFFSET, TRAIL_GEOMETRY, luminance, @@ -132,6 +132,8 @@ function Wisp({ ...props }: WispProps) { const id = `wisp-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; + const eyesRef = useRef(null); + useWispBlink(eyesRef, name); const pixels = PIXELS[size]; const small = pixels < 32; const large = pixels >= 48; @@ -227,7 +229,7 @@ function Wisp({ )} - + diff --git a/src/hooks/use-wisp-blink.test.tsx b/src/hooks/use-wisp-blink.test.tsx new file mode 100644 index 00000000..3cbe4d78 --- /dev/null +++ b/src/hooks/use-wisp-blink.test.tsx @@ -0,0 +1,76 @@ +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Wisp } from "@/components/wisp"; +import { hashSeed } from "@/lib/wisp-appearance"; + +const NAME = "Nova"; +const FIRST_BLINK_MS = (2760 - (hashSeed(NAME) % 6000) + 6000) % 6000; + +function setReducedMotion(matches: boolean) { + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches })); +} + +describe("useWispBlink", () => { + const animate = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + animate.mockClear(); + setReducedMotion(false); + (SVGElement.prototype as unknown as { animate: typeof animate }).animate = animate; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete (SVGElement.prototype as unknown as { animate?: typeof animate }).animate; + }); + + it("blinks once per period at the place the name gives it", () => { + render(); + + vi.advanceTimersByTime(FIRST_BLINK_MS - 1); + expect(animate).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(animate).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(12000); + expect(animate).toHaveBeenCalledTimes(3); + }); + + it("blinks in the conversation list", () => { + render( + + + , + ); + + vi.advanceTimersByTime(6000); + expect(animate).toHaveBeenCalled(); + }); + + it("leaves a Wisp elsewhere still", () => { + render(); + + vi.advanceTimersByTime(12000); + expect(animate).not.toHaveBeenCalled(); + }); + + it("does not blink when the person asks for reduced motion", () => { + setReducedMotion(true); + render(); + + vi.advanceTimersByTime(12000); + expect(animate).not.toHaveBeenCalled(); + }); + + it("stops when the Wisp leaves the screen", () => { + const { unmount } = render(); + + unmount(); + vi.advanceTimersByTime(12000); + expect(animate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-wisp-blink.ts b/src/hooks/use-wisp-blink.ts new file mode 100644 index 00000000..1dba6677 --- /dev/null +++ b/src/hooks/use-wisp-blink.ts @@ -0,0 +1,47 @@ +import { useEffect, type RefObject } from "react"; + +import { hashSeed } from "@/lib/wisp-appearance"; + +const BLINK_PERIOD_MS = 6000; +/** Where in its period a Wisp blinks, so a name keeps the same place in the rhythm. */ +const BLINK_START_MS = 2760; +const BLINK_DURATION_MS = 240; + +/** Only the large picture and the conversation list blink; elsewhere the eyes are too small to read. */ +const BLINKING_WISP = '[data-slot="wisp"][data-size="xl"], .conversation-avatar [data-slot="wisp"]'; + +const BLINK_KEYFRAMES: Keyframe[] = [ + { scale: "1 1", easing: "ease" }, + { scale: "1 0.1", easing: "ease" }, + { scale: "1 1" }, +]; + +/** + * Blinks a Wisp's eyes every few seconds. An endless CSS animation made Chromium + * recalculate styles and layout on every frame even between blinks, so the idle + * app never stopped working. A timer starts a short animation only when one is due. + */ +export function useWispBlink(eyesRef: RefObject, seed: string) { + useEffect(() => { + const eyes = eyesRef.current; + if (!eyes?.closest(BLINKING_WISP) || typeof eyes.animate !== "function") return; + + function blink() { + // CSS reduced-motion rules do not reach animations started from script. + if (document.hidden || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + eyes?.animate(BLINK_KEYFRAMES, BLINK_DURATION_MS); + } + + let interval: ReturnType | undefined; + const firstDelay = (BLINK_START_MS - (hashSeed(seed) % BLINK_PERIOD_MS) + BLINK_PERIOD_MS) % BLINK_PERIOD_MS; + const first = setTimeout(() => { + blink(); + interval = setInterval(blink, BLINK_PERIOD_MS); + }, firstDelay); + + return () => { + clearTimeout(first); + clearInterval(interval); + }; + }, [eyesRef, seed]); +} diff --git a/styles.css b/styles.css index 9f78e34d..0ead381a 100644 --- a/styles.css +++ b/styles.css @@ -289,7 +289,6 @@ .conversation-avatar [data-slot="wisp"] .wisp-blink { transform-box: fill-box; transform-origin: center; - animation: wisp-blink 6s infinite; } [data-slot="wisp"][data-state="working"] .wisp-trail { animation: wisp-sway 1.5s ease-in-out infinite; @@ -310,17 +309,6 @@ translate: 0 3px; } -@keyframes wisp-blink { - 0%, - 46%, - 50%, - 100% { - scale: 1 1; - } - 48% { - scale: 1 0.1; - } -} @keyframes wisp-sway { 0%, 100% { From 13b7eeb2d26202b98e1158b660c8c83b14cb6f87 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 31 Oct 2026 17:05:11 -0300 Subject: [PATCH 2/2] refactor(ui): quicken floating surfaces and take motion off layout Surfaces that fade in used the weak built-in ease, while dialogs asked for ease-out. A single strong --ease-out token now drives every entrance, so menus and panels are readable sooner at the same duration. Moving between tooltips no longer replays the fade, since the first one already answered the wait. The switch thumb moves with translate instead of left, and the working pulse scales a disc instead of repainting a box shadow on every frame. --- src/aura.css | 16 +++++++++++----- src/components/ui/context-menu.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 2 +- src/components/ui/popover.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/toggle-switch.tsx | 4 ++-- src/components/ui/tooltip.tsx | 2 +- src/components/update-toast.tsx | 2 +- styles.css | 12 +++++++----- 9 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/aura.css b/src/aura.css index fab5ed88..f8853056 100644 --- a/src/aura.css +++ b/src/aura.css @@ -163,19 +163,25 @@ color: var(--destructive); } .chat-subline-pulse { + position: relative; flex: none; width: 6px; height: 6px; border-radius: 50%; background: currentColor; +} +.chat-subline-pulse::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: currentColor; animation: chat-subline-pulse 1.4s ease-out infinite; } @keyframes chat-subline-pulse { - from { - box-shadow: 0 0 0 0 currentColor; - } to { - box-shadow: 0 0 0 6px transparent; + opacity: 0; + scale: 3; } } @@ -309,7 +315,7 @@ @media (prefers-reduced-motion: reduce) { .wisp-halo[data-state="working"]::before, - .chat-subline-pulse { + .chat-subline-pulse::after { animation: none; } } diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index d3be67b7..137f9689 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -20,7 +20,7 @@ function ContextMenuContent({ className, children, ...props }: ContextMenuPrimit diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index c03cec6f..2ccdd188 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -38,7 +38,7 @@ function TooltipContent({ {children} , diff --git a/styles.css b/styles.css index 0ead381a..32124ea6 100644 --- a/styles.css +++ b/styles.css @@ -67,12 +67,14 @@ --text-lg: 17px; --text-xl: 20px; - --animate-message-in: message-in 160ms ease-out; - --animate-message-highlight: message-highlight 1.8s ease-out; - --animate-panel-in: panel-in 150ms ease-out; + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + + --animate-message-in: message-in 160ms var(--ease-out); + --animate-message-highlight: message-highlight 1.8s var(--ease-out); + --animate-panel-in: panel-in 150ms var(--ease-out); --animate-typing-dot: typing-dot 1.2s ease-in-out infinite; - --animate-tab-forward: tab-forward 170ms ease-out; - --animate-tab-back: tab-back 170ms ease-out; + --animate-tab-forward: tab-forward 170ms var(--ease-out); + --animate-tab-back: tab-back 170ms var(--ease-out); @keyframes message-in { from {