feat: add landing page hero section with animated background
- Create ColorWaveBackground canvas component with animated gradient blobs - Create HeroSection with ShieldAI branding, headline, CTAs, and trust signals - Compose landing page in index.tsx route with background + hero - Add unit tests for HeroSection (13 tests) and ColorWaveBackground (8 tests) - Background respects prefers-reduced-motion accessibility preference - Responsive layout with mobile-first text sizing and stacked buttons
This commit is contained in:
119
web/src/components/landing/ColorWaveBackground.tsx
Normal file
119
web/src/components/landing/ColorWaveBackground.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { onMount, onCleanup } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ColorWaveBackgroundProps {
|
||||
class?: string;
|
||||
yOffset?: number;
|
||||
scale?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
indigo: "#4f46e5",
|
||||
cyan: "#06b6d4",
|
||||
slate: "#0f172a",
|
||||
};
|
||||
|
||||
export default function ColorWaveBackground(props: ColorWaveBackgroundProps) {
|
||||
const canvasRef = { current: null as HTMLCanvasElement | null };
|
||||
let animationId: number | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = canvas.clientHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const speed = props.speed ?? 1;
|
||||
const scale = props.scale ?? 1;
|
||||
const t0 = Date.now();
|
||||
|
||||
const blobs = [
|
||||
{
|
||||
x: 0.2,
|
||||
y: 0.3,
|
||||
r: 0.35,
|
||||
color: COLORS.indigo,
|
||||
phase: 0,
|
||||
},
|
||||
{
|
||||
x: 0.7,
|
||||
y: 0.6,
|
||||
r: 0.4,
|
||||
color: COLORS.cyan,
|
||||
phase: 2.1,
|
||||
},
|
||||
{
|
||||
x: 0.5,
|
||||
y: 0.8,
|
||||
r: 0.3,
|
||||
color: COLORS.slate,
|
||||
phase: 4.2,
|
||||
},
|
||||
];
|
||||
|
||||
const draw = () => {
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
const t = (Date.now() - t0) * 0.001 * speed;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
const ox = Math.sin(t + blob.phase) * w * 0.15;
|
||||
const oy = Math.cos(t * 0.7 + blob.phase) * h * 0.12;
|
||||
const x = (blob.x + (props.yOffset ?? 0) * 0.001) * w + ox;
|
||||
const y = blob.y * h + oy;
|
||||
const r = blob.r * Math.min(w, h) * scale;
|
||||
|
||||
const grad = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
grad.addColorStop(0, blob.color + "40");
|
||||
grad.addColorStop(1, blob.color + "00");
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
if (!reducedMotion) {
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
};
|
||||
|
||||
draw();
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (animationId !== undefined) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
});
|
||||
|
||||
const setRef = (el: HTMLCanvasElement) => {
|
||||
canvasRef.current = el;
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={setRef}
|
||||
class={cn("absolute inset-0 w-full h-full", props.class)}
|
||||
style="pointer-events: none;"
|
||||
/>
|
||||
);
|
||||
}
|
||||
115
web/src/components/landing/HeroSection.tsx
Normal file
115
web/src/components/landing/HeroSection.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { onMount } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-12 h-12 md:w-16 md:h-16"
|
||||
>
|
||||
<circle cx="24" cy="24" r="24" class="gradient-primary" />
|
||||
<path
|
||||
d="M24 8L14 12V20C14 28.4 19.2 36 24 38C28.8 36 34 28.4 34 20V12L24 8Z"
|
||||
fill="white"
|
||||
fill-opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeroSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function HeroSection(props: HeroSectionProps) {
|
||||
let heroRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (heroRef) {
|
||||
heroRef.style.opacity = "1";
|
||||
heroRef.style.transform = "translateY(0)";
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class={cn("relative", props.class)}>
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<ShieldIcon />
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<span class="text-[var(--color-text-primary)]">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-[var(--color-text-primary)]">for Everyone</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-[var(--color-text-secondary)] max-w-2xl mb-10 leading-relaxed">
|
||||
ShieldAI uses advanced AI to monitor, detect, and prevent identity
|
||||
threats in real-time. Your digital identity, protected by
|
||||
intelligence.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">
|
||||
Get Started
|
||||
</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">
|
||||
Learn More
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
102
web/src/components/landing/background.test.tsx
Normal file
102
web/src/components/landing/background.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import ColorWaveBackground from "./ColorWaveBackground";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
render(() => comp(), container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn((query: string) => ({
|
||||
matches: query === "(prefers-reduced-motion: reduce)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ColorWaveBackground", () => {
|
||||
it("renders a canvas element", () => {
|
||||
mount(() => <ColorWaveBackground />);
|
||||
const canvas = document.querySelector("canvas");
|
||||
expect(canvas).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has absolute positioning classes", () => {
|
||||
mount(() => <ColorWaveBackground />);
|
||||
const canvas = document.querySelector("canvas")!;
|
||||
expect(canvas.className).toContain("absolute");
|
||||
expect(canvas.className).toContain("inset-0");
|
||||
expect(canvas.className).toContain("w-full");
|
||||
expect(canvas.className).toContain("h-full");
|
||||
});
|
||||
|
||||
it("has pointer-events none style", () => {
|
||||
mount(() => <ColorWaveBackground />);
|
||||
const canvas = document.querySelector("canvas")!;
|
||||
expect(canvas.getAttribute("style")).toContain("pointer-events");
|
||||
});
|
||||
|
||||
it("merges custom class prop", () => {
|
||||
mount(() => <ColorWaveBackground class="custom-bg" />);
|
||||
const canvas = document.querySelector("canvas")!;
|
||||
expect(canvas.className).toContain("custom-bg");
|
||||
});
|
||||
|
||||
it("accepts yOffset prop", () => {
|
||||
mount(() => <ColorWaveBackground yOffset={100} />);
|
||||
const canvas = document.querySelector("canvas");
|
||||
expect(canvas).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts scale prop", () => {
|
||||
mount(() => <ColorWaveBackground scale={1.5} />);
|
||||
const canvas = document.querySelector("canvas");
|
||||
expect(canvas).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts speed prop", () => {
|
||||
mount(() => <ColorWaveBackground speed={2} />);
|
||||
const canvas = document.querySelector("canvas");
|
||||
expect(canvas).toBeTruthy();
|
||||
});
|
||||
|
||||
it("respects prefers-reduced-motion", () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn((query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
configurable: true,
|
||||
});
|
||||
mount(() => <ColorWaveBackground />);
|
||||
const canvas = document.querySelector("canvas");
|
||||
expect(canvas).toBeTruthy();
|
||||
});
|
||||
});
|
||||
120
web/src/components/landing/hero.test.tsx
Normal file
120
web/src/components/landing/hero.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
vi.mock("@solidjs/router", () => ({
|
||||
A: (props: { href?: string; children?: JSX.Element }) => {
|
||||
const href = props.href || "#";
|
||||
return (
|
||||
<a href={href}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import HeroSection from "./HeroSection";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
render(() => comp(), container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("HeroSection", () => {
|
||||
it("renders the headline with AI-Powered Identity Protection", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("AI-Powered");
|
||||
expect(document.body.textContent).toContain("Identity Protection");
|
||||
expect(document.body.textContent).toContain("for Everyone");
|
||||
});
|
||||
|
||||
it("renders the subheadline", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("ShieldAI uses advanced AI");
|
||||
});
|
||||
|
||||
it("renders the Get Started CTA", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Get Started");
|
||||
const primaryBtn = document.querySelector("button.gradient-primary");
|
||||
expect(primaryBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Learn More CTA", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Learn More");
|
||||
});
|
||||
|
||||
it("renders trust indicators", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("No credit card required");
|
||||
expect(document.body.textContent).toContain("Free tier available");
|
||||
});
|
||||
|
||||
it("renders the shield icon SVG", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wraps content in PageContainer", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const container = document.querySelector(".max-w-7xl");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders two buttons for CTAs", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has Get Started button wrapped in link to /signup", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const signupLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "/signup",
|
||||
);
|
||||
expect(signupLink).toBeTruthy();
|
||||
expect(signupLink!.textContent).toContain("Get Started");
|
||||
});
|
||||
|
||||
it("has Learn More button wrapped in link to #features", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const featuresLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "#features",
|
||||
);
|
||||
expect(featuresLink).toBeTruthy();
|
||||
expect(featuresLink!.textContent).toContain("Learn More");
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <HeroSection class="custom-hero" />);
|
||||
const section = document.querySelector("section.custom-hero");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has centered text layout", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const inner = document.querySelector(".text-center");
|
||||
expect(inner).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has responsive vertical padding", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const inner = document.querySelector(".py-20");
|
||||
expect(inner).toBeTruthy();
|
||||
expect(inner!.className).toContain("md:py-32");
|
||||
});
|
||||
});
|
||||
2
web/src/components/landing/index.ts
Normal file
2
web/src/components/landing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ColorWaveBackground } from "./ColorWaveBackground";
|
||||
export { default as HeroSection } from "./HeroSection";
|
||||
@@ -1,25 +1,16 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { createAsync } from "@solidjs/router";
|
||||
import Counter from "~/components/Counter";
|
||||
import { api } from "~/lib/api";
|
||||
import { ColorWaveBackground, HeroSection } from "~/components/landing";
|
||||
|
||||
export default function Home() {
|
||||
const hello = createAsync(() => api.example.hello.query("world"));
|
||||
return (
|
||||
<main>
|
||||
<Title>Hello World</Title>
|
||||
<h1>Hello world!</h1>
|
||||
<Counter />
|
||||
<p>
|
||||
Visit{" "}
|
||||
<a href="https://start.solidjs.com" target="_blank">
|
||||
start.solidjs.com
|
||||
</a>{" "}
|
||||
to learn how to build SolidStart apps.
|
||||
</p>
|
||||
<pre>
|
||||
<code>{JSON.stringify(hello(), null, 2)}</code>
|
||||
</pre>
|
||||
<main class="relative min-h-[calc(100vh-4rem)] overflow-hidden">
|
||||
<Title>ShieldAI — AI-Powered Identity Protection</Title>
|
||||
<div class="absolute inset-0 z-0">
|
||||
<ColorWaveBackground />
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<HeroSection />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user