diff --git a/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx b/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
index 18a98b5dc1..edff0c55dc 100644
--- a/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
+++ b/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
@@ -1,7 +1,11 @@
"use client";
import type { AgendaTaskRecord } from "@cline/shared";
-import { type AgentQuickAction, AgentQuickActions } from "@cline/ui";
+import {
+ type AgentQuickAction,
+ AgentQuickActions,
+ AgentWelcomeHero,
+} from "@cline/ui";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
@@ -10,7 +14,6 @@ import { isAgendaTaskExpired, useAgendaTasks } from "@/hooks/use-agenda-tasks";
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils";
import { SessionContent } from "./session-content";
-import { WelcomeHero } from "./welcome-hero";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
export function WelcomeScreen({
@@ -131,7 +134,7 @@ export function WelcomeScreen({
{active ? (
What would you like to build?
-
+
(null);
- const showsGrid = variant !== "bot-only";
- const showsBot = variant !== "grid-only";
- const tracksPointer = interactive ?? showsBot;
- useWelcomeHeroPointer(heroRef, tracksPointer);
-
- return (
-
- {showsGrid ? (
-
- ) : null}
- {showsBot ? (
- <>
-
-
-
-
-
- >
- ) : null}
-
- );
-}
diff --git a/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx b/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx
index 90daa96063..029c40f3a6 100644
--- a/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx
+++ b/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Button, IconButton } from "@cline/ui";
+import { AgentWelcomeHero, Button, IconButton } from "@cline/ui";
import {
ArrowLeft,
CheckCircle2,
@@ -20,7 +20,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { WelcomeHero } from "@/components/views/chat/welcome-hero";
import { useAccount } from "@/contexts/account-context";
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
@@ -268,7 +267,7 @@ function WelcomeStep({ onContinue }: { onContinue: () => void }) {
Cline
Build software your way
@@ -880,7 +879,7 @@ export function OnboardingView({
}
data-onboarding-grid={step}
>
-
-
-
diff --git a/apps/examples/desktop-app/webview/public/welcome-hero/bot-outline-mask.svg b/apps/examples/desktop-app/webview/public/welcome-hero/bot-outline-mask.svg
deleted file mode 100644
index 5961d57f00..0000000000
--- a/apps/examples/desktop-app/webview/public/welcome-hero/bot-outline-mask.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/apps/examples/desktop-app/webview/public/welcome-hero/grid-tile.svg b/apps/examples/desktop-app/webview/public/welcome-hero/grid-tile.svg
deleted file mode 100644
index 85c3ec0be2..0000000000
--- a/apps/examples/desktop-app/webview/public/welcome-hero/grid-tile.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/apps/examples/desktop-app/webview/public/welcome-hero/inner-mask.svg b/apps/examples/desktop-app/webview/public/welcome-hero/inner-mask.svg
deleted file mode 100644
index 4da163e544..0000000000
--- a/apps/examples/desktop-app/webview/public/welcome-hero/inner-mask.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/bun.lock b/bun.lock
index 7cb3ad5855..6739559ff5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -747,7 +747,7 @@
},
"sdk/packages/ui": {
"name": "@cline/ui",
- "version": "0.2.0-next.6",
+ "version": "0.2.0-next.7",
"dependencies": {
"@cline/shared": "workspace:*",
"@radix-ui/react-slot": "1.2.4",
diff --git a/sdk/packages/ui/README.md b/sdk/packages/ui/README.md
index 2b1d23c032..5d0b39bb9a 100644
--- a/sdk/packages/ui/README.md
+++ b/sdk/packages/ui/README.md
@@ -75,6 +75,10 @@ dimensions.
`AgentHeroHeading` renders the shared cycling “What would you like to …?”
welcome heading and respects reduced-motion preferences.
+`AgentWelcomeHero` renders the interactive Cline bot and grid used on agent
+welcome surfaces. It supports bot-only and grid-only compositions and becomes
+static when reduced motion is requested.
+
`AgentApprovalCard` is controlled presentation; the host owns approval state
and submits its callbacks.
diff --git a/sdk/packages/ui/components.css b/sdk/packages/ui/components.css
index 847140c5c9..d39b75f5b5 100644
--- a/sdk/packages/ui/components.css
+++ b/sdk/packages/ui/components.css
@@ -3,6 +3,7 @@
@import "./components/agent-ask-question.css";
@import "./components/agent-aurora.css";
@import "./components/agent-hero-heading.css";
+@import "./components/agent-welcome-hero.css";
@import "./components/agent-prompt-queue.css";
@import "./components/agent-quick-actions.css";
@import "./components/search-combobox.css";
diff --git a/apps/examples/desktop-app/webview/components/views/chat/welcome-hero/use-welcome-hero-pointer.ts b/sdk/packages/ui/components/agent-welcome-hero-pointer.ts
similarity index 69%
rename from apps/examples/desktop-app/webview/components/views/chat/welcome-hero/use-welcome-hero-pointer.ts
rename to sdk/packages/ui/components/agent-welcome-hero-pointer.ts
index af5780fa79..a43aaebea4 100644
--- a/apps/examples/desktop-app/webview/components/views/chat/welcome-hero/use-welcome-hero-pointer.ts
+++ b/sdk/packages/ui/components/agent-welcome-hero-pointer.ts
@@ -1,83 +1,83 @@
import { type RefObject, useEffect } from "react";
-import { WELCOME_HERO_POINTER_CONFIG } from "./hero-config";
+
+const POINTER_CONFIG = {
+ defaultFrameHeight: 220,
+ defaultGridHeight: 520,
+ eyes: {
+ travel: 6,
+ falloffDistance: 200,
+ smoothing: 0.22,
+ },
+} as const;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
-interface WelcomeHeroPointerState {
- eyeX: number;
- eyeY: number;
- gridX: string;
- gridY: string;
-}
-
-const WELCOME_HERO_POINTER_PROPERTIES = [
+const POINTER_PROPERTIES = [
"--welcome-grid-x",
"--welcome-grid-y",
"--welcome-eye-x",
"--welcome-eye-y",
] as const;
-function resetWelcomeHeroPointerStyles(element: HTMLDivElement | null): void {
+function resetPointerStyles(element: HTMLDivElement | null): void {
if (!element) return;
- for (const property of WELCOME_HERO_POINTER_PROPERTIES) {
+ for (const property of POINTER_PROPERTIES) {
element.style.removeProperty(property);
}
}
-function getWelcomeHeroPointerState(
+function getPointerState(
element: HTMLDivElement,
clientX: number,
clientY: number,
-): WelcomeHeroPointerState | null {
+) {
const bounds = element.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return null;
- const { defaultFrameHeight, defaultGridHeight, eyes } =
- WELCOME_HERO_POINTER_CONFIG;
const pointerX = clientX - bounds.left;
const pointerY = clientY - bounds.top;
- const gridElement = element.querySelector(
- '[data-welcome-hero-layer="grid"]',
- );
- const gridBounds = gridElement?.getBoundingClientRect();
- const hasRenderedGridBounds =
+ const gridBounds = element
+ .querySelector('[data-welcome-hero-layer="grid"]')
+ ?.getBoundingClientRect();
+ const hasGridBounds =
gridBounds !== undefined && gridBounds.width > 0 && gridBounds.height > 0;
- const gridX = hasRenderedGridBounds
+ const gridX = hasGridBounds
? `${(
(clamp(clientX - gridBounds.left, 0, gridBounds.width) /
gridBounds.width) *
100
).toFixed(2)}%`
: `${((clamp(pointerX, 0, bounds.width) / bounds.width) * 100).toFixed(2)}%`;
- const gridY = hasRenderedGridBounds
+ const gridY = hasGridBounds
? `${(
(clamp(clientY - gridBounds.top, 0, gridBounds.height) /
gridBounds.height) *
100
).toFixed(2)}%`
: `${(
- clamp(pointerY, 0, defaultFrameHeight) +
- (defaultGridHeight - defaultFrameHeight) / 2
+ clamp(pointerY, 0, POINTER_CONFIG.defaultFrameHeight) +
+ (POINTER_CONFIG.defaultGridHeight -
+ POINTER_CONFIG.defaultFrameHeight) /
+ 2
).toFixed(2)}px`;
const deltaX = pointerX - bounds.width / 2;
- const deltaY = pointerY - defaultFrameHeight / 2;
+ const deltaY = pointerY - POINTER_CONFIG.defaultFrameHeight / 2;
const distance = Math.hypot(deltaX, deltaY);
const eyeStrength =
- Math.min(distance / eyes.falloffDistance, 1) * eyes.travel;
- const eyeX = distance === 0 ? 0 : (deltaX / distance) * eyeStrength;
- const eyeY = distance === 0 ? 0 : (deltaY / distance) * eyeStrength;
+ Math.min(distance / POINTER_CONFIG.eyes.falloffDistance, 1) *
+ POINTER_CONFIG.eyes.travel;
return {
- eyeX,
- eyeY,
+ eyeX: distance === 0 ? 0 : (deltaX / distance) * eyeStrength,
+ eyeY: distance === 0 ? 0 : (deltaY / distance) * eyeStrength,
gridX,
gridY,
};
}
-function applyWelcomeHeroEyeState(
+function applyEyeState(
element: HTMLDivElement,
eyeX: number,
eyeY: number,
@@ -86,10 +86,9 @@ function applyWelcomeHeroEyeState(
element.style.setProperty("--welcome-eye-y", `${eyeY.toFixed(2)}px`);
}
-/** Tracks the full page while keeping the visual response clamped to the hero. */
-export function useWelcomeHeroPointer(
+export function useAgentWelcomeHeroPointer(
heroRef: RefObject,
- enabled = true,
+ enabled: boolean,
): void {
useEffect(() => {
if (!enabled) return;
@@ -106,11 +105,7 @@ export function useWelcomeHeroPointer(
const updatePointerTarget = (hero: HTMLDivElement): boolean => {
if (!pointerDirty) return false;
- const target = getWelcomeHeroPointerState(
- hero,
- latestClientX,
- latestClientY,
- );
+ const target = getPointerState(hero, latestClientX, latestClientY);
pointerDirty = false;
if (!target) return false;
@@ -127,17 +122,13 @@ export function useWelcomeHeroPointer(
if (!hero) return;
updatePointerTarget(hero);
-
- const { smoothing } = WELCOME_HERO_POINTER_CONFIG.eyes;
- currentEyeX += (targetEyeX - currentEyeX) * smoothing;
- currentEyeY += (targetEyeY - currentEyeY) * smoothing;
-
+ currentEyeX += (targetEyeX - currentEyeX) * POINTER_CONFIG.eyes.smoothing;
+ currentEyeY += (targetEyeY - currentEyeY) * POINTER_CONFIG.eyes.smoothing;
const settledX = Math.abs(targetEyeX - currentEyeX) < 0.01;
const settledY = Math.abs(targetEyeY - currentEyeY) < 0.01;
if (settledX) currentEyeX = targetEyeX;
if (settledY) currentEyeY = targetEyeY;
-
- applyWelcomeHeroEyeState(hero, currentEyeX, currentEyeY);
+ applyEyeState(hero, currentEyeX, currentEyeY);
if (!settledX || !settledY) {
animationFrame = window.requestAnimationFrame(drawFrame);
@@ -147,17 +138,15 @@ export function useWelcomeHeroPointer(
const handlePointerMove = (event: PointerEvent) => {
const hero = heroRef.current;
if (!hero) return;
-
latestClientX = event.clientX;
latestClientY = event.clientY;
pointerDirty = true;
- // jsdom and older webviews may not expose requestAnimationFrame.
if (typeof window.requestAnimationFrame !== "function") {
if (updatePointerTarget(hero)) {
currentEyeX = targetEyeX;
currentEyeY = targetEyeY;
- applyWelcomeHeroEyeState(hero, currentEyeX, currentEyeY);
+ applyEyeState(hero, currentEyeX, currentEyeY);
}
return;
}
@@ -189,7 +178,7 @@ export function useWelcomeHeroPointer(
currentEyeY = 0;
targetEyeX = 0;
targetEyeY = 0;
- resetWelcomeHeroPointerStyles(heroRef.current);
+ resetPointerStyles(heroRef.current);
};
const motionPreference = window.matchMedia?.(
diff --git a/sdk/packages/ui/components/agent-welcome-hero.css b/sdk/packages/ui/components/agent-welcome-hero.css
new file mode 100644
index 0000000000..e04ca9f7a3
--- /dev/null
+++ b/sdk/packages/ui/components/agent-welcome-hero.css
@@ -0,0 +1,206 @@
+.cline-ui-agent-welcome-hero {
+ --welcome-hero-width: 1200px;
+ --welcome-hero-height: 220px;
+ --welcome-hero-viewport-gutter: 16px;
+ --welcome-grid-height: 520px;
+ --welcome-grid-initial-x: 50%;
+ --welcome-grid-initial-y: 280px;
+ --welcome-grid-x: var(--welcome-grid-initial-x);
+ --welcome-grid-y: var(--welcome-grid-initial-y);
+ --welcome-grid-layer-mask: radial-gradient(
+ ellipse 44.7% 45% at 44.7% 50%,
+ black 50%,
+ transparent 100%
+ );
+ --welcome-grid-mask: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgdmlld0JveD0iMCAwIDQwMCA0MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0wLjMgMFY0MDBNNDAuMyAwVjQwME04MC4zIDBWNDAwTTEyMC4zIDBWNDAwTTE2MC4zIDBWNDAwTTIwMC4zIDBWNDAwTTI0MC4zIDBWNDAwTTI4MC4zIDBWNDAwTTMyMC4zIDBWNDAwTTM2MC4zIDBWNDAwIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjAuNiIvPgo8cGF0aCBkPSJNMCAwLjNINDAwTTAgNDAuM0g0MDBNMCA4MC4zSDQwME0wIDEyMC4zSDQwME0wIDE2MC4zSDQwME0wIDIwMC4zSDQwME0wIDI0MC4zSDQwME0wIDI4MC4zSDQwME0wIDMyMC4zSDQwME0wIDM2MC4zSDQwMCIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIwLjYiLz4KPHBhdGggZD0iTTAgMEw0MDAgNDAwTTQwMCAwTDAgNDAwIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjAuNiIvPgo8L3N2Zz4K");
+ --welcome-grid-tile-size: 400px;
+ --welcome-inner-mask: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjA1IiBoZWlnaHQ9IjE5MyIgdmlld0JveD0iMCAwIDIwNSAxOTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0yMi4zOTM1IDE0MC43MzhMMjIuMjczMyAxNDAuNTk4TDIuNDc3NDIgMTE3LjUwM0MtMC4xNTk0NTIgMTE0LjQyNiAtMC4xNTg4MjcgMTA5Ljg4NyAyLjQ3NzQyIDEwNi44MTFMMjIuMjczMyA4My43MTQ4TDIyLjM5MzUgODMuNTc0MlY1OS4xMDk0QzIyLjM5MzcgNDQuMzE4OSAzNC4zODM5IDMyLjMyODIgNDkuMTc0NyAzMi4zMjgxSDc0LjE2ODhMNzQuMTE0MiAzMS43NzgzQzc0LjAxNDggMzAuNzg2NSA3My45NTExIDI5LjgxNyA3My45MzE1IDI4Ljg3N0w3My45MjQ3IDI4LjI0NjFDNzMuOTI0NyAxMi42NzE0IDg2LjczOTkgMC41MDAwNiAxMDIuMjIyIDAuNUMxMTcuNzAzIDAuNTAwMTQgMTMwLjUxOSAxMi42NzE0IDEzMC41MTkgMjguMjQ2MUwxMzAuNTEyIDI4Ljg3N0MxMzAuNDkyIDI5LjgxNyAxMzAuNDI5IDMwLjc4NjUgMTMwLjMzIDMxLjc3ODNMMTMwLjI3NCAzMi4zMjgxSDE1NS4yNjlDMTcwLjA1OSAzMi4zMjgzIDE4Mi4wNSA0NC4zMTg5IDE4Mi4wNSA1OS4xMDk0VjgzLjU3NTJMMTgyLjE3IDgzLjcxNThMMjAxLjk2NSAxMDYuODExQzIwNC42MDEgMTA5Ljg4NyAyMDQuNjAyIDExNC40MjYgMjAxLjk2NSAxMTcuNTAzTDE4Mi4xNyAxNDAuNTk2TDE4Mi4wNSAxNDAuNzM2VjE2NS4yMDNDMTgyLjA1IDE3OS45OTQgMTcwLjA1OSAxOTEuOTg0IDE1NS4yNjkgMTkxLjk4NEg0OS4xNzQ3QzM0LjM4MzkgMTkxLjk4NCAyMi4zOTM1IDE3OS45OTQgMjIuMzkzNSAxNjUuMjAzVjE0MC43MzhaIiBmaWxsPSJibGFjayIgc3Ryb2tlPSJibGFjayIvPgo8L3N2Zz4K");
+ --welcome-bot-top: -2px;
+ --welcome-bot-offset-x: -100px;
+ --welcome-bot-width: 204px;
+ --welcome-bot-height: 192px;
+ --welcome-bot-fill-mask: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjA1IiBoZWlnaHQ9IjE5MyIgdmlld0JveD0iMCAwIDIwNSAxOTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0xMTguODk0IDI4LjI0NjFDMTE4Ljg5NCAxOS4zOTQxIDExMS41OSAxMi4xMjUxIDEwMi4yMjIgMTIuMTI1QzkyLjg1MzkgMTIuMTI1IDg1LjU0OTkgMTkuMzk0IDg1LjU0OTkgMjguMjQ2MUM4NS41NDk5IDMwLjM3NTggODUuOTMwNCAzMy4xNjg5IDg2LjcwMDMgMzYuNTQyTDg4LjM5MjcgNDMuOTUzMUg0OS4xNzQ5QzQwLjgwNDYgNDMuOTUzMSAzNC4wMTg5IDUwLjczODggMzQuMDE4NyA1OS4xMDk0Vjg3Ljg3NUwxMy4yMDUyIDExMi4xNTZMMzQuMDE4NyAxMzYuNDM3VjE2NS4yMDNDMzQuMDE4NyAxNzMuNTc0IDQwLjgwNDQgMTgwLjM1OSA0OS4xNzQ5IDE4MC4zNTlIMTU1LjI2OUMxNjMuNjM5IDE4MC4zNTkgMTcwLjQyNSAxNzMuNTc0IDE3MC40MjUgMTY1LjIwM1YxMzYuNDM3TDE5MS4yMzYgMTEyLjE1NkwxNzAuNDI1IDg3Ljg3NVY1OS4xMDk0QzE3MC40MjUgNTAuNzM5IDE2My42MzkgNDMuOTUzMyAxNTUuMjY5IDQzLjk1MzFIMTE2LjA1MUwxMTcuNzQyIDM2LjU0MkMxMTguNTEyIDMzLjE2ODkgMTE4Ljg5NCAzMC4zNzU4IDExOC44OTQgMjguMjQ2MVpNMTMxLjAxMiAyOC44ODc3QzEzMC45OTIgMjkuODQyOCAxMzAuOTI4IDMwLjgyNTMgMTMwLjgyNyAzMS44MjgxSDE1NS4yNjlDMTcwLjMzNiAzMS44MjgzIDE4Mi41NSA0NC4wNDI4IDE4Mi41NSA1OS4xMDk0VjgzLjM5MDZMMjAyLjM0NSAxMDYuNDg1QzIwNS4xNDEgMTA5Ljc0OSAyMDUuMTQyIDExNC41NjQgMjAyLjM0NSAxMTcuODI4TDE4Mi41NSAxNDAuOTIxVjE2NS4yMDNDMTgyLjU1IDE4MC4yNyAxNzAuMzM2IDE5Mi40ODQgMTU1LjI2OSAxOTIuNDg0SDQ5LjE3NDlDMzQuMTA3OSAxOTIuNDg0IDIxLjg5MzcgMTgwLjI3IDIxLjg5MzcgMTY1LjIwM1YxNDAuOTIzTDIuMDk3NzcgMTE3LjgyOEMtMC42OTk2MDQgMTE0LjU2NCAtMC42OTg5MSAxMDkuNzQ5IDIuMDk3NzcgMTA2LjQ4NUwyMS44OTM3IDgzLjM4OTZWNTkuMTA5NEMyMS44OTM5IDQ0LjA0MjcgMzQuMTA3OSAzMS44MjgxIDQ5LjE3NDkgMzEuODI4MUg3My42MTYzQzczLjUxNTkgMzAuODI1MyA3My40NTE2IDI5Ljg0MjggNzMuNDMxOCAyOC44ODc3TDczLjQyNDkgMjguMjQ2MUM3My40MjQ5IDEyLjM4MTQgODYuNDc4IDAgMTAyLjIyMiAwQzExNy45NjUgMC4wMDAxNDI4NCAxMzEuMDE5IDEyLjM4MTQgMTMxLjAxOSAyOC4yNDYxTDEzMS4wMTIgMjguODg3N1oiIGZpbGw9ImJsYWNrIi8+Cjwvc3ZnPgo=");
+ --welcome-bot-outline-mask: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjA1IiBoZWlnaHQ9IjE5MyIgdmlld0JveD0iMCAwIDIwNSAxOTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0xMDIuMjIyIDExLjYyNUM5Mi41ODkzIDExLjYyNTEgODUuMDQ5NyAxOS4xMDYzIDg1LjA0OTcgMjguMjQ2MUw4NS4wNTQ2IDI4LjY2MzFDODUuMTAwMiAzMC43ODQ3IDg1LjQ4NzEgMzMuNDczOSA4Ni4yMTI4IDM2LjY1MzNMODcuNzY1NSA0My40NTMxSDQ5LjE3NDdDNDAuNTI4MyA0My40NTMyIDMzLjUxODcgNTAuNDYyOCAzMy41MTg1IDU5LjEwOTRWODcuNjg5NUwxMi44MjUxIDExMS44MzFMMTIuNTQ2OCAxMTIuMTU2TDEyLjgyNTEgMTEyLjQ4MUwzMy41MTg1IDEzNi42MjFWMTY1LjIwM0MzMy41MTg1IDE3My44NSA0MC41MjggMTgwLjg1OSA0OS4xNzQ3IDE4MC44NTlIMTU1LjI2OUMxNjMuOTE1IDE4MC44NTkgMTcwLjkyNSAxNzMuODUgMTcwLjkyNSAxNjUuMjAzVjEzNi42MjFMMTkxLjYxNiAxMTIuNDgxTDE5MS44OTUgMTEyLjE1NkwxOTEuNjE2IDExMS44MzFMMTcwLjkyNSA4Ny42ODk1VjU5LjEwOTRMMTcwLjkyIDU4LjcwNTFDMTcwLjcwNSA1MC4yNDU0IDE2My43OCA0My40NTMzIDE1NS4yNjkgNDMuNDUzMUgxMTYuNjc4TDExOC4yMjkgMzYuNjUzM0MxMTkuMDA0IDMzLjI2MiAxMTkuMzk0IDMwLjQyODIgMTE5LjM5NCAyOC4yNDYxTDExOS4zODggMjcuODE5M0MxMTkuMTU3IDE4Ljg3NDYgMTExLjcwNCAxMS42MjUxIDEwMi4yMjIgMTEuNjI1Wk0yMi4zOTM1IDE0MC43MzhMMjIuMjczMyAxNDAuNTk4TDIuNDc3NDIgMTE3LjUwM0MtMC4xNTk0NTIgMTE0LjQyNiAtMC4xNTg4MjcgMTA5Ljg4NyAyLjQ3NzQyIDEwNi44MTFMMjIuMjczMyA4My43MTQ4TDIyLjM5MzUgODMuNTc0MlY1OS4xMDk0QzIyLjM5MzcgNDQuMzE4OSAzNC4zODM5IDMyLjMyODIgNDkuMTc0NyAzMi4zMjgxSDc0LjE2ODhMNzQuMTE0MiAzMS43NzgzQzc0LjAxNDggMzAuNzg2NSA3My45NTExIDI5LjgxNyA3My45MzE1IDI4Ljg3N0w3My45MjQ3IDI4LjI0NjFDNzMuOTI0NyAxMi42NzE0IDg2LjczOTkgMC41MDAwNiAxMDIuMjIyIDAuNUMxMTcuNzAzIDAuNTAwMTQgMTMwLjUxOSAxMi42NzE0IDEzMC41MTkgMjguMjQ2MUwxMzAuNTEyIDI4Ljg3N0MxMzAuNDkyIDI5LjgxNyAxMzAuNDI5IDMwLjc4NjUgMTMwLjMzIDMxLjc3ODNMMTMwLjI3NCAzMi4zMjgxSDE1NS4yNjlDMTcwLjA1OSAzMi4zMjgzIDE4Mi4wNSA0NC4zMTg5IDE4Mi4wNSA1OS4xMDk0VjgzLjU3NTJMMTgyLjE3IDgzLjcxNThMMjAxLjk2NSAxMDYuODExQzIwNC42MDEgMTA5Ljg4NyAyMDQuNjAyIDExNC40MjYgMjAxLjk2NSAxMTcuNTAzTDE4Mi4xNyAxNDAuNTk2TDE4Mi4wNSAxNDAuNzM2VjE2NS4yMDNDMTgyLjA1IDE3OS45OTQgMTcwLjA1OSAxOTEuOTg0IDE1NS4yNjkgMTkxLjk4NEg0OS4xNzQ3QzM0LjM4MzkgMTkxLjk4NCAyMi4zOTM1IDE3OS45OTQgMjIuMzkzNSAxNjUuMjAzVjE0MC43MzhaIiBzdHJva2U9ImJsYWNrIi8+Cjwvc3ZnPgo=");
+ --welcome-eye-top: 83px;
+ --welcome-eye-width: 18px;
+ --welcome-eye-height: 52px;
+ --welcome-eye-left-offset-x: -37px;
+ --welcome-eye-right-offset-x: 19.5px;
+ --welcome-eye-x: 0px;
+ --welcome-eye-y: 0px;
+ --welcome-hero-color: oklch(from var(--primary) 0.68 calc(c * 0.8) h);
+ --welcome-grid-color: oklch(from var(--welcome-hero-color) l calc(c * 0.4) h);
+ --welcome-grid-opacity: 0.2;
+ --welcome-inner-opacity: 2%;
+ --welcome-bot-fill-opacity: 13%;
+ --welcome-bot-stroke-opacity: 12%;
+
+ position: relative;
+ left: 50%;
+ isolation: isolate;
+ width: min(
+ calc(
+ 100vw -
+ var(--welcome-hero-viewport-gutter) -
+ var(--welcome-hero-viewport-gutter)
+ ),
+ var(--welcome-hero-width)
+ );
+ max-width: none;
+ height: var(--welcome-hero-height);
+ transform: translateX(-50%);
+}
+
+.cline-ui-agent-welcome-hero[data-welcome-hero-layout="full-bleed"] {
+ --welcome-hero-width: 100vw;
+ --welcome-hero-height: 100%;
+ --welcome-hero-viewport-gutter: 0px;
+ --welcome-grid-height: 100%;
+ --welcome-grid-initial-y: 50%;
+ --welcome-grid-layer-mask: none;
+}
+
+.cline-ui-agent-welcome-hero[data-welcome-hero-layout="wide-grid"] {
+ --welcome-hero-width: 1600px;
+ --welcome-grid-height: 960px;
+ --welcome-grid-initial-y: 370px;
+}
+
+.cline-ui-agent-welcome-hero__grid {
+ position: absolute;
+ z-index: 0;
+ top: 50%;
+ left: 0;
+ width: 100%;
+ height: var(--welcome-grid-height);
+ pointer-events: none;
+ -webkit-mask-image: var(--welcome-grid-layer-mask);
+ mask-image: var(--welcome-grid-layer-mask);
+ transform: translateY(-50%);
+}
+
+.cline-ui-agent-welcome-hero__grid::before {
+ position: absolute;
+ inset: 0;
+ content: "";
+ background: radial-gradient(
+ ellipse 44% 41.25% at var(--welcome-grid-x) var(--welcome-grid-y),
+ var(--welcome-grid-color) 0%,
+ color-mix(in srgb, var(--welcome-grid-color) 0%, transparent) 100%
+ );
+ -webkit-mask-image: var(--welcome-grid-mask);
+ mask-image: var(--welcome-grid-mask);
+ -webkit-mask-position: center;
+ mask-position: center;
+ -webkit-mask-repeat: repeat;
+ mask-repeat: repeat;
+ -webkit-mask-size: var(--welcome-grid-tile-size) var(--welcome-grid-tile-size);
+ mask-size: var(--welcome-grid-tile-size) var(--welcome-grid-tile-size);
+ opacity: var(--welcome-grid-opacity);
+}
+
+.cline-ui-agent-welcome-hero__inner,
+.cline-ui-agent-welcome-hero__bot-fill,
+.cline-ui-agent-welcome-hero__bot-outline {
+ position: absolute;
+ display: block;
+ top: var(--welcome-bot-top);
+ left: calc(50% + var(--welcome-bot-offset-x));
+ width: var(--welcome-bot-width);
+ height: var(--welcome-bot-height);
+ max-width: none;
+ pointer-events: none;
+ -webkit-mask-position: center;
+ mask-position: center;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+}
+
+.cline-ui-agent-welcome-hero__inner {
+ z-index: 1;
+ background: color-mix(
+ in srgb,
+ var(--welcome-hero-color) var(--welcome-inner-opacity),
+ transparent
+ );
+ -webkit-mask-image: var(--welcome-inner-mask);
+ mask-image: var(--welcome-inner-mask);
+}
+
+.cline-ui-agent-welcome-hero__bot-fill {
+ z-index: 2;
+ background: color-mix(
+ in srgb,
+ var(--welcome-hero-color) var(--welcome-bot-fill-opacity),
+ transparent
+ );
+ -webkit-mask-image: var(--welcome-bot-fill-mask);
+ mask-image: var(--welcome-bot-fill-mask);
+}
+
+.cline-ui-agent-welcome-hero__bot-outline {
+ z-index: 3;
+ background: color-mix(
+ in srgb,
+ var(--welcome-hero-color) var(--welcome-bot-stroke-opacity),
+ transparent
+ );
+ -webkit-mask-image: var(--welcome-bot-outline-mask);
+ mask-image: var(--welcome-bot-outline-mask);
+}
+
+.cline-ui-agent-welcome-hero__eye {
+ position: absolute;
+ z-index: 4;
+ top: var(--welcome-eye-top);
+ width: var(--welcome-eye-width);
+ height: var(--welcome-eye-height);
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--welcome-hero-color) var(--welcome-bot-stroke-opacity),
+ transparent
+ );
+ border-radius: 999px;
+ pointer-events: none;
+ background: color-mix(
+ in srgb,
+ var(--welcome-hero-color) var(--welcome-bot-fill-opacity),
+ transparent
+ );
+ backface-visibility: hidden;
+ transform: translate3d(var(--welcome-eye-x), var(--welcome-eye-y), 0);
+ will-change: transform;
+}
+
+.cline-ui-agent-welcome-hero__eye--left {
+ left: calc(50% + var(--welcome-eye-left-offset-x));
+}
+
+.cline-ui-agent-welcome-hero__eye--right {
+ left: calc(50% + var(--welcome-eye-right-offset-x));
+}
+
+.dark .cline-ui-agent-welcome-hero,
+.cline-ui-agent-welcome-hero.dark {
+ --welcome-hero-color: oklch(from var(--primary) 0.9 calc(c * 0.8) h);
+ --welcome-grid-opacity: 0.2;
+ --welcome-inner-opacity: 6%;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cline-ui-agent-welcome-hero__grid::before {
+ background: radial-gradient(
+ ellipse 44% 41.25% at var(--welcome-grid-initial-x)
+ var(--welcome-grid-initial-y),
+ var(--welcome-grid-color) 0%,
+ color-mix(in srgb, var(--welcome-grid-color) 0%, transparent) 100%
+ );
+ }
+
+ .cline-ui-agent-welcome-hero__eye {
+ transform: none;
+ }
+}
diff --git a/sdk/packages/ui/components/agent-welcome-hero.tsx b/sdk/packages/ui/components/agent-welcome-hero.tsx
new file mode 100644
index 0000000000..8f1879e569
--- /dev/null
+++ b/sdk/packages/ui/components/agent-welcome-hero.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useRef } from "react";
+import { useAgentWelcomeHeroPointer } from "./agent-welcome-hero-pointer.js";
+
+export type AgentWelcomeHeroLayout = "default" | "full-bleed" | "wide-grid";
+
+export interface AgentWelcomeHeroProps {
+ interactive?: boolean;
+ layout?: AgentWelcomeHeroLayout;
+ variant?: "full" | "grid-only" | "bot-only";
+}
+
+/** Cline bot illustration and grid backdrop used on agent welcome surfaces. */
+export function AgentWelcomeHero({
+ interactive,
+ layout = "default",
+ variant = "full",
+}: AgentWelcomeHeroProps) {
+ const heroRef = useRef(null);
+ const showsGrid = variant !== "bot-only";
+ const showsBot = variant !== "grid-only";
+ const tracksPointer = interactive ?? showsBot;
+ useAgentWelcomeHeroPointer(heroRef, tracksPointer);
+
+ return (
+
+ {showsGrid ? (
+
+ ) : null}
+ {showsBot ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/sdk/packages/ui/components/index.ts b/sdk/packages/ui/components/index.ts
index a960b17e0b..1a2b63a94e 100644
--- a/sdk/packages/ui/components/index.ts
+++ b/sdk/packages/ui/components/index.ts
@@ -22,6 +22,11 @@ export {
AgentQuickActions,
type AgentQuickActionsProps,
} from "./agent-quick-actions.js";
+export {
+ AgentWelcomeHero,
+ type AgentWelcomeHeroLayout,
+ type AgentWelcomeHeroProps,
+} from "./agent-welcome-hero.js";
export { Badge, type BadgeProps } from "./badge.js";
export {
Button,
diff --git a/sdk/packages/ui/package.json b/sdk/packages/ui/package.json
index cb7adca282..872880f0d7 100644
--- a/sdk/packages/ui/package.json
+++ b/sdk/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@cline/ui",
- "version": "0.2.0-next.6",
+ "version": "0.2.0-next.7",
"description": "Shared Cline web theme and reusable agent UI components",
"repository": {
"type": "git",
@@ -62,6 +62,9 @@
"components/agent-aurora.tsx",
"components/agent-hero-heading.css",
"components/agent-hero-heading.tsx",
+ "components/agent-welcome-hero.css",
+ "components/agent-welcome-hero.tsx",
+ "components/agent-welcome-hero-pointer.ts",
"components/agent-prompt-queue.css",
"components/agent-prompt-queue.tsx",
"components/agent-quick-actions.css",
diff --git a/sdk/packages/ui/scripts/smoke-package.ts b/sdk/packages/ui/scripts/smoke-package.ts
index 03956eb59f..fce41ef1d4 100644
--- a/sdk/packages/ui/scripts/smoke-package.ts
+++ b/sdk/packages/ui/scripts/smoke-package.ts
@@ -11,13 +11,14 @@ import { basename, join, resolve } from "node:path";
const packageRoot = join(import.meta.dir, "..");
const importCheck = `
-import { existsSync } from "node:fs";
+import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
AgentAskQuestion,
AgentApprovalCard,
AgentAurora,
AgentHeroHeading,
+ AgentWelcomeHero,
AgentPromptQueue,
AgentQuickActions,
SearchCombobox,
@@ -38,6 +39,17 @@ for (const specifier of [
}
}
+const packageJsonUrl = import.meta.resolve("@cline/ui/package.json");
+const heroCss = readFileSync(
+ fileURLToPath(new URL("./components/agent-welcome-hero.css", packageJsonUrl)),
+ "utf8",
+);
+const inlineHeroMaskCount =
+ heroCss.split('url("data:image/svg+xml;base64,').length - 1;
+if (inlineHeroMaskCount !== 4) {
+ throw new Error("packed hero CSS does not contain four inline SVG masks");
+}
+
const css = import.meta.resolve("@cline/ui/components/agent-chat.css");
const tokens = import.meta.resolve("@cline/ui/theme/tokens.css");
const summary = buildToolSummary({
@@ -55,6 +67,7 @@ if (
!AgentAskQuestion ||
!AgentAurora ||
!AgentHeroHeading ||
+ !AgentWelcomeHero ||
!AgentPromptQueue ||
!SearchCombobox ||
!AgentQuickActions ||
@@ -114,6 +127,16 @@ function expectFragment(css: string, fragment: string, contract: string): void {
}
}
+function expectInlineHeroMasks(css: string, contract: string): void {
+ const masks = css.match(/url\("?data:image\/svg\+xml;base64,/g);
+ if (masks?.length !== 4) {
+ throw new Error(`${contract} did not emit four inline SVG masks`);
+ }
+ if (css.includes("agent-welcome-hero-assets")) {
+ throw new Error(`${contract} emitted external hero mask URLs`);
+ }
+}
+
async function verifyTailwindContract(
root: string,
runner: string[],
@@ -156,6 +179,7 @@ async function verifyTailwindContract(
]) {
expectFragment(css, fragment, "host Tailwind namespace");
}
+ expectInlineHeroMasks(css, "host Tailwind namespace");
const noPreflightCss = await compileTailwind(
root,
@@ -178,6 +202,7 @@ async function verifyTailwindContract(
]) {
expectFragment(noPreflightCss, fragment, "no-Preflight Tailwind contract");
}
+ expectInlineHeroMasks(noPreflightCss, "no-Preflight Tailwind contract");
}
const temporaryRoot = mkdtempSync(join(tmpdir(), "cline-ui-package-"));
diff --git a/apps/examples/desktop-app/webview/components/views/chat/welcome-hero/welcome-hero.test.tsx b/sdk/packages/ui/tests/agent-welcome-hero.test.tsx
similarity index 55%
rename from apps/examples/desktop-app/webview/components/views/chat/welcome-hero/welcome-hero.test.tsx
rename to sdk/packages/ui/tests/agent-welcome-hero.test.tsx
index 14703ae46e..e095bbf3da 100644
--- a/apps/examples/desktop-app/webview/components/views/chat/welcome-hero/welcome-hero.test.tsx
+++ b/sdk/packages/ui/tests/agent-welcome-hero.test.tsx
@@ -3,8 +3,10 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { WELCOME_HERO_POINTER_CONFIG } from "./hero-config";
-import { WelcomeHero, type WelcomeHeroProps } from "./welcome-hero";
+import {
+ AgentWelcomeHero,
+ type AgentWelcomeHeroProps,
+} from "../components/index.js";
let container: HTMLDivElement;
let root: Root;
@@ -23,13 +25,8 @@ beforeEach(() => {
"matchMedia",
vi.fn(() => ({
matches: reducedMotion,
- media: "(prefers-reduced-motion: reduce)",
- onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
- addListener: vi.fn(),
- removeListener: vi.fn(),
- dispatchEvent: vi.fn(() => true),
})),
);
reducedMotion = false;
@@ -44,17 +41,15 @@ afterEach(async () => {
vi.unstubAllGlobals();
});
-async function renderHero(
- props: WelcomeHeroProps = {},
-): Promise {
- await act(async () => root.render());
+async function renderHero(props: AgentWelcomeHeroProps = {}) {
+ await act(async () => root.render());
const hero = container.querySelector("[data-welcome-hero]");
expect(hero).not.toBeNull();
return hero as HTMLDivElement;
}
-describe("WelcomeHero", () => {
- it("renders independently tunable illustration layers", async () => {
+describe("AgentWelcomeHero", () => {
+ it("renders the full bot and grid by default", async () => {
const hero = await renderHero();
expect(hero.getAttribute("aria-hidden")).toBe("true");
@@ -76,20 +71,19 @@ describe("WelcomeHero", () => {
expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(2);
});
- it("renders a static grid without bot layers", async () => {
- const hero = await renderHero({ variant: "grid-only" });
- const getBoundingClientRect = vi.spyOn(hero, "getBoundingClientRect");
+ it("supports grid-only and bot-only variants", async () => {
+ const grid = await renderHero({ variant: "grid-only" });
+ expect(grid.dataset.welcomeHeroInteractive).toBe("false");
+ expect(grid.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(0);
- expect(hero.dataset.welcomeHeroVariant).toBe("grid-only");
- expect(hero.dataset.welcomeHeroInteractive).toBe("false");
- expect(
- hero.querySelector('[data-welcome-hero-layer="grid"]'),
- ).not.toBeNull();
- expect(hero.querySelector('[data-welcome-hero-layer="inner"]')).toBeNull();
- expect(
- hero.querySelector('[data-welcome-hero-layer="bot-fill"]'),
- ).toBeNull();
- expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(0);
+ const bot = await renderHero({ variant: "bot-only" });
+ expect(bot.querySelector('[data-welcome-hero-layer="grid"]')).toBeNull();
+ expect(bot.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(2);
+ });
+
+ it("keeps the default grid-only variant static", async () => {
+ const hero = await renderHero({ variant: "grid-only" });
+ const bounds = vi.spyOn(hero, "getBoundingClientRect");
await act(async () => {
window.dispatchEvent(
@@ -97,60 +91,48 @@ describe("WelcomeHero", () => {
);
});
- expect(getBoundingClientRect).not.toHaveBeenCalled();
+ expect(bounds).not.toHaveBeenCalled();
expect(animationFrames).toHaveLength(0);
expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("");
+ expect(hero.style.getPropertyValue("--welcome-grid-y")).toBe("");
});
- it("renders bot layers without the inline grid", async () => {
- const hero = await renderHero({ variant: "bot-only" });
+ it("tracks the pointer", async () => {
+ const hero = await renderHero();
+ hero.getBoundingClientRect = vi.fn(
+ () =>
+ ({
+ height: 220,
+ left: 10,
+ top: 20,
+ width: 930,
+ }) as DOMRect,
+ );
- expect(hero.dataset.welcomeHeroVariant).toBe("bot-only");
- expect(hero.dataset.welcomeHeroInteractive).toBe("true");
- expect(hero.querySelector('[data-welcome-hero-layer="grid"]')).toBeNull();
- expect(
- hero.querySelector('[data-welcome-hero-layer="bot-fill"]'),
- ).not.toBeNull();
- expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(2);
+ await act(async () => {
+ window.dispatchEvent(
+ new MouseEvent("pointermove", { clientX: -100, clientY: 500 }),
+ );
+ animationFrames.shift()?.(0);
+ });
+
+ expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("0.00%");
+ expect(hero.style.getPropertyValue("--welcome-grid-y")).toBe("370.00px");
+ expect(hero.style.getPropertyValue("--welcome-eye-x")).toMatch(/^-/);
});
- it("tracks the pointer for an interactive grid without rendering the bot", async () => {
+ it("tracks an interactive grid using its rendered bounds", async () => {
const hero = await renderHero({ interactive: true, variant: "grid-only" });
const grid = hero.querySelector(
'[data-welcome-hero-layer="grid"]',
);
- hero.getBoundingClientRect = vi.fn(
- () =>
- ({
- bottom: 240,
- height: 220,
- left: 10,
- right: 940,
- top: 20,
- width: 930,
- x: 10,
- y: 20,
- toJSON: () => ({}),
- }) as DOMRect,
- );
if (!grid) throw new Error("Expected the grid layer to render");
- grid.getBoundingClientRect = vi.fn(
- () =>
- ({
- bottom: 720,
- height: 700,
- left: 10,
- right: 940,
- top: 20,
- width: 930,
- x: 10,
- y: 20,
- toJSON: () => ({}),
- }) as DOMRect,
+ hero.getBoundingClientRect = vi.fn(
+ () => ({ height: 220, left: 10, top: 20, width: 930 }) as DOMRect,
+ );
+ grid.getBoundingClientRect = vi.fn(
+ () => ({ height: 700, left: 10, top: 20, width: 930 }) as DOMRect,
);
-
- expect(hero.dataset.welcomeHeroInteractive).toBe("true");
- expect(hero.querySelectorAll("[data-welcome-hero-eye]")).toHaveLength(0);
await act(async () => {
window.dispatchEvent(
@@ -163,63 +145,10 @@ describe("WelcomeHero", () => {
expect(hero.style.getPropertyValue("--welcome-grid-y")).toBe("68.57%");
});
- it("tracks the page pointer and holds the reveal at the nearest edge", async () => {
- const hero = await renderHero();
- const getBoundingClientRect = vi.fn(
- () =>
- ({
- bottom: 240,
- height: 220,
- left: 10,
- right: 940,
- top: 20,
- width: 930,
- x: 10,
- y: 20,
- toJSON: () => ({}),
- }) as DOMRect,
- );
- hero.getBoundingClientRect = getBoundingClientRect;
-
- await act(async () => {
- window.dispatchEvent(
- new MouseEvent("pointermove", {
- clientX: -100,
- clientY: 500,
- }),
- );
- animationFrames.shift()?.(0);
- });
-
- expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("0.00%");
- expect(hero.style.getPropertyValue("--welcome-grid-y")).toBe(
- `${(
- WELCOME_HERO_POINTER_CONFIG.defaultFrameHeight +
- (WELCOME_HERO_POINTER_CONFIG.defaultGridHeight -
- WELCOME_HERO_POINTER_CONFIG.defaultFrameHeight) /
- 2
- ).toFixed(2)}px`,
- );
- expect(hero.style.getPropertyValue("--welcome-eye-x")).toMatch(/^-/);
- expect(hero.style.getPropertyValue("--welcome-eye-y")).toMatch(/^\d/);
- expect(getBoundingClientRect).toHaveBeenCalledOnce();
- });
-
it("clears pointer styles when interaction is disabled", async () => {
const hero = await renderHero();
hero.getBoundingClientRect = vi.fn(
- () =>
- ({
- bottom: 240,
- height: 220,
- left: 10,
- right: 940,
- top: 20,
- width: 930,
- x: 10,
- y: 20,
- toJSON: () => ({}),
- }) as DOMRect,
+ () => ({ height: 220, left: 10, top: 20, width: 930 }) as DOMRect,
);
await act(async () => {
@@ -228,12 +157,9 @@ describe("WelcomeHero", () => {
);
animationFrames.shift()?.(0);
});
-
expect(hero.style.getPropertyValue("--welcome-grid-x")).not.toBe("");
- expect(hero.style.getPropertyValue("--welcome-eye-x")).not.toBe("");
const staticHero = await renderHero({ interactive: false });
-
expect(staticHero).toBe(hero);
expect(staticHero.style.getPropertyValue("--welcome-grid-x")).toBe("");
expect(staticHero.style.getPropertyValue("--welcome-grid-y")).toBe("");
@@ -241,10 +167,10 @@ describe("WelcomeHero", () => {
expect(staticHero.style.getPropertyValue("--welcome-eye-y")).toBe("");
});
- it("does not track the pointer when reduced motion is requested", async () => {
+ it("does not track the pointer with reduced motion", async () => {
reducedMotion = true;
const hero = await renderHero();
- const getBoundingClientRect = vi.spyOn(hero, "getBoundingClientRect");
+ const bounds = vi.spyOn(hero, "getBoundingClientRect");
await act(async () => {
window.dispatchEvent(
@@ -252,7 +178,7 @@ describe("WelcomeHero", () => {
);
});
- expect(getBoundingClientRect).not.toHaveBeenCalled();
+ expect(bounds).not.toHaveBeenCalled();
expect(animationFrames).toHaveLength(0);
expect(hero.style.getPropertyValue("--welcome-grid-x")).toBe("");
expect(hero.style.getPropertyValue("--welcome-eye-x")).toBe("");