feat(ui): share agent welcome hero (#13567)

* feat(ui): share agent welcome hero

* test(ui): cover welcome hero pointer states

* refactor(ui): keep welcome hero API minimal

* test(ui): verify welcome hero package assets

* fix(ui): inline welcome hero masks
This commit is contained in:
John Choi
2026-08-26 11:37:52 -07:00
committed by GitHub
parent c8f1caa88c
commit 70654acc3e
20 changed files with 416 additions and 489 deletions
@@ -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 ? (
<div className="cline-view-enter">
<h1 className="sr-only">What would you like to build?</h1>
<WelcomeHero />
<AgentWelcomeHero />
<div className="mt-11 flex min-w-0 items-center">
<WelcomeWorkspaceControls
@@ -1,16 +0,0 @@
/**
* Values the pointer calculation needs when layout bounds are unavailable.
*
* Visual geometry belongs to welcome-hero.module.css. These two fallback sizes
* mirror its default layout because jsdom and older webviews may not expose the
* rendered grid bounds used by the normal pointer path.
*/
export const WELCOME_HERO_POINTER_CONFIG = {
defaultFrameHeight: 220,
defaultGridHeight: 520,
eyes: {
travel: 6, // Maximum distance the eyes move toward the pointer.
falloffDistance: 200, // Pointer distance needed to reach maximum travel.
smoothing: 0.22, // Fraction of the remaining distance moved per frame.
},
} as const;
@@ -1 +0,0 @@
export { WelcomeHero, type WelcomeHeroProps } from "./welcome-hero";
@@ -1,208 +0,0 @@
.root {
/* Default inline layout. Alternate compositions override only this set. */
--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("/welcome-hero/grid-tile.svg");
--welcome-grid-tile-size: 400px;
--welcome-inner-mask: url("/welcome-hero/inner-mask.svg");
--welcome-bot-top: -2px;
--welcome-bot-offset-x: -100px;
--welcome-bot-width: 204px;
--welcome-bot-height: 192px;
--welcome-bot-fill-mask: url("/welcome-hero/bot-fill-mask.svg");
--welcome-bot-outline-mask: url("/welcome-hero/bot-outline-mask.svg");
--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;
/* Appearance controls. */
--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%);
}
.root[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;
}
.root[data-welcome-hero-layout="wide-grid"] {
--welcome-hero-width: 1600px;
--welcome-grid-height: 960px;
--welcome-grid-initial-y: 370px;
}
.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%);
}
.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);
}
.inner,
.botFill,
.botOutline {
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%;
}
.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);
}
.botFill {
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);
}
.botOutline {
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);
}
.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;
}
.eyeLeft {
left: calc(50% + var(--welcome-eye-left-offset-x));
}
.eyeRight {
left: calc(50% + var(--welcome-eye-right-offset-x));
}
[class~="dark"] .root {
--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) {
.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%
);
}
.eye {
transform: none;
}
}
@@ -1,63 +0,0 @@
"use client";
import { clsx } from "clsx";
import { useRef } from "react";
import { useWelcomeHeroPointer } from "./use-welcome-hero-pointer";
import styles from "./welcome-hero.module.css";
export type WelcomeHeroLayout = "default" | "full-bleed" | "wide-grid";
export interface WelcomeHeroProps {
className?: string;
interactive?: boolean;
layout?: WelcomeHeroLayout;
variant?: "full" | "grid-only" | "bot-only";
}
/** Composable welcome bot illustration and grid backdrop. */
export function WelcomeHero({
className,
interactive,
layout = "default",
variant = "full",
}: WelcomeHeroProps) {
const heroRef = useRef<HTMLDivElement>(null);
const showsGrid = variant !== "bot-only";
const showsBot = variant !== "grid-only";
const tracksPointer = interactive ?? showsBot;
useWelcomeHeroPointer(heroRef, tracksPointer);
return (
<div
aria-hidden="true"
className={clsx(styles.root, className)}
data-welcome-hero
data-welcome-hero-interactive={tracksPointer}
data-welcome-hero-layout={layout}
data-welcome-hero-variant={variant}
ref={heroRef}
>
{showsGrid ? (
<div className={styles.grid} data-welcome-hero-layer="grid" />
) : null}
{showsBot ? (
<>
<span className={styles.inner} data-welcome-hero-layer="inner" />
<span className={styles.botFill} data-welcome-hero-layer="bot-fill" />
<span
className={styles.botOutline}
data-welcome-hero-layer="bot-outline"
/>
<span
className={`${styles.eye} ${styles.eyeLeft}`}
data-welcome-hero-eye="left"
/>
<span
className={`${styles.eye} ${styles.eyeRight}`}
data-welcome-hero-eye="right"
/>
</>
) : null}
</div>
);
}
@@ -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 }) {
<OnboardingContent surface="transparent">
<div className="flex flex-col items-center py-4 text-center">
<div className="w-full">
<WelcomeHero variant="bot-only" />
<AgentWelcomeHero variant="bot-only" />
</div>
<h1 className="mt-5 text-4xl font-semibold text-foreground">Cline</h1>
<p className="mt-2 text-lg text-foreground">Build software your way</p>
@@ -880,7 +879,7 @@ export function OnboardingView({
}
data-onboarding-grid={step}
>
<WelcomeHero
<AgentWelcomeHero
interactive={step !== "done"}
layout={step === "done" ? "wide-grid" : "full-bleed"}
variant="grid-only"
@@ -1,3 +0,0 @@
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M118.894 28.2461C118.894 19.3941 111.59 12.1251 102.222 12.125C92.8539 12.125 85.5499 19.394 85.5499 28.2461C85.5499 30.3758 85.9304 33.1689 86.7003 36.542L88.3927 43.9531H49.1749C40.8046 43.9531 34.0189 50.7388 34.0187 59.1094V87.875L13.2052 112.156L34.0187 136.437V165.203C34.0187 173.574 40.8044 180.359 49.1749 180.359H155.269C163.639 180.359 170.425 173.574 170.425 165.203V136.437L191.236 112.156L170.425 87.875V59.1094C170.425 50.739 163.639 43.9533 155.269 43.9531H116.051L117.742 36.542C118.512 33.1689 118.894 30.3758 118.894 28.2461ZM131.012 28.8877C130.992 29.8428 130.928 30.8253 130.827 31.8281H155.269C170.336 31.8283 182.55 44.0428 182.55 59.1094V83.3906L202.345 106.485C205.141 109.749 205.142 114.564 202.345 117.828L182.55 140.921V165.203C182.55 180.27 170.336 192.484 155.269 192.484H49.1749C34.1079 192.484 21.8937 180.27 21.8937 165.203V140.923L2.09777 117.828C-0.699604 114.564 -0.69891 109.749 2.09777 106.485L21.8937 83.3896V59.1094C21.8939 44.0427 34.1079 31.8281 49.1749 31.8281H73.6163C73.5159 30.8253 73.4516 29.8428 73.4318 28.8877L73.4249 28.2461C73.4249 12.3814 86.478 0 102.222 0C117.965 0.00014284 131.019 12.3814 131.019 28.2461L131.012 28.8877Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,3 +0,0 @@
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.222 11.625C92.5893 11.6251 85.0497 19.1063 85.0497 28.2461L85.0546 28.6631C85.1002 30.7847 85.4871 33.4739 86.2128 36.6533L87.7655 43.4531H49.1747C40.5283 43.4532 33.5187 50.4628 33.5185 59.1094V87.6895L12.8251 111.831L12.5468 112.156L12.8251 112.481L33.5185 136.621V165.203C33.5185 173.85 40.528 180.859 49.1747 180.859H155.269C163.915 180.859 170.925 173.85 170.925 165.203V136.621L191.616 112.481L191.895 112.156L191.616 111.831L170.925 87.6895V59.1094L170.92 58.7051C170.705 50.2454 163.78 43.4533 155.269 43.4531H116.678L118.229 36.6533C119.004 33.262 119.394 30.4282 119.394 28.2461L119.388 27.8193C119.157 18.8746 111.704 11.6251 102.222 11.625ZM22.3935 140.738L22.2733 140.598L2.47742 117.503C-0.159452 114.426 -0.158827 109.887 2.47742 106.811L22.2733 83.7148L22.3935 83.5742V59.1094C22.3937 44.3189 34.3839 32.3282 49.1747 32.3281H74.1688L74.1142 31.7783C74.0148 30.7865 73.9511 29.817 73.9315 28.877L73.9247 28.2461C73.9247 12.6714 86.7399 0.50006 102.222 0.5C117.703 0.50014 130.519 12.6714 130.519 28.2461L130.512 28.877C130.492 29.817 130.429 30.7865 130.33 31.7783L130.274 32.3281H155.269C170.059 32.3283 182.05 44.3189 182.05 59.1094V83.5752L182.17 83.7158L201.965 106.811C204.601 109.887 204.602 114.426 201.965 117.503L182.17 140.596L182.05 140.736V165.203C182.05 179.994 170.059 191.984 155.269 191.984H49.1747C34.3839 191.984 22.3935 179.994 22.3935 165.203V140.738Z" stroke="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,5 +0,0 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.3 0V400M40.3 0V400M80.3 0V400M120.3 0V400M160.3 0V400M200.3 0V400M240.3 0V400M280.3 0V400M320.3 0V400M360.3 0V400" stroke="black" stroke-width="0.6"/>
<path d="M0 0.3H400M0 40.3H400M0 80.3H400M0 120.3H400M0 160.3H400M0 200.3H400M0 240.3H400M0 280.3H400M0 320.3H400M0 360.3H400" stroke="black" stroke-width="0.6"/>
<path d="M0 0L400 400M400 0L0 400" stroke="black" stroke-width="0.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 504 B

@@ -1,3 +0,0 @@
<svg width="205" height="193" viewBox="0 0 205 193" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.3935 140.738L22.2733 140.598L2.47742 117.503C-0.159452 114.426 -0.158827 109.887 2.47742 106.811L22.2733 83.7148L22.3935 83.5742V59.1094C22.3937 44.3189 34.3839 32.3282 49.1747 32.3281H74.1688L74.1142 31.7783C74.0148 30.7865 73.9511 29.817 73.9315 28.877L73.9247 28.2461C73.9247 12.6714 86.7399 0.50006 102.222 0.5C117.703 0.50014 130.519 12.6714 130.519 28.2461L130.512 28.877C130.492 29.817 130.429 30.7865 130.33 31.7783L130.274 32.3281H155.269C170.059 32.3283 182.05 44.3189 182.05 59.1094V83.5752L182.17 83.7158L201.965 106.811C204.601 109.887 204.602 114.426 201.965 117.503L182.17 140.596L182.05 140.736V165.203C182.05 179.994 170.059 191.984 155.269 191.984H49.1747C34.3839 191.984 22.3935 179.994 22.3935 165.203V140.738Z" fill="black" stroke="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 882 B

+1 -1
View File
@@ -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",
+4
View File
@@ -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.
+1
View File
@@ -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";
@@ -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<HTMLElement>(
'[data-welcome-hero-layer="grid"]',
);
const gridBounds = gridElement?.getBoundingClientRect();
const hasRenderedGridBounds =
const gridBounds = element
.querySelector<HTMLElement>('[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<HTMLDivElement | null>,
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?.(
@@ -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;
}
}
@@ -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<HTMLDivElement>(null);
const showsGrid = variant !== "bot-only";
const showsBot = variant !== "grid-only";
const tracksPointer = interactive ?? showsBot;
useAgentWelcomeHeroPointer(heroRef, tracksPointer);
return (
<div
aria-hidden="true"
className="cline-ui-agent-welcome-hero"
data-welcome-hero
data-welcome-hero-interactive={tracksPointer}
data-welcome-hero-layout={layout}
data-welcome-hero-variant={variant}
ref={heroRef}
>
{showsGrid ? (
<div
className="cline-ui-agent-welcome-hero__grid"
data-welcome-hero-layer="grid"
/>
) : null}
{showsBot ? (
<>
<span
className="cline-ui-agent-welcome-hero__inner"
data-welcome-hero-layer="inner"
/>
<span
className="cline-ui-agent-welcome-hero__bot-fill"
data-welcome-hero-layer="bot-fill"
/>
<span
className="cline-ui-agent-welcome-hero__bot-outline"
data-welcome-hero-layer="bot-outline"
/>
<span
className="cline-ui-agent-welcome-hero__eye cline-ui-agent-welcome-hero__eye--left"
data-welcome-hero-eye="left"
/>
<span
className="cline-ui-agent-welcome-hero__eye cline-ui-agent-welcome-hero__eye--right"
data-welcome-hero-eye="right"
/>
</>
) : null}
</div>
);
}
+5
View File
@@ -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,
+4 -1
View File
@@ -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",
+26 -1
View File
@@ -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-"));
@@ -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<HTMLDivElement> {
await act(async () => root.render(<WelcomeHero {...props} />));
async function renderHero(props: AgentWelcomeHeroProps = {}) {
await act(async () => root.render(<AgentWelcomeHero {...props} />));
const hero = container.querySelector<HTMLDivElement>("[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<HTMLElement>(
'[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("");