From 2b08e584c872f8f658bad5543baad7ea1bab4cd2 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 14 Sep 2026 13:00:59 +0530 Subject: [PATCH 1/2] feat: premium product landing page (site/) + GitHub Pages workflow Move all site-related code under site/. Build a Next.js 15 + Tailwind v4 landing page with static export, deployable to GitHub Pages. Sections: hero with animated route preview, trust bar, features grid, how-it-works (3-step), algorithms (ALNS + BRKGA deep dive with charts), benchmarks table, interactive code preview, CTA, and footer. Design system: custom Tailwind v4 tokens, OKLCH color space, light + dark themes, full-bleed typography, restrained borders, premium gradients, Framer Motion micro-interactions with reduced-motion support. Deployment: GitHub Actions workflow at .github/workflows/pages.yml builds site/ with basePath derived from GITHUB_REPOSITORY and publishes the static export to sachncs.github.io/fleetpilot/. --- .github/workflows/pages.yml | 54 + .gitignore | 6 + _config.yml | 8 - assets/favicon.svg | 6 - assets/logo.svg | 7 - index.md | 19 - site/.eslintrc.json | 3 + site/README.md | 61 + site/app/globals.css | 355 + site/app/layout.tsx | 134 + site/app/not-found.tsx | 27 + site/app/page.tsx | 23 + site/components/algorithms.tsx | 304 + site/components/benchmarks.tsx | 178 + site/components/code-preview.tsx | 298 + site/components/cta.tsx | 88 + site/components/features.tsx | 212 + site/components/hero.tsx | 373 + site/components/how-it-works.tsx | 134 + site/components/site-footer.tsx | 184 + site/components/site-nav.tsx | 187 + site/components/theme-provider.tsx | 78 + site/components/trust-bar.tsx | 70 + site/lib/utils.ts | 44 + site/next-env.d.ts | 6 + site/next.config.mjs | 21 + site/package-lock.json | 6391 +++++++++++++++++ site/package.json | 31 + site/postcss.config.mjs | 7 + site/public/favicon.svg | 19 + site/public/logo.svg | 4 + site/public/manifest.json | 17 + .../social-preview.png => site/public/og.png | Bin site/public/robots.txt | 4 + site/tsconfig.json | 28 + 35 files changed, 9341 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/pages.yml delete mode 100644 _config.yml delete mode 100644 assets/favicon.svg delete mode 100644 assets/logo.svg delete mode 100644 index.md create mode 100644 site/.eslintrc.json create mode 100644 site/README.md create mode 100644 site/app/globals.css create mode 100644 site/app/layout.tsx create mode 100644 site/app/not-found.tsx create mode 100644 site/app/page.tsx create mode 100644 site/components/algorithms.tsx create mode 100644 site/components/benchmarks.tsx create mode 100644 site/components/code-preview.tsx create mode 100644 site/components/cta.tsx create mode 100644 site/components/features.tsx create mode 100644 site/components/hero.tsx create mode 100644 site/components/how-it-works.tsx create mode 100644 site/components/site-footer.tsx create mode 100644 site/components/site-nav.tsx create mode 100644 site/components/theme-provider.tsx create mode 100644 site/components/trust-bar.tsx create mode 100644 site/lib/utils.ts create mode 100644 site/next-env.d.ts create mode 100644 site/next.config.mjs create mode 100644 site/package-lock.json create mode 100644 site/package.json create mode 100644 site/postcss.config.mjs create mode 100644 site/public/favicon.svg create mode 100644 site/public/logo.svg create mode 100644 site/public/manifest.json rename assets/social-preview.png => site/public/og.png (100%) create mode 100644 site/public/robots.txt create mode 100644 site/tsconfig.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..bad02a9 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,54 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [master, main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./site + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: 'npm' + cache-dependency-path: site/package-lock.json + - name: Install dependencies + run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Build (static export) + run: npm run build + env: + GITHUB_ACTIONS: 'true' + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./site/out + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index fdc0b09..5b3188a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ data/ *~ tests/**/*.js.map tests/**/*.d.ts.map + +# site/ (Next.js landing page) +site/.next/ +site/out/ +site/node_modules/ +site/.turbo/ diff --git a/_config.yml b/_config.yml deleted file mode 100644 index 496c9cf..0000000 --- a/_config.yml +++ /dev/null @@ -1,8 +0,0 @@ -title: "fleetpilot" -description: "Vehicle Routing Problem with Resource-Constrained Pickup and Delivery" -theme: minima -logo: /assets/logo.svg -show_downloads: false -permalink: /:title/ -plugins: - - jekyll-feed diff --git a/assets/favicon.svg b/assets/favicon.svg deleted file mode 100644 index 30f0d86..0000000 --- a/assets/favicon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - F - diff --git a/assets/logo.svg b/assets/logo.svg deleted file mode 100644 index 618bc4e..0000000 --- a/assets/logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - fleetpilot - - F - diff --git a/index.md b/index.md deleted file mode 100644 index 4fc6768..0000000 --- a/index.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -layout: default -title: fleetpilot -description: "Vehicle Routing Problem with Resource-Constrained Pickup and Delivery" ---- -# fleetpilot - -Vehicle Routing Problem with Resource-Constrained Pickup and Delivery - -## Source - -The canonical source for this project lives at -[`sachncs/fleetpilot`](https://github.com/sachncs/fleetpilot). -See the README there for installation, usage, and contribution instructions. - -## Pages - -This site is generated by GitHub Pages from a Jekyll landing page; the project -documentation lives alongside the source. diff --git a/site/.eslintrc.json b/site/.eslintrc.json new file mode 100644 index 0000000..d9cdaf7 --- /dev/null +++ b/site/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} \ No newline at end of file diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..fac651a --- /dev/null +++ b/site/README.md @@ -0,0 +1,61 @@ +# FleetPilot landing page + +The premium product marketing site for [FleetPilot](https://github.com/sachncs/fleetpilot). Built with Next.js (App Router) + Tailwind CSS v4 + Framer Motion, statically exported for GitHub Pages. + +## Stack + +| Concern | Choice | +| --- | --- | +| Framework | Next.js 15 (App Router, static export) | +| Styling | Tailwind CSS v4 (custom design tokens in `app/globals.css`) | +| Motion | Framer Motion (subtle entrance + reduced-motion fallbacks) | +| Icons | `lucide-react` | +| Type system | TypeScript 5.7 | + +## Local development + +```bash +npm install +npm run dev # http://localhost:3001 (Turbopack) +npm run typecheck # tsc --noEmit +npm run build # static export → ./out +``` + +The dev server runs at `http://localhost:3001` so it never collides with the `frontend/` console app on `:3000`. + +## Deployment + +Pushing to `master` triggers `.github/workflows/pages.yml`, which: + +1. Installs deps inside `site/` +2. Runs `npm run typecheck` and `npm run build` +3. Uploads `./site/out/` as a GitHub Pages artifact +4. Publishes to `https://sachncs.github.io/fleetpilot/` + +The build uses `output: 'export'` with a basePath derived from `GITHUB_REPOSITORY` so the output works correctly under the project-page subpath on GitHub Pages. + +## Layout + +``` +site/ +├── app/ +│ ├── layout.tsx # html shell, metadata, theme provider, nav, footer +│ ├── page.tsx # home composition +│ ├── not-found.tsx # 404 page +│ └── globals.css # Tailwind v4 tokens, utilities, animations +├── components/ # hero, features, how-it-works, algorithms, benchmarks, code-preview, cta, site-nav, site-footer, theme-provider +├── lib/utils.ts # cn(), formatters +├── public/ # favicon.svg, logo.svg, manifest.json, og.png, robots.txt +├── next.config.mjs +├── postcss.config.mjs +├── tsconfig.json +└── package.json +``` + +## Conventions + +- One component per file; `components/.tsx` +- `'use client'` only where state, refs, or browser APIs are required +- All design tokens live in `app/globals.css` — no ad-hoc colors in components +- Animations respect `prefers-reduced-motion` +- Layout is responsive at `sm`, `md`, `lg` breakpoints with no horizontal scroll on mobile \ No newline at end of file diff --git a/site/app/globals.css b/site/app/globals.css new file mode 100644 index 0000000..e7f0ecd --- /dev/null +++ b/site/app/globals.css @@ -0,0 +1,355 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --color-ink-50: oklch(0.98 0.005 250); + --color-ink-100: oklch(0.96 0.008 250); + --color-ink-200: oklch(0.92 0.012 250); + --color-ink-300: oklch(0.84 0.018 250); + --color-ink-400: oklch(0.68 0.025 250); + --color-ink-500: oklch(0.55 0.03 250); + --color-ink-600: oklch(0.42 0.04 250); + --color-ink-700: oklch(0.28 0.05 250); + --color-ink-800: oklch(0.16 0.04 250); + --color-ink-900: oklch(0.1 0.035 250); + --color-ink-950: oklch(0.06 0.025 250); + + --color-paper: oklch(0.995 0.003 250); + --color-paper-elev: oklch(1 0 0); + + --color-ink: oklch(0.16 0.04 250); + --color-ink-soft: oklch(0.42 0.04 250); + --color-ink-muted: oklch(0.55 0.03 250); + --color-ink-faint: oklch(0.72 0.02 250); + + --color-line: oklch(0.92 0.01 250); + --color-line-strong: oklch(0.86 0.015 250); + + --color-accent: oklch(0.62 0.18 252); + --color-accent-soft: oklch(0.72 0.14 252); + --color-accent-deep: oklch(0.48 0.2 256); + + --color-iris: oklch(0.62 0.18 290); + --color-aurora: oklch(0.78 0.14 195); + --color-amber-glow: oklch(0.82 0.14 80); + + --font-sans: + 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-display: + 'Inter Display', 'Inter', ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, sans-serif; + --font-mono: + 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, + Consolas, 'Liberation Mono', monospace; + --font-serif: + 'Instrument Serif', 'Iowan Old Style', 'Apple Garamond', Georgia, serif; +} + +:root { + color-scheme: light; + --bg: var(--color-paper); + --bg-elev: var(--color-paper-elev); + --fg: var(--color-ink); + --fg-soft: var(--color-ink-soft); + --fg-muted: var(--color-ink-muted); + --fg-faint: var(--color-ink-faint); + --line: var(--color-line); + --line-strong: var(--color-line-strong); + --accent: var(--color-accent); + --accent-deep: var(--color-accent-deep); + --halo: oklch(0.72 0.14 252 / 0.18); + --grid: oklch(0.84 0.018 250 / 0.5); +} + +.dark { + color-scheme: dark; + --bg: oklch(0.07 0.018 250); + --bg-elev: oklch(0.1 0.022 250); + --fg: oklch(0.97 0.005 250); + --fg-soft: oklch(0.82 0.015 250); + --fg-muted: oklch(0.68 0.025 250); + --fg-faint: oklch(0.5 0.025 250); + --line: oklch(0.22 0.025 250); + --line-strong: oklch(0.3 0.03 250); + --accent: oklch(0.78 0.15 252); + --accent-deep: oklch(0.65 0.2 256); + --halo: oklch(0.7 0.18 252 / 0.22); + --grid: oklch(0.22 0.025 250 / 0.5); +} + +@layer base { + *, + ::before, + ::after { + border-color: var(--line); + } + + html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-feature-settings: 'cv11', 'ss01', 'ss03'; + background: var(--bg); + } + + body { + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-feature-settings: 'cv11', 'ss01'; + min-height: 100dvh; + overflow-x: hidden; + } + + ::selection { + background: color-mix(in oklab, var(--accent) 28%, transparent); + color: var(--fg); + } + + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 6px; + } + + h1, + h2, + h3, + h4 { + font-family: var(--font-display); + letter-spacing: -0.025em; + color: var(--fg); + } +} + +@utility container-wide { + width: 100%; + margin-inline: auto; + padding-inline: clamp(1.25rem, 4vw, 2.5rem); + max-width: 1280px; +} + +@utility container-narrow { + margin-inline: auto; + padding-inline: clamp(1.25rem, 4vw, 2.5rem); + max-width: 960px; +} + +@utility shell { + position: relative; + isolation: isolate; +} + +@utility hairline { + border: 1px solid var(--line); +} + +@utility surface { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: 18px; + box-shadow: + 0 1px 0 0 color-mix(in oklab, var(--fg) 4%, transparent) inset, + 0 20px 50px -32px color-mix(in oklab, var(--fg) 24%, transparent); +} + +@utility surface-soft { + background: color-mix(in oklab, var(--bg-elev) 80%, transparent); + border: 1px solid var(--line); + border-radius: 18px; + backdrop-filter: blur(14px); +} + +@utility ink-muted { + color: var(--fg-muted); +} + +@utility ink-soft { + color: var(--fg-soft); +} + +@utility ink-faint { + color: var(--fg-faint); +} + +@utility display { + font-family: var(--font-display); + letter-spacing: -0.035em; + font-weight: 500; +} + +@utility eyebrow { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 11px; + font-weight: 500; + color: var(--fg-muted); +} + +@utility btn-primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 1.1rem; + border-radius: 999px; + background: var(--fg); + color: var(--bg); + font-weight: 500; + font-size: 0.92rem; + letter-spacing: -0.005em; + transition: transform 220ms ease, opacity 220ms ease; + box-shadow: + 0 1px 0 0 color-mix(in oklab, white 14%, transparent) inset, + 0 18px 40px -22px color-mix(in oklab, var(--fg) 60%, transparent); +} + +@utility btn-primary { + &:hover { + transform: translateY(-1px); + } +} + +@utility btn-ghost { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 1.1rem; + border-radius: 999px; + background: transparent; + color: var(--fg); + font-weight: 500; + font-size: 0.92rem; + border: 1px solid var(--line-strong); + transition: background 220ms ease, border-color 220ms ease; +} + +@utility btn-ghost { + &:hover { + background: color-mix(in oklab, var(--fg) 4%, transparent); + border-color: var(--fg-faint); + } +} + +@utility chip { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.32rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--line); + background: color-mix(in oklab, var(--bg-elev) 75%, transparent); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.02em; + color: var(--fg-muted); + backdrop-filter: blur(8px); +} + +@utility shine { + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient( + 1200px 600px at 50% -10%, + var(--halo), + transparent 60% + ), + radial-gradient( + 900px 500px at 100% 10%, + color-mix(in oklab, var(--accent-soft) 18%, transparent), + transparent 60% + ); + z-index: -1; +} + +@utility grain { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.5; + mix-blend-mode: overlay; + background-image: radial-gradient( + color-mix(in oklab, var(--fg) 6%, transparent) 1px, + transparent 1px + ); + background-size: 3px 3px; + z-index: -1; +} + +@utility grid-overlay { + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to right, var(--grid) 1px, transparent 1px), + linear-gradient(to bottom, var(--grid) 1px, transparent 1px); + background-size: 64px 64px; + mask-image: radial-gradient( + 900px 600px at 50% 0%, + black, + transparent 70% + ); + z-index: -1; +} + +@utility mono { + font-family: var(--font-mono); + font-feature-settings: 'calt' 0; +} + +@keyframes pulse-dot { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.4); + opacity: 0.6; + } +} + +@keyframes drift { + 0% { + transform: translate3d(0, 0, 0); + } + 50% { + transform: translate3d(0, -6px, 0); + } + 100% { + transform: translate3d(0, 0, 0); + } +} + +@keyframes dash-flow { + to { + stroke-dashoffset: -32; + } +} + +@keyframes halo-glow { + 0%, + 100% { + opacity: 0.55; + transform: scale(1); + } + 50% { + opacity: 0.85; + transform: scale(1.06); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} \ No newline at end of file diff --git a/site/app/layout.tsx b/site/app/layout.tsx new file mode 100644 index 0000000..f12816a --- /dev/null +++ b/site/app/layout.tsx @@ -0,0 +1,134 @@ +import type { Metadata, Viewport } from 'next'; +import { Inter, Instrument_Serif, JetBrains_Mono } from 'next/font/google'; +import './globals.css'; +import { ThemeProvider } from '@/components/theme-provider'; +import { SiteNav } from '@/components/site-nav'; +import { SiteFooter } from '@/components/site-footer'; + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', + display: 'swap', +}); + +const serif = Instrument_Serif({ + subsets: ['latin'], + weight: '400', + variable: '--font-serif', + display: 'swap', +}); + +const mono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', + display: 'swap', +}); + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://sachncs.github.io'; +const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''; +const repoUrl = 'https://github.com/sachncs/fleetpilot'; + +export const metadata: Metadata = { + metadataBase: new URL(siteUrl), + title: { + default: 'FleetPilot — Route optimization for modern logistics', + template: '%s · FleetPilot', + }, + description: + 'FleetPilot solves resource-constrained pickup-and-delivery with a two-stage metaheuristic. Built for fleets that need answers, not paper parity.', + applicationName: 'FleetPilot', + authors: [{ name: 'Sachin', url: repoUrl }], + generator: 'Next.js', + keywords: [ + 'route optimization', + 'pickup and delivery', + 'vehicle routing', + 'ALNS', + 'BRKGA', + 'metaheuristic', + 'logistics', + 'last-mile', + 'fleetpilot', + ], + referrer: 'origin-when-cross-origin', + creator: 'Sachin', + publisher: 'FleetPilot', + alternates: { + canonical: `${basePath}/`, + }, + openGraph: { + type: 'website', + locale: 'en_US', + url: `${siteUrl}${basePath}/`, + siteName: 'FleetPilot', + title: 'FleetPilot — Route optimization for modern logistics', + description: + 'Two-stage metaheuristic (ALNS + BRKGA) for resource-constrained pickup-and-delivery. Production-grade, fast, deterministic.', + images: [ + { + url: `${siteUrl}${basePath}/og.png`, + width: 1280, + height: 640, + alt: 'FleetPilot — Route optimization for modern logistics', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'FleetPilot — Route optimization for modern logistics', + description: + 'Two-stage metaheuristic (ALNS + BRKGA) for resource-constrained pickup-and-delivery.', + images: [`${siteUrl}${basePath}/og.png`], + creator: '@sachncs', + }, + icons: { + icon: `${basePath}/favicon.svg`, + shortcut: `${basePath}/favicon.svg`, + apple: `${basePath}/favicon.svg`, + }, + manifest: `${basePath}/manifest.json`, + robots: { + index: true, + follow: true, + googleBot: { index: true, follow: true }, + }, + category: 'technology', +}; + +export const viewport: Viewport = { + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#fbfbfd' }, + { media: '(prefers-color-scheme: dark)', color: '#0b0d12' }, + ], + width: 'device-width', + initialScale: 1, + maximumScale: 5, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement { + return ( + + + + + Skip to content + + +
{children}
+ +
+ + + ); +} \ No newline at end of file diff --git a/site/app/not-found.tsx b/site/app/not-found.tsx new file mode 100644 index 0000000..772ec47 --- /dev/null +++ b/site/app/not-found.tsx @@ -0,0 +1,27 @@ +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; + +export default function NotFound(): React.ReactElement { + return ( +
+
+
+

— 404

+

+ Off-route. +

+

+ We couldn’t find the page you were looking for. Let’s get + you back to the dispatch board. +

+ + + Back to home + +
+
+ ); +} \ No newline at end of file diff --git a/site/app/page.tsx b/site/app/page.tsx new file mode 100644 index 0000000..a0972da --- /dev/null +++ b/site/app/page.tsx @@ -0,0 +1,23 @@ +import { Hero } from '@/components/hero'; +import { TrustBar } from '@/components/trust-bar'; +import { Features } from '@/components/features'; +import { HowItWorks } from '@/components/how-it-works'; +import { Algorithms } from '@/components/algorithms'; +import { Benchmarks } from '@/components/benchmarks'; +import { CodePreview } from '@/components/code-preview'; +import { Cta } from '@/components/cta'; + +export default function HomePage(): React.ReactElement { + return ( + <> + + + + + + + + + + ); +} \ No newline at end of file diff --git a/site/components/algorithms.tsx b/site/components/algorithms.tsx new file mode 100644 index 0000000..62eb473 --- /dev/null +++ b/site/components/algorithms.tsx @@ -0,0 +1,304 @@ +'use client'; + +import * as React from 'react'; +import { motion } from 'framer-motion'; +import { Workflow, Binary, Thermometer, Layers } from 'lucide-react'; + +export function Algorithms(): React.ReactElement { + return ( +
+
+
+
+

— Under the hood

+

+ Two stages, one winner. +

+

+ FleetPilot pairs Adaptive Large Neighborhood Search with a + Biased Random-Key Genetic Algorithm — each stage sharpening the + other. +

+
+ +
+ } + /> + } + /> +
+ +
+ + + + +
+
+
+ ); +} + +function AlgorithmCard({ + tag, + name, + subtitle, + description, + points, + accent, + icon: Icon, + visual, +}: { + tag: string; + name: string; + subtitle: string; + description: string; + points: [string, string][]; + accent: string; + icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; + visual: React.ReactNode; +}): React.ReactElement { + return ( + +