diff --git a/src/browser/components/ai-elements/shimmer.tsx b/src/browser/components/ai-elements/shimmer.tsx index 166cc94a72..3c7c4402e5 100644 --- a/src/browser/components/ai-elements/shimmer.tsx +++ b/src/browser/components/ai-elements/shimmer.tsx @@ -1,10 +1,8 @@ "use client"; import { cn } from "@/common/lib/utils"; -import * as Comlink from "comlink"; import type { ElementType } from "react"; -import { memo, useEffect, useRef } from "react"; -import type { ShimmerWorkerAPI } from "@/browser/workers/shimmerWorker"; +import { memo } from "react"; export interface TextShimmerProps { children: string; @@ -15,214 +13,42 @@ export interface TextShimmerProps { colorClass?: string; } -// ───────────────────────────────────────────────────────────────────────────── -// Worker Management (singleton) -// ───────────────────────────────────────────────────────────────────────────── - -let workerAPI: Comlink.Remote | null = null; -let workerFailed = false; - -function getWorkerAPI(): Comlink.Remote | null { - if (workerFailed) return null; - if (workerAPI) return workerAPI; - - try { - const worker = new Worker(new URL("../../workers/shimmerWorker.ts", import.meta.url), { - type: "module", - name: "shimmer-animation", - }); - - worker.onerror = (e) => { - console.error("[Shimmer] Worker failed to load:", e); - workerFailed = true; - workerAPI = null; - }; - - workerAPI = Comlink.wrap(worker); - return workerAPI; - } catch (e) { - console.error("[Shimmer] Failed to create worker:", e); - workerFailed = true; - return null; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shimmer Component -// ───────────────────────────────────────────────────────────────────────────── - /** - * GPU-accelerated shimmer text effect using OffscreenCanvas in a Web Worker. + * Shimmer text effect using CSS background-clip: text. + * + * Uses a gradient background clipped to text shape, animated via + * background-position. This is much lighter than the previous + * canvas + Web Worker approach: + * - No JS animation loop + * - No canvas rendering + * - No worker message passing + * - Browser handles animation natively * - * Renders text with a sweeping highlight animation entirely off the main thread. - * All animation logic runs in a dedicated worker, leaving the main thread free - * for streaming and other UI work. + * Note: background-position isn't compositor-only, but for small text + * elements like "Thinking..." the repaint cost is negligible compared + * to the overhead of canvas/worker solutions. */ const ShimmerComponent = ({ children, as: Component = "span", className, duration = 2, - spread = 2, colorClass = "var(--color-muted-foreground)", }: TextShimmerProps) => { - const canvasRef = useRef(null); - const instanceIdRef = useRef(null); - const transferredRef = useRef(false); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const api = getWorkerAPI(); - - // Get computed styles for font matching - const computedStyle = getComputedStyle(canvas); - const font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`; - - // Resolve CSS variable to actual color - const tempEl = document.createElement("span"); - tempEl.style.color = colorClass; - document.body.appendChild(tempEl); - const resolvedColor = getComputedStyle(tempEl).color; - document.body.removeChild(tempEl); - - // Get background color for highlight - const bgColor = - getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim() || - "hsl(0 0% 12%)"; - - // Measure text and size canvas - const ctx2d = canvas.getContext("2d"); - if (!ctx2d) return; - ctx2d.font = font; - const metrics = ctx2d.measureText(children); - const textWidth = Math.ceil(metrics.width); - const ascent = metrics.actualBoundingBoxAscent; - const descent = metrics.actualBoundingBoxDescent; - const textHeight = Math.ceil(ascent + descent); - - // Handle HiDPI - const dpr = window.devicePixelRatio || 1; - canvas.width = textWidth * dpr; - canvas.height = textHeight * dpr; - canvas.style.width = `${textWidth}px`; - canvas.style.height = `${textHeight}px`; - canvas.style.verticalAlign = `${-descent}px`; - - const config = { - text: children, - font, - color: resolvedColor, - bgColor, - duration, - spread, - dpr, - textWidth, - textHeight, - baselineY: ascent, - }; - - // Worker path: transfer canvas and register - if (api && !transferredRef.current) { - try { - const offscreen = canvas.transferControlToOffscreen(); - transferredRef.current = true; - void api.register(Comlink.transfer(offscreen, [offscreen]), config).then((id) => { - instanceIdRef.current = id; - }); - } catch { - // Transfer failed, fall back to main thread - runMainThreadAnimation(canvas, config); - } - } else if (api && instanceIdRef.current !== null) { - // Already registered, just update config - void api.update(instanceIdRef.current, config); - } else if (!api) { - // No worker, run on main thread - return runMainThreadAnimation(canvas, config); - } - - return () => { - if (api && instanceIdRef.current !== null) { - void api.unregister(instanceIdRef.current); - instanceIdRef.current = null; - } - }; - }, [children, colorClass, duration, spread]); - return ( - - + + {children} ); }; -// ───────────────────────────────────────────────────────────────────────────── -// Main Thread Fallback -// ───────────────────────────────────────────────────────────────────────────── - -interface ShimmerConfig { - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; -} - -function runMainThreadAnimation(canvas: HTMLCanvasElement, config: ShimmerConfig): () => void { - const ctx = canvas.getContext("2d"); - if (!ctx) { - return function cleanup() { - // No animation started - nothing to clean up - }; - } - - const { text, font, color, bgColor, duration, spread, dpr, textWidth, textHeight, baselineY } = - config; - const durationMs = duration * 1000; - const startTime = performance.now(); - let animationId: number; - - const animate = (now: number) => { - const elapsed = now - startTime; - const progress = 1 - (elapsed % durationMs) / durationMs; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.scale(dpr, dpr); - - ctx.font = font; - ctx.fillStyle = color; - ctx.fillText(text, 0, baselineY); - - const dynamicSpread = (text?.length ?? 0) * spread; - const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75; - const gradient = ctx.createLinearGradient( - gradientCenter - dynamicSpread, - 0, - gradientCenter + dynamicSpread, - 0 - ); - gradient.addColorStop(0, "transparent"); - gradient.addColorStop(0.5, bgColor); - gradient.addColorStop(1, "transparent"); - - ctx.globalCompositeOperation = "source-atop"; - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, textWidth, textHeight); - - ctx.restore(); - animationId = requestAnimationFrame(animate); - }; - - animationId = requestAnimationFrame(animate); - return () => cancelAnimationFrame(animationId); -} - export const Shimmer = memo(ShimmerComponent); diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index ed412722f6..c31fc11ec0 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1665,6 +1665,40 @@ pre code { } } +/* Shimmer text effect - gradient clipped to text, animated via background-position */ +/* Dark band sweeps across text, works with any base color */ +.shimmer-text { + --shimmer-color: var(--color-muted-foreground); + --shimmer-dark: color-mix(in srgb, var(--shimmer-color) 25%, black); + display: inline; /* Prevent layout shift - behave exactly like normal text */ + background: linear-gradient( + 90deg, + var(--shimmer-color) 0%, + var(--shimmer-color) 35%, + var(--shimmer-dark) 50%, + var(--shimmer-color) 65%, + var(--shimmer-color) 100% + ); + background-size: 300% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: shimmer-text-sweep var(--shimmer-duration, 1.4s) linear infinite; + /* Decoration inheritance must be explicit for background-clip: text */ + text-decoration: inherit; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +@keyframes shimmer-text-sweep { + from { + background-position: 100% 0; + } + to { + background-position: 0% 0; + } +} + @keyframes toastSlideIn { from { transform: translateY(10px); diff --git a/src/browser/workers/shimmerWorker.ts b/src/browser/workers/shimmerWorker.ts deleted file mode 100644 index 633ea69535..0000000000 --- a/src/browser/workers/shimmerWorker.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Web Worker for shimmer animation - * - * Runs the animation loop entirely off the main thread using OffscreenCanvas. - * Each shimmer instance registers its canvas and config, and the worker - * manages all animations in a single rAF loop. - */ - -import * as Comlink from "comlink"; - -interface ShimmerInstance { - canvas: OffscreenCanvas; - ctx: OffscreenCanvasRenderingContext2D; - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; - startTime: number; -} - -const instances = new Map(); -let nextId = 0; -let animationRunning = false; - -function animate() { - if (instances.size === 0) { - animationRunning = false; - return; - } - - const now = performance.now(); - - for (const instance of instances.values()) { - const { - canvas, - ctx, - text, - font, - color, - bgColor, - duration, - spread, - dpr, - textWidth, - textHeight, - baselineY, - startTime, - } = instance; - - const durationMs = duration * 1000; - const elapsed = now - startTime; - // Progress from 1 to 0 (right to left, matching original) - const progress = 1 - (elapsed % durationMs) / durationMs; - - // Clear - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Scale for HiDPI - ctx.save(); - ctx.scale(dpr, dpr); - - // Draw base text - ctx.font = font; - ctx.fillStyle = color; - ctx.fillText(text, 0, baselineY); - - // Create gradient for highlight at current position - const dynamicSpread = (text?.length ?? 0) * spread; - const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75; - const gradient = ctx.createLinearGradient( - gradientCenter - dynamicSpread, - 0, - gradientCenter + dynamicSpread, - 0 - ); - gradient.addColorStop(0, "transparent"); - gradient.addColorStop(0.5, bgColor); - gradient.addColorStop(1, "transparent"); - - // Draw highlight on top using composite - ctx.globalCompositeOperation = "source-atop"; - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, textWidth, textHeight); - - ctx.restore(); - } - - requestAnimationFrame(animate); -} - -function ensureAnimationRunning() { - if (!animationRunning) { - animationRunning = true; - requestAnimationFrame(animate); - } -} - -const api = { - /** - * Register a new shimmer instance - * @returns Instance ID for later removal - */ - register( - canvas: OffscreenCanvas, - config: { - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; - } - ): number { - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new Error("Failed to get 2d context from OffscreenCanvas"); - } - - const id = nextId++; - instances.set(id, { - canvas, - ctx, - ...config, - startTime: performance.now(), - }); - - ensureAnimationRunning(); - return id; - }, - - /** - * Update an existing shimmer instance (e.g., text changed) - */ - update( - id: number, - config: { - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; - } - ): void { - const instance = instances.get(id); - if (!instance) return; - - // Resize canvas if dimensions changed - if ( - config.textWidth * config.dpr !== instance.canvas.width || - config.textHeight * config.dpr !== instance.canvas.height - ) { - instance.canvas.width = config.textWidth * config.dpr; - instance.canvas.height = config.textHeight * config.dpr; - } - - // Update config (keep startTime for smooth animation) - Object.assign(instance, config); - }, - - /** - * Unregister a shimmer instance - */ - unregister(id: number): void { - instances.delete(id); - // Animation loop will stop itself when no instances remain - }, -}; - -export type ShimmerWorkerAPI = typeof api; - -Comlink.expose(api);