From c9a82fc6de281c8ba481ed18d7d776d1ded7bae5 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 14:43:09 -0400 Subject: [PATCH] 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 --- .../landing/ColorWaveBackground.tsx | 119 +++++++++++++++++ web/src/components/landing/HeroSection.tsx | 115 +++++++++++++++++ .../components/landing/background.test.tsx | 102 +++++++++++++++ web/src/components/landing/hero.test.tsx | 120 ++++++++++++++++++ web/src/components/landing/index.ts | 2 + web/src/routes/index.tsx | 27 ++-- 6 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 web/src/components/landing/ColorWaveBackground.tsx create mode 100644 web/src/components/landing/HeroSection.tsx create mode 100644 web/src/components/landing/background.test.tsx create mode 100644 web/src/components/landing/hero.test.tsx create mode 100644 web/src/components/landing/index.ts diff --git a/web/src/components/landing/ColorWaveBackground.tsx b/web/src/components/landing/ColorWaveBackground.tsx new file mode 100644 index 0000000..a521742 --- /dev/null +++ b/web/src/components/landing/ColorWaveBackground.tsx @@ -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 ( + + ); +} diff --git a/web/src/components/landing/HeroSection.tsx b/web/src/components/landing/HeroSection.tsx new file mode 100644 index 0000000..e211ea5 --- /dev/null +++ b/web/src/components/landing/HeroSection.tsx @@ -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 ( + + + + + ); +} + +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 ( +
+ +
+
+ +
+ +

+ AI-Powered + Identity Protection +
+ for Everyone +

+ +

+ ShieldAI uses advanced AI to monitor, detect, and prevent identity + threats in real-time. Your digital identity, protected by + intelligence. +

+ + + +
+ + + + + No credit card required + + + + + + Free tier available + +
+
+
+
+ ); +} diff --git a/web/src/components/landing/background.test.tsx b/web/src/components/landing/background.test.tsx new file mode 100644 index 0000000..f014d70 --- /dev/null +++ b/web/src/components/landing/background.test.tsx @@ -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(() => ); + const canvas = document.querySelector("canvas"); + expect(canvas).toBeTruthy(); + }); + + it("has absolute positioning classes", () => { + mount(() => ); + 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(() => ); + const canvas = document.querySelector("canvas")!; + expect(canvas.getAttribute("style")).toContain("pointer-events"); + }); + + it("merges custom class prop", () => { + mount(() => ); + const canvas = document.querySelector("canvas")!; + expect(canvas.className).toContain("custom-bg"); + }); + + it("accepts yOffset prop", () => { + mount(() => ); + const canvas = document.querySelector("canvas"); + expect(canvas).toBeTruthy(); + }); + + it("accepts scale prop", () => { + mount(() => ); + const canvas = document.querySelector("canvas"); + expect(canvas).toBeTruthy(); + }); + + it("accepts speed prop", () => { + mount(() => ); + 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(() => ); + const canvas = document.querySelector("canvas"); + expect(canvas).toBeTruthy(); + }); +}); diff --git a/web/src/components/landing/hero.test.tsx b/web/src/components/landing/hero.test.tsx new file mode 100644 index 0000000..b9ab4f4 --- /dev/null +++ b/web/src/components/landing/hero.test.tsx @@ -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 ( + + {props.children} + + ); + }, +})); + +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(() => ); + 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(() => ); + expect(document.body.textContent).toContain("ShieldAI uses advanced AI"); + }); + + it("renders the Get Started CTA", () => { + mount(() => ); + expect(document.body.textContent).toContain("Get Started"); + const primaryBtn = document.querySelector("button.gradient-primary"); + expect(primaryBtn).toBeTruthy(); + }); + + it("renders the Learn More CTA", () => { + mount(() => ); + expect(document.body.textContent).toContain("Learn More"); + }); + + it("renders trust indicators", () => { + mount(() => ); + expect(document.body.textContent).toContain("No credit card required"); + expect(document.body.textContent).toContain("Free tier available"); + }); + + it("renders the shield icon SVG", () => { + mount(() => ); + const svg = document.querySelector("svg"); + expect(svg).toBeTruthy(); + }); + + it("wraps content in PageContainer", () => { + mount(() => ); + const container = document.querySelector(".max-w-7xl"); + expect(container).toBeTruthy(); + }); + + it("renders two buttons for CTAs", () => { + mount(() => ); + const buttons = document.querySelectorAll("button"); + expect(buttons.length).toBe(2); + }); + + it("has Get Started button wrapped in link to /signup", () => { + mount(() => ); + 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(() => ); + 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(() => ); + const section = document.querySelector("section.custom-hero"); + expect(section).toBeTruthy(); + }); + + it("has centered text layout", () => { + mount(() => ); + const inner = document.querySelector(".text-center"); + expect(inner).toBeTruthy(); + }); + + it("has responsive vertical padding", () => { + mount(() => ); + const inner = document.querySelector(".py-20"); + expect(inner).toBeTruthy(); + expect(inner!.className).toContain("md:py-32"); + }); +}); diff --git a/web/src/components/landing/index.ts b/web/src/components/landing/index.ts new file mode 100644 index 0000000..af54582 --- /dev/null +++ b/web/src/components/landing/index.ts @@ -0,0 +1,2 @@ +export { default as ColorWaveBackground } from "./ColorWaveBackground"; +export { default as HeroSection } from "./HeroSection"; diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index ca8669c..d626d51 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -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 ( -
- Hello World -

Hello world!

- -

- Visit{" "} - - start.solidjs.com - {" "} - to learn how to build SolidStart apps. -

-
-        {JSON.stringify(hello(), null, 2)}
-      
+
+ ShieldAI — AI-Powered Identity Protection +
+ +
+
+ +
); }