diff --git a/src/aura.css b/src/aura.css index fab5ed8..f885305 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 d3be67b..137f968 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 c03cec6..2ccdd18 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -38,7 +38,7 @@ function TooltipContent({ {children} , diff --git a/src/components/wisp.tsx b/src/components/wisp.tsx index 08a1c21..a33dfeb 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 0000000..3cbe4d7 --- /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 0000000..1dba667 --- /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 9f78e34..32124ea 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 { @@ -289,7 +291,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 +311,6 @@ translate: 0 3px; } -@keyframes wisp-blink { - 0%, - 46%, - 50%, - 100% { - scale: 1 1; - } - 48% { - scale: 1 0.1; - } -} @keyframes wisp-sway { 0%, 100% {