mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
feat(ui): add AI agents banner to overview (#12074)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com> Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ff45f46047
commit
7f0dc9b7da
@@ -1,53 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
|
||||
|
||||
import { LighthouseOverviewBanner } from "./lighthouse-overview-banner";
|
||||
|
||||
describe("LighthouseOverviewBanner", () => {
|
||||
it("renders Toni copy and opens a prompted chat when connected", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
|
||||
expect(link).toHaveTextContent("Lighthouse AI");
|
||||
expect(link).toHaveTextContent("Find and remediate what actually matters.");
|
||||
});
|
||||
|
||||
it("links to Lighthouse settings when no connected configuration exists", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseOverviewBanner
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
}),
|
||||
).toHaveAttribute("href", "/lighthouse/settings");
|
||||
});
|
||||
|
||||
it("isolates its stacking so content never paints over the sticky navbar", () => {
|
||||
// Given / When: the banner's inner z-10 must stay scoped to the card —
|
||||
// without isolation it ties the sticky header's z-10 and wins by DOM order
|
||||
render(
|
||||
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
const card = screen
|
||||
.getByRole("link", { name: /Find and remediate what actually matters\./ })
|
||||
.querySelector("[data-slot='card']");
|
||||
expect(card).toHaveClass("isolate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
|
||||
import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "../_lib/overview-banner";
|
||||
|
||||
import { OverviewBanner } from "./overview-banner";
|
||||
|
||||
describe("OverviewBanner", () => {
|
||||
it("renders Toni copy and opens a prompted chat when connected", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
|
||||
expect(link).toHaveTextContent("Lighthouse AI");
|
||||
expect(link).toHaveTextContent("Find and remediate what actually matters.");
|
||||
});
|
||||
|
||||
it("links to Lighthouse settings when no connected configuration exists", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", "/lighthouse/settings");
|
||||
// In-app hrefs stay in the current tab
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
});
|
||||
|
||||
it("opens the AI agents docs in a new tab", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Connect all your agents to Prowler Cloud/,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", DOCS_URLS.AI_AGENTS);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(link).toHaveTextContent(
|
||||
"Turn your favorite agent into a Cloud Security Expert.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the purple agents palette with the same animated layers", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Connect all your agents to Prowler Cloud/,
|
||||
});
|
||||
const gradient = link.querySelector(".overview-banner-gradient");
|
||||
expect(gradient).toHaveClass("overview-banner-gradient-agents");
|
||||
expect(gradient?.querySelector(".animate-first")).toBeInTheDocument();
|
||||
expect(gradient?.querySelector(".animate-second")).toBeInTheDocument();
|
||||
expect(gradient?.querySelector(".animate-third")).toBeInTheDocument();
|
||||
expect(
|
||||
gradient?.querySelector(".overview-banner-gradient-primary-press"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Lighthouse banner on the default green palette", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
const gradient = link.querySelector(".overview-banner-gradient");
|
||||
expect(gradient).not.toHaveClass("overview-banner-gradient-agents");
|
||||
});
|
||||
|
||||
it("scopes the blur filter id per instance so stacked banners keep their gradient", () => {
|
||||
// Given / When: url(#id) resolves against the FIRST match in the document,
|
||||
// so two banners sharing one id would both resolve to the same filter
|
||||
const { container } = render(
|
||||
<>
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const filterIds = Array.from(
|
||||
container.querySelectorAll("filter"),
|
||||
(filter) => filter.id,
|
||||
);
|
||||
expect(filterIds).toHaveLength(2);
|
||||
expect(new Set(filterIds).size).toBe(2);
|
||||
});
|
||||
|
||||
it("isolates its stacking so content never paints over the sticky navbar", () => {
|
||||
// Given / When: the banner's inner z-10 must stay scoped to the card —
|
||||
// without isolation it ties the sticky header's z-10 and wins by DOM order
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const card = screen
|
||||
.getByRole("link", { name: /Find and remediate what actually matters\./ })
|
||||
.querySelector("[data-slot='card']");
|
||||
expect(card).toHaveClass("isolate");
|
||||
});
|
||||
});
|
||||
+58
-21
@@ -1,29 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { ArrowRight, Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { type ReactNode, useId, useRef, useState } from "react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { Card, CardContent } from "@/components/shadcn";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { LighthouseOverviewBannerHref } from "../_lib/lighthouse-banner";
|
||||
import {
|
||||
OVERVIEW_BANNER_VARIANT,
|
||||
type OverviewBannerVariant,
|
||||
} from "../_lib/overview-banner";
|
||||
|
||||
interface LighthouseOverviewBannerProps {
|
||||
href: LighthouseOverviewBannerHref;
|
||||
// Content lives here rather than at the call site so the icons stay inside the
|
||||
// client boundary — LighthouseIcon uses useId and cannot render on the server.
|
||||
const OVERVIEW_BANNER_CONTENT = {
|
||||
lighthouse: {
|
||||
icon: <LighthouseIcon className="size-5" />,
|
||||
title: "Lighthouse AI",
|
||||
description: "Find and remediate what actually matters.",
|
||||
},
|
||||
agents: {
|
||||
icon: <Bot className="size-5" />,
|
||||
title: "Connect all your agents to Prowler Cloud",
|
||||
description: "Turn your favorite agent into a Cloud Security Expert.",
|
||||
},
|
||||
} as const satisfies Record<
|
||||
OverviewBannerVariant,
|
||||
{ icon: ReactNode; title: string; description: string }
|
||||
>;
|
||||
|
||||
interface OverviewBannerProps {
|
||||
variant: OverviewBannerVariant;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function LighthouseOverviewBanner({
|
||||
href,
|
||||
}: LighthouseOverviewBannerProps) {
|
||||
export function OverviewBanner({ variant, href }: OverviewBannerProps) {
|
||||
const { icon, title, description } = OVERVIEW_BANNER_CONTENT[variant];
|
||||
// Absolute hrefs leave the app, so they open in a new tab.
|
||||
const isExternal = href.startsWith("http");
|
||||
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||
const curXRef = useRef(0);
|
||||
const curYRef = useRef(0);
|
||||
const tgXRef = useRef(0);
|
||||
const tgYRef = useRef(0);
|
||||
const [isSafari, setIsSafari] = useState(false);
|
||||
// Several banners render per page and url(#id) resolves against the FIRST
|
||||
// matching id in the document, so the filter id must be per-instance.
|
||||
const blurFilterId = `overview-banner-blur-${useId().replace(/[«»:]/g, "")}`;
|
||||
|
||||
useMountEffect(() => {
|
||||
setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent));
|
||||
@@ -61,19 +87,22 @@ export function LighthouseOverviewBanner({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group focus-visible:ring-border-input-primary block rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: undefined)}
|
||||
className="group focus-visible:ring-border-input-primary block h-full rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
// isolate: the content's internal z-10 (above the gradient layers)
|
||||
// must not compete with the page's sticky header, which is also z-10.
|
||||
className="group-hover:border-border-input-primary relative isolate overflow-hidden transition-colors"
|
||||
className="group-hover:border-border-input-primary relative isolate h-full overflow-hidden transition-colors"
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<svg className="hidden">
|
||||
<defs>
|
||||
<filter id="blurMe">
|
||||
<filter id={blurFilterId}>
|
||||
<feGaussianBlur
|
||||
in="SourceGraphic"
|
||||
stdDeviation="10"
|
||||
@@ -92,33 +121,41 @@ export function LighthouseOverviewBanner({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 blur-lg",
|
||||
isSafari ? "blur-2xl" : "[filter:url(#blurMe)_blur(40px)]",
|
||||
"overview-banner-gradient pointer-events-none absolute inset-0 blur-lg",
|
||||
variant === OVERVIEW_BANNER_VARIANT.AGENTS
|
||||
? "overview-banner-gradient-agents"
|
||||
: undefined,
|
||||
isSafari ? "blur-2xl" : undefined,
|
||||
)}
|
||||
style={
|
||||
isSafari
|
||||
? undefined
|
||||
: { filter: `url(#${blurFilterId}) blur(40px)` }
|
||||
}
|
||||
>
|
||||
<div className="animate-first lighthouse-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
|
||||
<div className="animate-first overview-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
|
||||
|
||||
<div className="animate-second lighthouse-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
|
||||
<div className="animate-second overview-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
|
||||
|
||||
<div className="animate-third lighthouse-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
|
||||
<div className="animate-third overview-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
|
||||
|
||||
<div
|
||||
ref={interactiveRef}
|
||||
className="lighthouse-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
|
||||
className="overview-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="relative z-10 flex min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
|
||||
<CardContent className="relative z-10 flex h-full min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-md border">
|
||||
<LighthouseIcon className="size-5" />
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-text-neutral-primary text-sm font-medium">
|
||||
Lighthouse AI
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Find and remediate what actually matters.
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const OVERVIEW_BANNER_VARIANT = {
|
||||
LIGHTHOUSE: "lighthouse",
|
||||
AGENTS: "agents",
|
||||
} as const;
|
||||
|
||||
export type OverviewBannerVariant =
|
||||
(typeof OVERVIEW_BANNER_VARIANT)[keyof typeof OVERVIEW_BANNER_VARIANT];
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Overview page", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("renders the overview banners before the provider filters", () => {
|
||||
// Given
|
||||
const firstBannerPosition = source.indexOf("<OverviewBanner");
|
||||
const firstProviderFilterPosition = source.indexOf(
|
||||
"<ProviderAccountSelectors",
|
||||
);
|
||||
|
||||
// When
|
||||
const bannersRenderBeforeFilters =
|
||||
firstBannerPosition < firstProviderFilterPosition;
|
||||
|
||||
// Then
|
||||
expect(firstBannerPosition).toBeGreaterThan(-1);
|
||||
expect(firstProviderFilterPosition).toBeGreaterThan(-1);
|
||||
expect(bannersRenderBeforeFilters).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
AppSidebarModeSync,
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { LighthouseOverviewBanner } from "./_overview/_components/lighthouse-overview-banner";
|
||||
import { OverviewBanner } from "./_overview/_components/overview-banner";
|
||||
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
|
||||
import {
|
||||
AttackSurfaceSkeleton,
|
||||
AttackSurfaceSSR,
|
||||
@@ -58,17 +60,30 @@ export default async function Home({
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
{/* Agents banner shows everywhere; Lighthouse is Cloud-only, so on a
|
||||
local server the agents banner is the only child and fills the row. */}
|
||||
<div className="mb-6 flex flex-col gap-6 lg:flex-row">
|
||||
{lighthouseBannerHref ? (
|
||||
<div className="min-w-0 lg:flex-1">
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={lighthouseBannerHref}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 lg:flex-1">
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<ProviderAccountSelectors providers={providersData?.data ?? []} />
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
{lighthouseBannerHref ? (
|
||||
<div className="mb-6">
|
||||
<LighthouseOverviewBanner href={lighthouseBannerHref} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Overview banner linking to the AI agents documentation, shown next to the Lighthouse AI banner in Cloud and full width on self-hosted deployments
|
||||
@@ -15,6 +15,7 @@ export const DOCS_URLS = {
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration",
|
||||
ATTACK_PATHS_CUSTOM_QUERIES:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries",
|
||||
AI_AGENTS: "https://docs.prowler.com/user-guide/ai-agents/",
|
||||
} as const;
|
||||
|
||||
// CloudFormation template URL for the ProwlerScan role.
|
||||
|
||||
+20
-8
@@ -581,8 +581,20 @@
|
||||
bottom: 3rem !important;
|
||||
}
|
||||
|
||||
/* Lighthouse overview banner animated gradient layers */
|
||||
.lighthouse-banner-gradient-neutral {
|
||||
/* Overview banner animated gradient layers */
|
||||
.overview-banner-gradient {
|
||||
--overview-banner-gradient-primary: var(--bg-button-primary);
|
||||
--overview-banner-gradient-primary-hover: var(--bg-button-primary-hover);
|
||||
--overview-banner-gradient-primary-press: var(--bg-button-primary-press);
|
||||
}
|
||||
|
||||
.overview-banner-gradient-agents {
|
||||
--overview-banner-gradient-primary: var(--color-violet-400);
|
||||
--overview-banner-gradient-primary-hover: var(--color-fuchsia-300);
|
||||
--overview-banner-gradient-primary-press: var(--color-indigo-500);
|
||||
}
|
||||
|
||||
.overview-banner-gradient-neutral {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--bg-neutral-tertiary) 0,
|
||||
@@ -591,28 +603,28 @@
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.lighthouse-banner-gradient-primary {
|
||||
.overview-banner-gradient-primary {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--bg-button-primary) 0,
|
||||
var(--overview-banner-gradient-primary) 0,
|
||||
transparent 50%
|
||||
)
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.lighthouse-banner-gradient-primary-hover {
|
||||
.overview-banner-gradient-primary-hover {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--bg-button-primary-hover) 0,
|
||||
var(--overview-banner-gradient-primary-hover) 0,
|
||||
transparent 50%
|
||||
)
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.lighthouse-banner-gradient-primary-press {
|
||||
.overview-banner-gradient-primary-press {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--bg-button-primary-press) 0,
|
||||
var(--overview-banner-gradient-primary-press) 0,
|
||||
transparent 50%
|
||||
)
|
||||
no-repeat;
|
||||
|
||||
Reference in New Issue
Block a user