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:
2026-05-25 14:43:09 -04:00
parent 6981a05de4
commit c9a82fc6de
6 changed files with 467 additions and 18 deletions

View 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;"
/>
);
}

View 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>
);
}

View 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();
});
});

View 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");
});
});

View File

@@ -0,0 +1,2 @@
export { default as ColorWaveBackground } from "./ColorWaveBackground";
export { default as HeroSection } from "./HeroSection";

View File

@@ -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>
);
}