2566 lines
95 KiB
TypeScript
2566 lines
95 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type CSSProperties,
|
||
type FormEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
type ReactNode,
|
||
} from "react";
|
||
import {
|
||
ArrowDown,
|
||
ArrowRight,
|
||
ArrowUp,
|
||
ArrowUpRight,
|
||
Bot,
|
||
Brain,
|
||
Building2,
|
||
ChartLine,
|
||
Check,
|
||
Cloud,
|
||
Cpu,
|
||
Database,
|
||
Eye,
|
||
FlaskConical,
|
||
GitBranch,
|
||
Hammer,
|
||
Handshake,
|
||
HeartPulse,
|
||
Landmark,
|
||
Layers,
|
||
Leaf,
|
||
Mail,
|
||
MapPin,
|
||
Megaphone,
|
||
Network,
|
||
Palette,
|
||
Rocket,
|
||
Scale,
|
||
Search,
|
||
ShoppingBag,
|
||
Sparkles,
|
||
Sprout,
|
||
Target,
|
||
TrendingUp,
|
||
Truck,
|
||
Users,
|
||
UtensilsCrossed,
|
||
Wallet,
|
||
Workflow,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { CONTACT_EMAIL, INTERESTS, sendContactEnquiry, type ContactInput } from "@/lib/contact";
|
||
// Brand assets cut from kapfoundry-logo-1.png (emblem, wordmark, full lockup).
|
||
import logoMark from "@/assets/kapfoundry-mark.webp";
|
||
import logoWordmark from "@/assets/kapfoundry-wordmark.webp";
|
||
import logoFull from "@/assets/kapfoundry-logo-full.webp";
|
||
import heroBg from "@/assets/hero-bg.jpg";
|
||
import vLogistics from "@/assets/vertical-logistics.jpg";
|
||
import vRetail from "@/assets/vertical-retail.jpg";
|
||
import vFintech from "@/assets/vertical-fintech.jpg";
|
||
import vAgri from "@/assets/vertical-agri.jpg";
|
||
import vFoodtech from "@/assets/vertical-foodtech.jpg";
|
||
import vCarbon from "@/assets/vertical-carbon.jpg";
|
||
import vHealthtech from "@/assets/vertical-healthtech.jpg";
|
||
|
||
// Home-page sections are "/#id" so they work from any page; About is its own route.
|
||
const NAV = [
|
||
{ label: "Studio", href: "/#studio" },
|
||
{ label: "Framework", href: "/#framework" },
|
||
{ label: "Portfolio", href: "/#portfolio" },
|
||
{ label: "AI", href: "/#ai" },
|
||
{ label: "Investors", href: "/#investors" },
|
||
{ label: "About", href: "/about" },
|
||
{ label: "Contact", href: "/#contact" },
|
||
];
|
||
|
||
export type SitePageName = "home" | "about";
|
||
|
||
/** Section id a nav link points at ("" for page links). */
|
||
function sectionOf(href: string) {
|
||
return href.split("#")[1] ?? "";
|
||
}
|
||
|
||
const VERTICALS = [
|
||
{
|
||
n: "01",
|
||
name: "Logistics Technology",
|
||
short: "Logistics",
|
||
icon: Truck,
|
||
tag: "Fragmented, expensive, inefficient",
|
||
img: vLogistics,
|
||
problem: [
|
||
"Last-mile delivery",
|
||
"Fleet optimization",
|
||
"Air cargo coordination",
|
||
"Route planning",
|
||
"Warehouse visibility",
|
||
"Freight pricing and supply-chain transparency",
|
||
],
|
||
solution: [
|
||
"Route optimization",
|
||
"Real-time fleet intelligence",
|
||
"Air cargo platforms",
|
||
"Warehouse automation and delivery intelligence",
|
||
"Predictive logistics",
|
||
"Carbon-aware route planning",
|
||
],
|
||
example: "DoorMile Wings. An AI-powered domestic air cargo and express logistics network.",
|
||
},
|
||
{
|
||
n: "02",
|
||
name: "Retail Technology",
|
||
short: "Retail",
|
||
icon: ShoppingBag,
|
||
tag: "Help merchants compete",
|
||
img: vRetail,
|
||
problem: [
|
||
"Poor loyalty programs",
|
||
"Lack of customer insights",
|
||
"No digital engagement",
|
||
"Ineffective promotions",
|
||
"Limited analytics",
|
||
],
|
||
solution: [
|
||
"Customer loyalty and rewards platforms",
|
||
"AI marketing",
|
||
"Consumer and merchant analytics",
|
||
"Store performance analytics",
|
||
"Digital commerce",
|
||
],
|
||
example: "Loyaly.ai. An AI loyalty and engagement platform for independent merchants.",
|
||
},
|
||
{
|
||
n: "03",
|
||
name: "Financial Technology",
|
||
short: "Fintech",
|
||
icon: Wallet,
|
||
tag: "Millions underserved",
|
||
img: vFintech,
|
||
problem: [
|
||
"Lending access",
|
||
"Payments",
|
||
"Credit visibility",
|
||
"Collections",
|
||
"Merchant financing",
|
||
],
|
||
solution: [
|
||
"Embedded finance",
|
||
"Lending infrastructure and merchant credit",
|
||
"Payment platforms",
|
||
"Expense intelligence and financial automation",
|
||
"Risk analytics",
|
||
],
|
||
},
|
||
{
|
||
n: "04",
|
||
name: "Agriculture Technology",
|
||
short: "Agritech",
|
||
icon: Sprout,
|
||
tag: "Connect farms to markets",
|
||
img: vAgri,
|
||
problem: [
|
||
"Low productivity",
|
||
"Market inefficiencies",
|
||
"Price uncertainty",
|
||
"Limited technology adoption",
|
||
"Climate risks",
|
||
],
|
||
solution: [
|
||
"Precision farming",
|
||
"AI crop monitoring",
|
||
"Marketplace platforms",
|
||
"Supply-chain digitization",
|
||
"Yield prediction and farm analytics",
|
||
],
|
||
},
|
||
{
|
||
n: "05",
|
||
name: "FoodTech",
|
||
short: "FoodTech",
|
||
icon: UtensilsCrossed,
|
||
tag: "Powering the future of food",
|
||
img: vFoodtech,
|
||
problem: [
|
||
"Customer retention",
|
||
"Food waste",
|
||
"Inventory management",
|
||
"Delivery inefficiencies",
|
||
"Demand forecasting",
|
||
],
|
||
solution: [
|
||
"Smart POS",
|
||
"AI kitchen analytics",
|
||
"Restaurant CRM and customer rewards",
|
||
"Food-delivery optimization",
|
||
"Inventory intelligence",
|
||
],
|
||
},
|
||
{
|
||
n: "06",
|
||
name: "Carbon Technology",
|
||
short: "Carbon",
|
||
icon: Leaf,
|
||
tag: "Sustainability, made measurable",
|
||
img: vCarbon,
|
||
problem: [
|
||
"Accurate emission measurement",
|
||
"Meeting sustainability goals",
|
||
"Regulatory reporting",
|
||
"Green supply-chain visibility",
|
||
"Reliable ESG tooling",
|
||
],
|
||
solution: [
|
||
"Carbon accounting",
|
||
"ESG platforms",
|
||
"Emission monitoring",
|
||
"Carbon credit marketplaces",
|
||
"Sustainability dashboards",
|
||
"Green supply-chain intelligence",
|
||
],
|
||
},
|
||
{
|
||
n: "07",
|
||
name: "HealthTech",
|
||
short: "HealthTech",
|
||
icon: HeartPulse,
|
||
tag: "Care that reaches everyone",
|
||
img: vHealthtech,
|
||
problem: [
|
||
"Access to quality care",
|
||
"Fragmented patient records",
|
||
"Rising treatment costs",
|
||
"Clinic and hospital efficiency",
|
||
"Preventive health at scale",
|
||
],
|
||
solution: [
|
||
"Digital health platforms",
|
||
"AI diagnostics and triage",
|
||
"Clinic operations software",
|
||
"Remote care and telemedicine",
|
||
"Health data intelligence",
|
||
"Patient engagement tools",
|
||
],
|
||
},
|
||
];
|
||
|
||
const FRAMEWORK = [
|
||
{
|
||
n: "01",
|
||
title: "Discover",
|
||
icon: Search,
|
||
body: "Identify billion-dollar industry problems.",
|
||
},
|
||
{
|
||
n: "02",
|
||
title: "Validate",
|
||
icon: FlaskConical,
|
||
body: "Customer interviews, market research, prototype testing.",
|
||
},
|
||
{ n: "03", title: "Build", icon: Hammer, body: "Scalable products on AI-first architecture." },
|
||
{
|
||
n: "04",
|
||
title: "Launch",
|
||
icon: Rocket,
|
||
body: "Acquire customers and reach product-market fit.",
|
||
},
|
||
{ n: "05", title: "Scale", icon: TrendingUp, body: "Expand nationally and internationally." },
|
||
{
|
||
n: "06",
|
||
title: "Spin-Off",
|
||
icon: GitBranch,
|
||
body: "Grow into independent portfolio companies.",
|
||
},
|
||
];
|
||
|
||
const STACK: { label: string; icon: LucideIcon }[] = [
|
||
{ label: "AI and Engineering", icon: Cpu },
|
||
{ label: "Product Design", icon: Palette },
|
||
{ label: "Cloud Infrastructure", icon: Cloud },
|
||
{ label: "Legal and Compliance", icon: Scale },
|
||
{ label: "Finance", icon: Wallet },
|
||
{ label: "Marketing", icon: Megaphone },
|
||
{ label: "Sales Enablement", icon: Handshake },
|
||
{ label: "Growth Teams", icon: Users },
|
||
{ label: "Data Science", icon: Database },
|
||
{ label: "Corporate Governance", icon: Landmark },
|
||
];
|
||
|
||
const AI_CAPS: { label: string; icon: LucideIcon }[] = [
|
||
{ label: "Computer Vision", icon: Eye },
|
||
{ label: "Agentic AI", icon: Bot },
|
||
{ label: "Enterprise AI", icon: Building2 },
|
||
{ label: "Predictive Analytics", icon: ChartLine },
|
||
{ label: "LLM Applications", icon: Sparkles },
|
||
{ label: "AI Automation", icon: Workflow },
|
||
{ label: "Multi-Agent Systems", icon: Network },
|
||
{ label: "Decision Intelligence", icon: Brain },
|
||
];
|
||
|
||
const MISSION = [
|
||
"Identify large market inefficiencies.",
|
||
"Build AI-first technology companies.",
|
||
"Scale them with shared resources.",
|
||
"Create long-term enterprise value.",
|
||
"Deliver diversified returns for investors.",
|
||
"Empower entrepreneurs with world-class execution.",
|
||
];
|
||
|
||
const INVESTOR_REASONS = [
|
||
"Diversified technology portfolio",
|
||
"Reduced single-company risk",
|
||
"AI-first businesses",
|
||
"Experienced venture-building team",
|
||
"Multiple growth opportunities",
|
||
"Shared engineering and operations",
|
||
"Long-term enterprise value",
|
||
"Repeatable creation framework",
|
||
];
|
||
|
||
// Team portraits live in public/assets, in card order: 4.jpeg, 1.png, 2.png, 3.jpg.
|
||
// Add each person's name when ready (cards hide the name while it is empty).
|
||
// Every card is labelled with its role and numbered 1-4 in card order.
|
||
const TEAM = [
|
||
{
|
||
name: "",
|
||
role: "Venture Builder",
|
||
img: "/assets/4.jpeg",
|
||
},
|
||
{
|
||
name: "",
|
||
role: "Venture Builder",
|
||
img: "/assets/1.png",
|
||
},
|
||
{
|
||
name: "",
|
||
role: "Venture Builder",
|
||
img: "/assets/2.png",
|
||
},
|
||
{
|
||
name: "",
|
||
role: "Venture Builder",
|
||
img: "/assets/3.jpg",
|
||
},
|
||
];
|
||
|
||
const TICKER = [...VERTICALS.map((v) => v.short), "AI-First", "Build Bold"];
|
||
|
||
/**
|
||
* Scroll-reveal styles (see styles.css):
|
||
* - "up": fade and rise (default)
|
||
* - "mask": text wipes up into view, for headlines
|
||
* - "clip": image frame opens from an inset while the photo settles, for media
|
||
* - "stagger": the element's direct children rise one after another
|
||
*/
|
||
type RevealVariant = "up" | "mask" | "clip" | "stagger";
|
||
|
||
/** Props that opt an element into the scroll-reveal animation. */
|
||
function reveal(variant: RevealVariant = "up", delay = 0) {
|
||
return {
|
||
"data-reveal": variant,
|
||
style: { "--reveal-delay": `${delay}ms` } as CSSProperties,
|
||
};
|
||
}
|
||
|
||
const REVEAL_BATCH_STEP = 90;
|
||
const REVEAL_SETTLE_MS = 1700;
|
||
|
||
function useScrollReveal() {
|
||
useEffect(() => {
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||
const timers: number[] = [];
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
// Elements that arrive together (a grid row, a fast scroll) cascade in reading order.
|
||
const arriving = entries
|
||
.filter((e) => e.isIntersecting)
|
||
.sort(
|
||
(a, b) =>
|
||
a.boundingClientRect.top - b.boundingClientRect.top ||
|
||
a.boundingClientRect.left - b.boundingClientRect.left,
|
||
);
|
||
arriving.forEach((entry, i) => {
|
||
const el = entry.target as HTMLElement;
|
||
const batchDelay = Math.min(i, 5) * REVEAL_BATCH_STEP;
|
||
el.style.setProperty("--batch-delay", `${batchDelay}ms`);
|
||
el.classList.add("is-visible");
|
||
io.unobserve(el);
|
||
// Once settled, drop the reveal classes so the element's own hover transitions return.
|
||
const own = parseFloat(el.style.getPropertyValue("--reveal-delay")) || 0;
|
||
const stagger = el.dataset.reveal === "stagger" ? el.children.length * 80 : 0;
|
||
timers.push(
|
||
window.setTimeout(
|
||
() => el.classList.remove("reveal-pending", "is-visible"),
|
||
own + batchDelay + stagger + REVEAL_SETTLE_MS,
|
||
),
|
||
);
|
||
});
|
||
},
|
||
{ rootMargin: "0px 0px -10% 0px" },
|
||
);
|
||
for (const el of document.querySelectorAll<HTMLElement>("[data-reveal]")) {
|
||
// Leave anything already on screen alone so it never flickers after hydration.
|
||
if (el.getBoundingClientRect().top < window.innerHeight) continue;
|
||
if (el.dataset.reveal === "stagger") {
|
||
Array.from(el.children).forEach((child, i) =>
|
||
(child as HTMLElement).style.setProperty("--i", String(i)),
|
||
);
|
||
}
|
||
el.classList.add("reveal-pending");
|
||
io.observe(el);
|
||
}
|
||
return () => {
|
||
io.disconnect();
|
||
timers.forEach(clearTimeout);
|
||
};
|
||
}, []);
|
||
}
|
||
|
||
function useScrolledPast(offset: number) {
|
||
const [past, setPast] = useState(false);
|
||
useEffect(() => {
|
||
const on = () => setPast(window.scrollY > offset);
|
||
on();
|
||
window.addEventListener("scroll", on, { passive: true });
|
||
return () => window.removeEventListener("scroll", on);
|
||
}, [offset]);
|
||
return past;
|
||
}
|
||
|
||
/**
|
||
* True while the visitor is scrolling down past the top of the page; false as soon as they
|
||
* scroll back up. Small movements are ignored so the header does not flicker.
|
||
*/
|
||
function useHideOnScroll(topZone = 120, tolerance = 6) {
|
||
const [hidden, setHidden] = useState(false);
|
||
useEffect(() => {
|
||
let last = window.scrollY;
|
||
let frame = 0;
|
||
const update = () => {
|
||
frame = 0;
|
||
const y = window.scrollY;
|
||
const delta = y - last;
|
||
if (y < topZone) {
|
||
setHidden(false);
|
||
last = y;
|
||
} else if (Math.abs(delta) > tolerance) {
|
||
setHidden(delta > 0);
|
||
last = y;
|
||
}
|
||
};
|
||
const onScroll = () => {
|
||
if (!frame) frame = requestAnimationFrame(update);
|
||
};
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => {
|
||
window.removeEventListener("scroll", onScroll);
|
||
cancelAnimationFrame(frame);
|
||
};
|
||
}, [topZone, tolerance]);
|
||
/** Bring the header back until the next downward scroll. */
|
||
const reveal = useCallback(() => setHidden(false), []);
|
||
return [hidden, reveal] as const;
|
||
}
|
||
|
||
const SECTION_IDS = ["top", ...NAV.map((n) => sectionOf(n.href)).filter(Boolean)];
|
||
|
||
/** Id of the section crossing the middle of the viewport, for highlighting the nav. */
|
||
function useActiveSection() {
|
||
const [active, setActive] = useState("top");
|
||
useEffect(() => {
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) if (entry.isIntersecting) setActive(entry.target.id);
|
||
},
|
||
{ rootMargin: "-45% 0px -50% 0px" },
|
||
);
|
||
for (const id of SECTION_IDS) {
|
||
const el = document.getElementById(id);
|
||
if (el) io.observe(el);
|
||
}
|
||
return () => io.disconnect();
|
||
}, []);
|
||
return active;
|
||
}
|
||
|
||
/** Staggered entrance for hero content (pairs with the `rise-in` class). */
|
||
function rise(delay: number) {
|
||
return { style: { "--rise-delay": `${delay}ms` } as CSSProperties };
|
||
}
|
||
|
||
function Accent({ children }: { children: ReactNode }) {
|
||
return <span className="text-primary">{children}</span>;
|
||
}
|
||
|
||
function Eyebrow({ icon: Icon, children }: { icon: LucideIcon; children: ReactNode }) {
|
||
return (
|
||
<span className="eyebrow">
|
||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function IconTile({ icon: Icon, className = "" }: { icon: LucideIcon; className?: string }) {
|
||
return (
|
||
<span
|
||
className={`inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-primary/15 text-primary ${className}`}
|
||
>
|
||
<Icon className="h-5 w-5" aria-hidden />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** Section heading: headline with an optional body line stacked beneath it. Labels are rationed. */
|
||
function SectionHeader({
|
||
eyebrow,
|
||
icon,
|
||
title,
|
||
children,
|
||
compact = false,
|
||
}: {
|
||
eyebrow?: string;
|
||
icon?: LucideIcon;
|
||
title: ReactNode;
|
||
children?: ReactNode;
|
||
/** Smaller heading for long titles. */
|
||
compact?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="max-w-3xl">
|
||
{eyebrow && icon ? (
|
||
<div {...reveal()} className="mb-5">
|
||
<Eyebrow icon={icon}>{eyebrow}</Eyebrow>
|
||
</div>
|
||
) : null}
|
||
<h2
|
||
{...reveal("mask", 120)}
|
||
className={`font-display font-bold leading-[1.04] ${
|
||
compact ? "text-3xl md:text-4xl lg:text-5xl" : "text-4xl md:text-5xl lg:text-6xl"
|
||
}`}
|
||
>
|
||
{title}
|
||
</h2>
|
||
{children ? (
|
||
<div
|
||
{...reveal("up", 260)}
|
||
className="mt-6 max-w-[65ch] text-lg leading-relaxed text-muted-foreground"
|
||
>
|
||
{children}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Nav({ page }: { page: SitePageName }) {
|
||
const scrolled = useScrolledPast(20);
|
||
const [scrollingDown, revealHeader] = useHideOnScroll();
|
||
const active = useActiveSection();
|
||
const [open, setOpen] = useState(false);
|
||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||
const overlayRef = useRef<HTMLDivElement>(null);
|
||
|
||
// The overlay grows out of (and shrinks back into) the menu button.
|
||
const setOrigin = () => {
|
||
const btn = toggleRef.current;
|
||
const overlay = overlayRef.current;
|
||
if (!btn || !overlay) return;
|
||
const r = btn.getBoundingClientRect();
|
||
overlay.style.setProperty("--menu-x", `${r.left + r.width / 2}px`);
|
||
overlay.style.setProperty("--menu-y", `${r.top + r.height / 2}px`);
|
||
};
|
||
const toggle = () => {
|
||
setOrigin();
|
||
setOpen((o) => !o);
|
||
};
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
document.documentElement.classList.add("menu-open");
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
setOpen(false);
|
||
toggleRef.current?.focus();
|
||
return;
|
||
}
|
||
if (e.key !== "Tab" || !overlayRef.current || !toggleRef.current) return;
|
||
// Keep keyboard focus inside the open menu (the toggle button plus the menu's links).
|
||
const items = [
|
||
toggleRef.current,
|
||
...overlayRef.current.querySelectorAll<HTMLElement>("a[href], button"),
|
||
];
|
||
const first = items[0];
|
||
const last = items[items.length - 1];
|
||
if (e.shiftKey && document.activeElement === first) {
|
||
e.preventDefault();
|
||
last.focus();
|
||
} else if (!e.shiftKey && document.activeElement === last) {
|
||
e.preventDefault();
|
||
first.focus();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
// Close if the viewport grows to desktop, where the menu does not exist.
|
||
const desktop = window.matchMedia("(min-width: 1024px)");
|
||
const onDesktop = () => desktop.matches && setOpen(false);
|
||
desktop.addEventListener("change", onDesktop);
|
||
return () => {
|
||
document.documentElement.classList.remove("menu-open");
|
||
window.removeEventListener("keydown", onKey);
|
||
desktop.removeEventListener("change", onDesktop);
|
||
};
|
||
}, [open]);
|
||
|
||
// After the menu closes, keep the header (and its menu button) in view.
|
||
useEffect(() => {
|
||
if (!open) revealHeader();
|
||
}, [open, revealHeader]);
|
||
|
||
const solid = scrolled && !open;
|
||
const hidden = scrollingDown && !open;
|
||
// Home highlights the section in view; other pages highlight their own link.
|
||
const isCurrent = (href: string) =>
|
||
page === "home" ? active === sectionOf(href) : href === `/${page}`;
|
||
return (
|
||
<>
|
||
<header
|
||
className={`fixed inset-x-0 top-0 z-50 transition-[transform,background-color,border-color] duration-500 focus-within:translate-y-0 motion-reduce:transition-none ${EASE_STAGE} ${
|
||
hidden ? "-translate-y-full" : "translate-y-0"
|
||
} ${
|
||
solid
|
||
? "border-b border-border bg-background/85 backdrop-blur-md"
|
||
: "border-b border-transparent bg-transparent"
|
||
}`}
|
||
>
|
||
<div
|
||
aria-hidden
|
||
className={`scroll-progress absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-primary/40 via-primary to-primary/40 transition-opacity duration-300 ${
|
||
open ? "opacity-0" : ""
|
||
}`}
|
||
/>
|
||
<div className="container-x flex h-18 items-center justify-between gap-4">
|
||
<a
|
||
href={page === "home" ? "#top" : "/"}
|
||
aria-label="KapFoundry home"
|
||
className="flex shrink-0 items-center gap-2.5"
|
||
onClick={() => setOpen(false)}
|
||
>
|
||
<img src={logoMark} alt="" width={201} height={192} className="h-10 w-auto sm:h-11" />
|
||
<img
|
||
src={logoWordmark}
|
||
alt=""
|
||
width={1041}
|
||
height={72}
|
||
className="hidden h-3.5 w-auto min-[400px]:block sm:h-4 lg:hidden xl:block"
|
||
/>
|
||
</a>
|
||
<nav
|
||
aria-label="Primary"
|
||
className={`hidden items-center gap-1 rounded-full border border-white/10 p-1.5 transition-colors duration-300 lg:flex ${
|
||
solid ? "bg-white/[0.03]" : "bg-white/5 backdrop-blur-md"
|
||
}`}
|
||
>
|
||
{NAV.map((n) => {
|
||
const current = isCurrent(n.href);
|
||
return (
|
||
<a
|
||
key={n.href}
|
||
href={n.href}
|
||
aria-current={current ? "true" : undefined}
|
||
className={`rounded-full px-3 py-2 font-display text-xs font-semibold uppercase tracking-wider transition-colors duration-300 hover:bg-white/10 hover:text-foreground xl:px-4 ${
|
||
current ? "bg-primary/15 text-primary" : "text-muted-foreground"
|
||
}`}
|
||
>
|
||
{n.label}
|
||
</a>
|
||
);
|
||
})}
|
||
</nav>
|
||
<a
|
||
href="#investors"
|
||
className="press hidden items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-display text-xs font-semibold uppercase tracking-wider text-primary-foreground hover:brightness-110 lg:inline-flex"
|
||
>
|
||
Invest with us
|
||
</a>
|
||
|
||
{/* Two bars that cross into an X; the label swaps on tablets. */}
|
||
<button
|
||
ref={toggleRef}
|
||
type="button"
|
||
onClick={toggle}
|
||
aria-expanded={open}
|
||
aria-controls="site-menu"
|
||
aria-label={open ? "Close menu" : "Open menu"}
|
||
className={`press group flex h-11 items-center gap-3 rounded-full border pl-4 pr-3 text-foreground transition-colors duration-300 lg:hidden ${
|
||
open ? "border-primary/50 bg-primary/10" : "border-border bg-ink/40 backdrop-blur-md"
|
||
}`}
|
||
>
|
||
<span className="hidden font-display text-xs font-semibold uppercase tracking-[0.16em] sm:block">
|
||
{open ? "Close" : "Menu"}
|
||
</span>
|
||
<span className="relative block h-5 w-5" aria-hidden>
|
||
<span
|
||
className={`absolute left-0 top-1/2 h-[1.5px] w-5 rounded-full bg-current transition-transform duration-500 ${EASE_STAGE} ${
|
||
open ? "rotate-45" : "-translate-y-[4px]"
|
||
}`}
|
||
/>
|
||
<span
|
||
className={`absolute left-0 top-1/2 h-[1.5px] rounded-full bg-current transition-all duration-500 ${EASE_STAGE} ${
|
||
open ? "w-5 -rotate-45" : "w-3.5 translate-y-[4px] group-hover:w-5"
|
||
}`}
|
||
/>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Full-screen menu for phones and tablets: a circle that opens from the menu button. */}
|
||
<div
|
||
id="site-menu"
|
||
ref={overlayRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Site menu"
|
||
inert={!open}
|
||
className={`menu-overlay fixed inset-0 z-40 overflow-y-auto bg-ink lg:hidden ${open ? "is-open" : ""}`}
|
||
>
|
||
<div className="mesh-gold absolute inset-0 opacity-40" aria-hidden />
|
||
<div className="dot-grid absolute inset-0" aria-hidden />
|
||
<div className="container-x relative flex min-h-full flex-col gap-10 pb-10 pt-28 md:grid md:grid-cols-12 md:items-end md:gap-12 md:pb-14">
|
||
<nav aria-label="Mobile" className="md:col-span-8">
|
||
<ul>
|
||
{NAV.map((n, i) => {
|
||
const current = isCurrent(n.href);
|
||
return (
|
||
<li key={n.href} className="overflow-hidden border-b border-white/10">
|
||
<a
|
||
href={n.href}
|
||
onClick={() => setOpen(false)}
|
||
aria-current={current ? "true" : undefined}
|
||
style={{ "--i": i } as CSSProperties}
|
||
className="menu-link group flex items-center gap-4 py-3 sm:py-4"
|
||
>
|
||
<span className="w-7 font-display text-xs font-semibold tabular-nums text-primary/70">
|
||
{String(i + 1).padStart(2, "0")}
|
||
</span>
|
||
<span
|
||
className={`font-display text-3xl font-bold tracking-tight transition-[color,transform] duration-500 group-hover:translate-x-2 sm:text-5xl md:text-6xl ${EASE_STAGE} ${
|
||
current ? "text-primary" : "text-foreground"
|
||
}`}
|
||
>
|
||
{n.label}
|
||
</span>
|
||
<ArrowUpRight
|
||
aria-hidden
|
||
className="ml-auto h-6 w-6 text-primary opacity-0 transition-all duration-500 group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:opacity-100 sm:-translate-x-2"
|
||
/>
|
||
</a>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</nav>
|
||
|
||
<div className="menu-foot space-y-6 md:col-span-4 md:pb-3">
|
||
<a
|
||
href="#investors"
|
||
onClick={() => setOpen(false)}
|
||
className="press group inline-flex w-full items-center justify-center gap-2 rounded-full bg-primary px-7 py-4 font-display text-sm font-semibold text-primary-foreground hover:brightness-110 md:w-auto"
|
||
>
|
||
Invest with us
|
||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||
</a>
|
||
<div className="space-y-1 text-sm text-muted-foreground">
|
||
<a
|
||
href={`mailto:${CONTACT_EMAIL}`}
|
||
className="block text-foreground transition-colors hover:text-primary"
|
||
>
|
||
{CONTACT_EMAIL}
|
||
</a>
|
||
<div>Bangalore, India</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Hero() {
|
||
const markRef = useRef<HTMLDivElement>(null);
|
||
|
||
// The brand mark leans toward the cursor: quiet feedback, written to CSS vars (no re-render).
|
||
const tilt = (e: ReactPointerEvent<HTMLElement>) => {
|
||
const mark = markRef.current;
|
||
if (!mark || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||
const r = e.currentTarget.getBoundingClientRect();
|
||
const x = (e.clientX - r.left) / r.width - 0.5;
|
||
const y = (e.clientY - r.top) / r.height - 0.5;
|
||
mark.style.setProperty("--rx", `${(-y * 10).toFixed(2)}deg`);
|
||
mark.style.setProperty("--ry", `${(x * 16).toFixed(2)}deg`);
|
||
};
|
||
const resetTilt = () => {
|
||
markRef.current?.style.setProperty("--rx", "0deg");
|
||
markRef.current?.style.setProperty("--ry", "0deg");
|
||
};
|
||
|
||
return (
|
||
// .hero-timeline drives .hero-media / .hero-copy as the hero scrolls away (styles.css).
|
||
<section
|
||
id="top"
|
||
onPointerMove={tilt}
|
||
onPointerLeave={resetTilt}
|
||
className="hero-timeline relative flex min-h-[100svh] items-center overflow-hidden"
|
||
>
|
||
<div className="hero-media absolute inset-0">
|
||
<img
|
||
src={heroBg}
|
||
alt=""
|
||
aria-hidden
|
||
fetchPriority="high"
|
||
className="hero-zoom absolute inset-0 h-full w-full object-cover opacity-60"
|
||
/>
|
||
</div>
|
||
<div className="absolute inset-0 bg-gradient-to-r from-ink/90 via-ink/60 to-ink/30" />
|
||
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-background to-transparent" />
|
||
|
||
<div className="hero-copy container-x relative grid w-full items-center gap-12 pb-20 pt-28 lg:grid-cols-12">
|
||
<div className="lg:col-span-8">
|
||
<h1
|
||
{...rise(150)}
|
||
className="rise-in font-display text-5xl font-extrabold leading-[0.98] sm:text-6xl lg:text-[3.5rem] xl:text-7xl"
|
||
>
|
||
Build Bold.
|
||
<br />
|
||
<Accent>Build the Future.</Accent>
|
||
</h1>
|
||
<p
|
||
{...rise(400)}
|
||
className="rise-in mt-8 max-w-xl text-lg leading-relaxed text-muted-foreground md:text-xl"
|
||
>
|
||
A venture studio and technology holding company that engineers category-defining,
|
||
AI-first companies, before they even exist.
|
||
</p>
|
||
<div {...rise(550)} className="rise-in mt-10 flex flex-wrap gap-4">
|
||
<a
|
||
href="#investors"
|
||
className="press group inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 font-display text-sm font-semibold text-primary-foreground shadow-[0_10px_40px_-10px] shadow-primary/60 hover:-translate-y-0.5 hover:brightness-110"
|
||
>
|
||
Invest with us
|
||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||
</a>
|
||
<a
|
||
href="#portfolio"
|
||
className="press inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-7 py-3.5 font-display text-sm font-semibold text-foreground backdrop-blur-md hover:-translate-y-0.5 hover:border-white/30 hover:bg-white/10"
|
||
>
|
||
Explore the portfolio
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div {...rise(700)} className="rise-in relative hidden justify-end lg:col-span-4 lg:flex">
|
||
<div ref={markRef} className="hero-mark relative">
|
||
<div className="glow absolute -inset-20" />
|
||
<img
|
||
src={logoFull}
|
||
alt=""
|
||
aria-hidden
|
||
width={900}
|
||
height={697}
|
||
className="relative w-[min(28rem,100%)] drop-shadow-[0_40px_60px_oklch(0.05_0.01_60/0.7)]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Ticker() {
|
||
const items = (hidden: boolean) => (
|
||
<div className="flex shrink-0 items-center" aria-hidden={hidden || undefined}>
|
||
{TICKER.map((t) => (
|
||
<span key={t} className="flex items-center">
|
||
<span className="px-8 font-display text-2xl font-bold uppercase tracking-tight md:text-4xl">
|
||
{t}
|
||
</span>
|
||
<Sparkles className="h-5 w-5 text-primary" aria-hidden />
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
return (
|
||
<div className="marquee overflow-hidden border-y border-border bg-ink py-6 md:py-8">
|
||
<div className="marquee-track">
|
||
{items(false)}
|
||
{items(true)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WhoWeAre() {
|
||
const cards = [
|
||
{
|
||
t: "Venture Studio",
|
||
icon: Building2,
|
||
d: "Companies are systematically created, validated and scaled on shared infrastructure, not simply funded after formation.",
|
||
},
|
||
{
|
||
t: "Holding Company",
|
||
icon: Layers,
|
||
d: "Every venture strengthens the ecosystem while remaining capable of becoming an independent, high-growth business.",
|
||
},
|
||
{
|
||
t: "Built to Compound",
|
||
icon: TrendingUp,
|
||
d: "Build faster, reduce risk, and maximize long-term value with one shared engine behind many companies.",
|
||
},
|
||
];
|
||
return (
|
||
<section
|
||
id="studio"
|
||
className="sheet-enter theme-light section-y bg-background text-foreground"
|
||
>
|
||
<div className="container-x">
|
||
<SectionHeader
|
||
title={
|
||
<>
|
||
A venture studio and <Accent>technology holding company.</Accent>
|
||
</>
|
||
}
|
||
>
|
||
<p>
|
||
We identify large-scale industry problems, validate market opportunities, create
|
||
technology-first companies, and scale them into category leaders, designed as a
|
||
portfolio, not a single startup.
|
||
</p>
|
||
</SectionHeader>
|
||
|
||
{/* One tall feature tile beside two stacked tiles, each with its own surface. */}
|
||
<div className="mt-16 grid gap-6 md:grid-cols-2 md:grid-rows-2">
|
||
{cards.map((c, i) => {
|
||
const feature = i === 0;
|
||
const gold = i === 1;
|
||
return (
|
||
<div
|
||
key={c.t}
|
||
{...reveal()}
|
||
className={`theme-dark group relative flex flex-col overflow-hidden rounded-3xl p-8 transition-transform duration-300 [clip-path:inset(0_round_1.5rem)] hover:-translate-y-1 md:p-10 ${
|
||
gold ? "bg-primary text-primary-foreground" : "bg-background text-foreground"
|
||
} ${feature ? "min-h-[360px] justify-end md:row-span-2 md:p-12" : ""}`}
|
||
>
|
||
{feature ? (
|
||
<>
|
||
<div aria-hidden className="mesh-gold media-parallax absolute inset-0" />
|
||
<div aria-hidden className="dot-grid absolute inset-0" />
|
||
<div
|
||
aria-hidden
|
||
className="absolute inset-0 bg-gradient-to-t from-ink via-ink/40 to-transparent"
|
||
/>
|
||
</>
|
||
) : gold ? null : (
|
||
<div className="glow absolute -right-20 -top-20 h-64 w-64 opacity-50 transition-opacity duration-500 group-hover:opacity-100" />
|
||
)}
|
||
<div className="relative">
|
||
<IconTile
|
||
icon={c.icon}
|
||
className={gold ? "bg-primary-foreground/10 text-primary-foreground" : ""}
|
||
/>
|
||
<h3
|
||
className={`font-display font-bold ${
|
||
feature ? "mt-8 text-3xl md:text-4xl" : "mt-8 text-2xl"
|
||
}`}
|
||
>
|
||
{c.t}
|
||
</h3>
|
||
<p
|
||
className={`mt-4 max-w-[45ch] leading-relaxed ${
|
||
gold ? "text-primary-foreground/80" : "text-muted-foreground"
|
||
} ${feature ? "text-lg" : ""}`}
|
||
>
|
||
{c.d}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function VisionMission() {
|
||
return (
|
||
<section className="section-y relative overflow-hidden bg-ink">
|
||
<div className="glow glow-drift absolute -right-48 top-0 h-[38rem] w-[38rem] opacity-40" />
|
||
<div className="container-x relative">
|
||
<div className="max-w-4xl" {...reveal("stagger")}>
|
||
<Eyebrow icon={Eye}>Vision</Eyebrow>
|
||
<p className="mt-6 font-display text-3xl font-bold leading-[1.1] tracking-tight md:text-5xl">
|
||
To become India's most <Accent>influential venture studio</Accent>, building
|
||
category-defining companies that solve real-world problems for billions.
|
||
</p>
|
||
<p className="mt-8 max-w-[65ch] text-lg leading-relaxed text-muted-foreground">
|
||
The next generation of global companies will not emerge by chance. They will be
|
||
engineered through research, technology, and repeatable company-building.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-20">
|
||
<div {...reveal()}>
|
||
<Eyebrow icon={Target}>Our mission</Eyebrow>
|
||
</div>
|
||
{/* Each line's gold rule draws in turn, so the six commitments read as a sequence. */}
|
||
<ol
|
||
className="mt-8 grid gap-x-12 md:grid-cols-2 lg:grid-cols-3"
|
||
{...reveal("stagger", 150)}
|
||
>
|
||
{MISSION.map((m, i) => (
|
||
<li key={m} className="group relative pb-8 pt-6">
|
||
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-border" />
|
||
<span
|
||
aria-hidden
|
||
data-draw
|
||
className="absolute inset-x-0 top-0 h-px origin-left bg-primary/70"
|
||
/>
|
||
<span className="font-display text-3xl font-bold tabular-nums text-primary/40 transition-colors duration-500 group-hover:text-primary">
|
||
{String(i + 1).padStart(2, "0")}
|
||
</span>
|
||
<p className="mt-3 max-w-[32ch] text-lg leading-snug">{m}</p>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** Splits a sentence into words that brighten as they cross the viewport (see .fill-word). */
|
||
function FillWords({ text, className = "" }: { text: string; className?: string }) {
|
||
return (
|
||
<>
|
||
{text.split(" ").map((word, i) => (
|
||
<span key={i} className={`fill-word ${className}`}>
|
||
{word}{" "}
|
||
</span>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Different() {
|
||
return (
|
||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||
<div className="container-x">
|
||
<p className="max-w-5xl font-display text-4xl font-bold leading-[1.06] tracking-tight md:text-6xl">
|
||
<FillWords text="Traditional venture capital invests in startups after they exist." />
|
||
<FillWords
|
||
text="KapFoundry creates startups before they exist."
|
||
className="text-primary"
|
||
/>
|
||
</p>
|
||
<p
|
||
{...reveal("up", 150)}
|
||
className="mt-10 max-w-[65ch] text-lg leading-relaxed text-muted-foreground"
|
||
>
|
||
Every company begins with extensive market research, customer discovery, product
|
||
validation, and AI-driven technology development,{" "}
|
||
<span className="font-semibold text-foreground">
|
||
before a single rupee of capital is deployed.
|
||
</span>
|
||
</p>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function StudioAdvantage() {
|
||
return (
|
||
<section className="section-y bg-background">
|
||
<div className="container-x">
|
||
<SectionHeader
|
||
title={
|
||
<>
|
||
Every venture launches with a <Accent>mature company's stack.</Accent>
|
||
</>
|
||
}
|
||
>
|
||
<p>
|
||
<span className="font-semibold text-foreground">The result:</span> dramatically reduced
|
||
startup risk, with meaningfully faster execution.
|
||
</p>
|
||
</SectionHeader>
|
||
|
||
{/* Index list. The hovered row lifts into a glass card: gold edge bar, filled number
|
||
badge, icon tile and a cursor spotlight. Fills down each column (01-05, 06-10). */}
|
||
<ol className="mt-16 grid gap-x-10 md:grid-flow-col md:grid-cols-2 md:grid-rows-5">
|
||
{STACK.map((s, i) => {
|
||
const Icon = s.icon;
|
||
return (
|
||
<li
|
||
key={s.label}
|
||
{...reveal()}
|
||
onPointerMove={trackSpotlight}
|
||
className={`spotlight group relative -mx-4 flex items-center gap-5 overflow-hidden rounded-2xl px-4 py-5 transition-[background-color,box-shadow] duration-500 before:absolute before:inset-x-4 before:top-0 before:h-px before:bg-border before:transition-opacity before:duration-300 hover:bg-white/[0.04] hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),inset_0_1px_0_rgb(255_255_255/0.08),0_24px_48px_-28px_oklch(0.05_0.01_60/0.9)] hover:before:opacity-0 [li:hover+&]:before:opacity-0 ${EASE_STAGE}`}
|
||
>
|
||
<span
|
||
aria-hidden
|
||
className={`absolute left-0 top-1/2 h-8 w-[3px] -translate-y-1/2 scale-y-0 rounded-full bg-primary transition-transform duration-500 group-hover:scale-y-100 ${EASE_STAGE}`}
|
||
/>
|
||
<span className="relative grid h-9 w-9 shrink-0 place-items-center rounded-full border border-border font-display text-xs font-semibold tabular-nums text-muted-foreground transition-all duration-500 group-hover:border-primary group-hover:bg-primary group-hover:text-primary-foreground group-hover:shadow-lg group-hover:shadow-primary/25">
|
||
{String(i + 1).padStart(2, "0")}
|
||
</span>
|
||
<span
|
||
className={`relative flex-1 font-display text-xl font-semibold transition-transform duration-500 group-hover:translate-x-1 md:text-2xl ${EASE_STAGE}`}
|
||
>
|
||
{s.label}
|
||
</span>
|
||
<span
|
||
className={`relative grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary ring-1 ring-inset ring-primary/20 transition-all duration-500 md:-rotate-12 md:scale-75 md:opacity-0 md:group-hover:rotate-0 md:group-hover:scale-100 md:group-hover:opacity-100 ${EASE_STAGE}`}
|
||
>
|
||
<Icon className="h-[18px] w-[18px]" aria-hidden />
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Whether a section may pin itself and drive its content from scroll. Short screens
|
||
* (e.g. landscape phones) and reduced-motion users get the static layout instead.
|
||
*/
|
||
function usePinnedLayout(extraQuery?: string) {
|
||
const [pinned, setPinned] = useState(false);
|
||
useEffect(() => {
|
||
const base = "(min-height: 600px) and (prefers-reduced-motion: no-preference)";
|
||
const mq = window.matchMedia(extraQuery ? `${base} and ${extraQuery}` : base);
|
||
const sync = () => setPinned(mq.matches);
|
||
sync();
|
||
mq.addEventListener("change", sync);
|
||
window.addEventListener("resize", sync);
|
||
return () => {
|
||
mq.removeEventListener("change", sync);
|
||
window.removeEventListener("resize", sync);
|
||
};
|
||
}, [extraQuery]);
|
||
return pinned;
|
||
}
|
||
|
||
const STEP_SCROLL_SVH = 75;
|
||
// Orbit geometry in a 400×400 viewBox; nodes sit on the ring at ORBIT_R.
|
||
const ORBIT_R = 150;
|
||
const ORBIT_C = 2 * Math.PI * ORBIT_R;
|
||
const EASE_STAGE = "ease-[cubic-bezier(0.16,1,0.3,1)]";
|
||
|
||
function orbitPosition(i: number, count: number) {
|
||
const angle = ((-90 + (i * 360) / count) * Math.PI) / 180;
|
||
const pct = (ORBIT_R / 400) * 100;
|
||
return {
|
||
left: `${(50 + pct * Math.cos(angle)).toFixed(3)}%`,
|
||
top: `${(50 + pct * Math.sin(angle)).toFixed(3)}%`,
|
||
};
|
||
}
|
||
|
||
function Framework() {
|
||
const pinned = usePinnedLayout();
|
||
const sectionRef = useRef<HTMLElement>(null);
|
||
const arcRef = useRef<SVGCircleElement>(null);
|
||
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||
const [active, setActive] = useState(0);
|
||
const steps = FRAMEWORK.length;
|
||
|
||
useEffect(() => {
|
||
const section = sectionRef.current;
|
||
if (!pinned || !section) return;
|
||
const segments = segmentRefs.current;
|
||
let frame = 0;
|
||
let shown = -1;
|
||
const update = () => {
|
||
frame = 0;
|
||
const distance = section.offsetHeight - window.innerHeight;
|
||
const progress =
|
||
distance > 0
|
||
? Math.min(1, Math.max(0, -section.getBoundingClientRect().top / distance))
|
||
: 0;
|
||
const scaled = progress * steps;
|
||
// Each stage owns an equal slice of the scroll, so one wheel tick never skips a stage.
|
||
const index = Math.min(steps - 1, Math.floor(scaled));
|
||
segments.forEach((el, i) => {
|
||
if (el) el.style.transform = `scaleX(${Math.min(1, Math.max(0, scaled - i))})`;
|
||
});
|
||
// The arc reaches node i as stage i begins and closes the loop after the last stage.
|
||
if (arcRef.current) arcRef.current.style.strokeDashoffset = String(ORBIT_C * (1 - progress));
|
||
if (index !== shown) {
|
||
shown = index;
|
||
setActive(index);
|
||
}
|
||
};
|
||
const onScroll = () => {
|
||
if (!frame) frame = requestAnimationFrame(update);
|
||
};
|
||
update();
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
window.addEventListener("resize", onScroll);
|
||
return () => {
|
||
window.removeEventListener("scroll", onScroll);
|
||
window.removeEventListener("resize", onScroll);
|
||
cancelAnimationFrame(frame);
|
||
};
|
||
}, [pinned, steps]);
|
||
|
||
const goTo = (i: number) => {
|
||
const section = sectionRef.current;
|
||
if (!section) return;
|
||
const distance = section.offsetHeight - window.innerHeight;
|
||
window.scrollTo({ top: section.offsetTop + ((i + 0.5) / steps) * distance });
|
||
};
|
||
|
||
const title = (
|
||
<>
|
||
Our <Accent>venture-building</Accent> framework.
|
||
</>
|
||
);
|
||
const intro =
|
||
"Six stages take every company from a billion-dollar problem to an independent business.";
|
||
|
||
if (!pinned) {
|
||
return (
|
||
<section
|
||
id="framework"
|
||
className="sheet-enter theme-light section-y bg-background text-foreground"
|
||
>
|
||
<div className="container-x">
|
||
<SectionHeader eyebrow="Repeatable by design" icon={Workflow} title={title}>
|
||
<p>{intro}</p>
|
||
</SectionHeader>
|
||
<ol className="mt-16 grid gap-4 md:grid-cols-2">
|
||
{FRAMEWORK.map((f) => (
|
||
<li
|
||
key={f.n}
|
||
{...reveal()}
|
||
className="flex gap-5 rounded-3xl border border-border bg-card p-6"
|
||
>
|
||
<IconTile icon={f.icon} />
|
||
<div>
|
||
<h3 className="font-display text-2xl font-bold">
|
||
<span className="mr-2 text-primary tabular-nums">{f.n}</span>
|
||
{f.title}
|
||
</h3>
|
||
<p className="mt-2 text-muted-foreground">{f.body}</p>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const next = FRAMEWORK[active + 1];
|
||
return (
|
||
<section
|
||
id="framework"
|
||
ref={sectionRef}
|
||
className="sheet-enter theme-light relative bg-background text-foreground"
|
||
style={{ height: `${100 + steps * STEP_SCROLL_SVH}svh` }}
|
||
>
|
||
<div className="sticky top-0 flex h-[100svh] flex-col overflow-hidden pb-6 pt-20 md:pt-24">
|
||
<div className="container-x flex min-h-0 w-full flex-1 flex-col">
|
||
{/* Heading row */}
|
||
<div {...reveal("stagger")}>
|
||
<Eyebrow icon={Workflow}>Repeatable by design</Eyebrow>
|
||
<h2 className="mt-4 font-display text-3xl font-bold leading-[1.04] md:text-5xl">
|
||
{title}
|
||
</h2>
|
||
<p className="mt-3 hidden max-w-[65ch] leading-relaxed text-muted-foreground md:block [@media(max-height:820px)]:!hidden">
|
||
{intro}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Segmented progress: one bar per stage, filling as you scroll through it */}
|
||
<div className="mt-6 grid grid-cols-6 gap-2 md:mt-8 md:gap-3">
|
||
{FRAMEWORK.map((f, i) => (
|
||
<button
|
||
key={f.n}
|
||
type="button"
|
||
onClick={() => goTo(i)}
|
||
aria-label={`Stage ${f.n}: ${f.title}`}
|
||
aria-current={i === active ? "step" : undefined}
|
||
className="group py-2 text-left"
|
||
>
|
||
<span className="block h-1 overflow-hidden rounded-full bg-foreground/10">
|
||
<span
|
||
ref={(el) => {
|
||
segmentRefs.current[i] = el;
|
||
}}
|
||
className="block h-full origin-left scale-x-0 rounded-full bg-primary"
|
||
/>
|
||
</span>
|
||
<span
|
||
className={`mt-3 flex items-center gap-2 font-display text-xs font-semibold transition-colors duration-500 ${
|
||
i === active
|
||
? "text-foreground"
|
||
: "text-muted-foreground group-hover:text-foreground"
|
||
}`}
|
||
>
|
||
<span className={i <= active ? "text-primary" : ""}>{f.n}</span>
|
||
<span className="hidden truncate md:inline">{f.title}</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Stage panel */}
|
||
<div
|
||
{...reveal("up", 150)}
|
||
className="theme-dark relative mt-5 min-h-0 flex-1 overflow-hidden rounded-[2rem] bg-background text-foreground shadow-2xl shadow-black/25 md:mt-6"
|
||
>
|
||
<div className="glow absolute -left-40 -top-40 h-[32rem] w-[32rem] opacity-60" />
|
||
<div className="absolute inset-0 grid-lines opacity-30" />
|
||
|
||
<div className="relative grid h-full md:grid-cols-2">
|
||
{/* Copy */}
|
||
<div className="flex min-h-0 flex-col justify-center p-6 md:p-10 lg:p-12">
|
||
<div className="flex items-end gap-3">
|
||
{/* Odometer: the stage numbers roll past like a counter */}
|
||
<div
|
||
aria-hidden
|
||
className="h-[1em] overflow-hidden font-display text-6xl font-extrabold leading-none tracking-tighter md:text-7xl lg:text-8xl"
|
||
>
|
||
<div
|
||
className={`transition-transform duration-700 ${EASE_STAGE}`}
|
||
style={{ transform: `translateY(-${active}em)` }}
|
||
>
|
||
{FRAMEWORK.map((f) => (
|
||
<div
|
||
key={f.n}
|
||
className="h-[1em] bg-gradient-to-b from-primary to-primary/35 bg-clip-text text-transparent"
|
||
>
|
||
{f.n}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<span className="mb-1.5 font-display text-sm font-semibold text-muted-foreground">
|
||
/ {String(steps).padStart(2, "0")}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="relative mt-6 min-h-[8.5rem] md:mt-8 md:min-h-[10rem]">
|
||
{FRAMEWORK.map((f, i) => {
|
||
const offset = i - active;
|
||
return (
|
||
<div
|
||
key={f.n}
|
||
aria-hidden={offset !== 0}
|
||
className={`absolute inset-x-0 top-0 transition-all duration-700 ${EASE_STAGE} ${
|
||
offset === 0
|
||
? "translate-y-0 opacity-100 blur-0"
|
||
: offset < 0
|
||
? "pointer-events-none -translate-y-6 opacity-0 blur-sm"
|
||
: "pointer-events-none translate-y-6 opacity-0 blur-sm"
|
||
}`}
|
||
>
|
||
<h3 className="font-display text-4xl font-extrabold md:text-5xl lg:text-6xl">
|
||
{f.title}
|
||
</h3>
|
||
<p className="mt-3 max-w-md text-base leading-relaxed text-muted-foreground md:text-lg">
|
||
{f.body}
|
||
</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div
|
||
key={active}
|
||
className="mt-4 flex items-center gap-3 font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary duration-700 animate-in fade-in slide-in-from-left-2"
|
||
>
|
||
{next ? (
|
||
<>
|
||
Next: {next.title}
|
||
<ArrowDown className="nudge-y h-4 w-4" aria-hidden />
|
||
</>
|
||
) : (
|
||
<>
|
||
<Check className="h-4 w-4" aria-hidden />
|
||
An independent portfolio company
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Orbit: the whole journey at a glance */}
|
||
<div className="relative hidden min-h-0 items-center justify-center p-8 md:flex">
|
||
<div className="relative aspect-square h-full max-h-[440px]">
|
||
<svg viewBox="0 0 400 400" className="absolute inset-0 h-full w-full" aria-hidden>
|
||
<circle
|
||
cx="200"
|
||
cy="200"
|
||
r="188"
|
||
fill="none"
|
||
strokeWidth="1"
|
||
strokeDasharray="2 10"
|
||
className="origin-center animate-[spin_80s_linear_infinite] stroke-white/15 [transform-box:fill-box] motion-reduce:animate-none"
|
||
/>
|
||
<circle
|
||
cx="200"
|
||
cy="200"
|
||
r={ORBIT_R}
|
||
fill="none"
|
||
strokeWidth="1.5"
|
||
className="stroke-white/10"
|
||
/>
|
||
<circle
|
||
ref={arcRef}
|
||
cx="200"
|
||
cy="200"
|
||
r={ORBIT_R}
|
||
fill="none"
|
||
strokeWidth="2.5"
|
||
strokeLinecap="round"
|
||
strokeDasharray={ORBIT_C}
|
||
strokeDashoffset={ORBIT_C}
|
||
transform="rotate(-90 200 200)"
|
||
className="stroke-primary transition-[stroke-dashoffset] duration-150 ease-linear"
|
||
/>
|
||
</svg>
|
||
|
||
{FRAMEWORK.map((f, i) => {
|
||
const state = i === active ? "active" : i < active ? "done" : "next";
|
||
return (
|
||
<button
|
||
key={f.n}
|
||
type="button"
|
||
onClick={() => goTo(i)}
|
||
aria-label={`Stage ${f.n}: ${f.title}`}
|
||
style={orbitPosition(i, steps)}
|
||
className="group absolute -translate-x-1/2 -translate-y-1/2"
|
||
>
|
||
{state === "active" ? (
|
||
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30 motion-reduce:animate-none" />
|
||
) : null}
|
||
<span
|
||
className={`relative flex h-11 w-11 items-center justify-center rounded-full border font-display text-xs font-bold transition-all duration-500 ${
|
||
state === "active"
|
||
? "scale-110 border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/40"
|
||
: state === "done"
|
||
? "border-primary bg-background text-primary"
|
||
: "border-white/15 bg-background text-muted-foreground group-hover:border-primary/60 group-hover:text-foreground"
|
||
}`}
|
||
>
|
||
{state === "done" ? <Check className="h-4 w-4" aria-hidden /> : f.n}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
{/* Centre: the active stage's icon */}
|
||
<div className="absolute left-1/2 top-1/2 flex h-28 w-28 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-[2rem] border border-white/10 bg-white/[0.04] backdrop-blur-sm">
|
||
<div className="glow absolute -inset-10 opacity-80" />
|
||
{FRAMEWORK.map((f, i) => {
|
||
const Icon = f.icon;
|
||
return (
|
||
<Icon
|
||
key={f.n}
|
||
aria-hidden
|
||
className={`absolute h-10 w-10 text-primary transition-all duration-700 ${EASE_STAGE} ${
|
||
i === active
|
||
? "rotate-0 scale-100 opacity-100"
|
||
: "-rotate-45 scale-50 opacity-0"
|
||
}`}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const PORTFOLIO_STEP_SVH = 70;
|
||
// A card starts flying off once this share of its scroll slice has passed.
|
||
const DECK_FLY_START = 0.5;
|
||
const DECK_PEEK_PX = 22;
|
||
|
||
/** One vertical as a deck card: photo side and details side. */
|
||
function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
|
||
const Icon = v.icon;
|
||
return (
|
||
<div className="grid h-full grid-rows-[minmax(130px,28%)_1fr] overflow-hidden rounded-[2rem] border border-white/10 bg-card shadow-[0_40px_80px_-40px_oklch(0.05_0.01_60/0.9)] md:grid-cols-2 md:grid-rows-1">
|
||
<div className="relative min-h-[150px] overflow-hidden md:min-h-0">
|
||
<img
|
||
src={v.img}
|
||
alt=""
|
||
aria-hidden
|
||
loading="lazy"
|
||
decoding="async"
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-ink via-ink/40 to-ink/5" />
|
||
<span className="absolute left-5 top-5 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-black/30 md:left-7 md:top-7">
|
||
<Icon className="h-5 w-5" aria-hidden />
|
||
</span>
|
||
<div className="absolute bottom-5 left-5 right-5 md:bottom-8 md:left-8 md:right-8">
|
||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||
{v.tag}
|
||
</div>
|
||
<h3 className="mt-2 font-display text-2xl font-bold leading-tight text-white md:text-4xl">
|
||
{v.name}
|
||
</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-col justify-center gap-4 p-4 sm:p-5 md:gap-5 md:p-8 lg:p-10">
|
||
<div className="grid grid-cols-2 gap-4 md:grid-cols-1 md:gap-6 xl:grid-cols-2">
|
||
<div>
|
||
<div className="mb-2.5 text-sm font-medium text-muted-foreground">The problem</div>
|
||
<ul className="space-y-1.5">
|
||
{v.problem.map((p) => (
|
||
<li
|
||
key={p}
|
||
className="flex gap-2 text-[13px] leading-snug text-muted-foreground md:text-sm"
|
||
>
|
||
<span className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-muted-foreground/60" />
|
||
{p}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<div className="mb-2.5 text-sm font-medium text-primary">Our solution</div>
|
||
<ul className="space-y-1.5">
|
||
{v.solution.map((p) => (
|
||
<li
|
||
key={p}
|
||
className="flex gap-2 text-[13px] leading-snug text-foreground md:text-sm"
|
||
>
|
||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" aria-hidden />
|
||
{p}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
{v.example ? (
|
||
<p className="flex items-start gap-3 rounded-2xl border border-primary/25 bg-primary/10 p-4 text-sm text-foreground">
|
||
<ArrowUpRight className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden />
|
||
<span>
|
||
<span className="text-primary">Example venture: </span>
|
||
{v.example}
|
||
</span>
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Card deck. Pinned: the verticals sit stacked like a deck; scrolling flies the top card away
|
||
* and the next one rises into place. Otherwise (short screens, reduced motion) the cards
|
||
* simply stack down the page.
|
||
*/
|
||
function Portfolio() {
|
||
// Phones need more height than tablets/desktops to fit a whole card while pinned.
|
||
const pinned = usePinnedLayout(
|
||
"(((min-width: 768px) and (min-height: 700px)) or (min-height: 800px))",
|
||
);
|
||
const sectionRef = useRef<HTMLElement>(null);
|
||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||
const fillRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||
const [active, setActive] = useState(0);
|
||
const count = VERTICALS.length;
|
||
|
||
useEffect(() => {
|
||
const section = sectionRef.current;
|
||
if (!pinned || !section) return;
|
||
const cards = cardRefs.current.filter((c): c is HTMLDivElement => c !== null);
|
||
const fills = fillRefs.current;
|
||
let frame = 0;
|
||
let shown = -1;
|
||
let eased = -1;
|
||
let last = 0;
|
||
|
||
const target = () => {
|
||
const distance = section.offsetHeight - window.innerHeight;
|
||
return distance > 0
|
||
? Math.min(1, Math.max(0, -section.getBoundingClientRect().top / distance))
|
||
: 0;
|
||
};
|
||
|
||
const render = (progress: number) => {
|
||
const scaled = progress * count;
|
||
const current = Math.min(count - 1, Math.floor(scaled));
|
||
// How far the top card has flown off (the last card never leaves).
|
||
const fly =
|
||
current < count - 1
|
||
? Math.min(1, Math.max(0, (scaled - current - DECK_FLY_START) / (1 - DECK_FLY_START)))
|
||
: 0;
|
||
|
||
cards.forEach((card, k) => {
|
||
const veil = card.querySelector<HTMLElement>("[data-veil]");
|
||
if (k < current) {
|
||
card.style.transform = "translate3d(0, -115%, 0) rotate(-7deg)";
|
||
card.style.opacity = "0";
|
||
return;
|
||
}
|
||
if (k === current) {
|
||
// Ease in (slow start, fast exit) and fade early so the two cards never read as one.
|
||
const lift = fly * fly;
|
||
card.style.transform = `translate3d(0, ${-lift * 115}%, 0) rotate(${-fly * 7}deg) scale(${1 - fly * 0.06})`;
|
||
card.style.opacity = String(Math.max(0, 1 - fly * 2));
|
||
if (veil) veil.style.opacity = "0";
|
||
return;
|
||
}
|
||
// Waiting in the deck: each layer sits a little lower and smaller, and rises as the
|
||
// card above it flies away.
|
||
const depth = k - current - fly;
|
||
card.style.transform = `translate3d(0, ${depth * DECK_PEEK_PX}px, 0) scale(${1 - depth * 0.045})`;
|
||
card.style.opacity = depth > 2.6 ? "0" : "1";
|
||
if (veil) veil.style.opacity = String(Math.min(0.75, depth * 0.28));
|
||
});
|
||
|
||
fills.forEach((fill, k) => {
|
||
if (fill) fill.style.transform = `scaleX(${Math.min(1, Math.max(0, scaled - k))})`;
|
||
});
|
||
if (current !== shown) {
|
||
shown = current;
|
||
setActive(current);
|
||
}
|
||
};
|
||
|
||
// Glide toward the scroll position rather than snapping, so wheel steps feel smooth.
|
||
const tick = (now: number) => {
|
||
frame = 0;
|
||
const goal = target();
|
||
const dt = last ? Math.min(now - last, 64) : 16;
|
||
last = now;
|
||
eased = eased < 0 ? goal : eased + (goal - eased) * (1 - Math.exp(-dt / 110));
|
||
if (Math.abs(goal - eased) < 0.0005) eased = goal;
|
||
render(eased);
|
||
if (eased !== goal) frame = requestAnimationFrame(tick);
|
||
else last = 0;
|
||
};
|
||
const onScroll = () => {
|
||
if (!frame) frame = requestAnimationFrame(tick);
|
||
};
|
||
|
||
eased = target();
|
||
render(eased);
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
window.addEventListener("resize", onScroll);
|
||
return () => {
|
||
window.removeEventListener("scroll", onScroll);
|
||
window.removeEventListener("resize", onScroll);
|
||
cancelAnimationFrame(frame);
|
||
for (const card of cards) {
|
||
card.style.transform = "";
|
||
card.style.opacity = "";
|
||
}
|
||
};
|
||
}, [pinned, count]);
|
||
|
||
const goTo = (i: number) => {
|
||
const section = sectionRef.current;
|
||
if (!section) return;
|
||
const distance = section.offsetHeight - window.innerHeight;
|
||
window.scrollTo({ top: section.offsetTop + ((i + 0.25) / count) * distance });
|
||
};
|
||
|
||
const title = (
|
||
<>
|
||
Seven industries, <Accent>one AI engine.</Accent>
|
||
</>
|
||
);
|
||
const intro = (
|
||
<>
|
||
<span className="font-semibold text-foreground">Artificial Intelligence</span> is the
|
||
foundation beneath every venture, not a vertical of its own.
|
||
</>
|
||
);
|
||
|
||
if (!pinned) {
|
||
return (
|
||
<section id="portfolio" className="section-y relative bg-ink">
|
||
<div className="container-x">
|
||
<SectionHeader title={title}>
|
||
<p>{intro}</p>
|
||
</SectionHeader>
|
||
<div className="mt-14 space-y-6">
|
||
{VERTICALS.map((v) => (
|
||
<div key={v.n} {...reveal()} className="md:h-[520px]">
|
||
<VerticalCard v={v} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section
|
||
id="portfolio"
|
||
ref={sectionRef}
|
||
className="relative bg-ink"
|
||
style={{ height: `${100 + count * PORTFOLIO_STEP_SVH}svh` }}
|
||
>
|
||
<div className="sticky top-0 flex h-[100svh] flex-col overflow-hidden pb-6 pt-20 lg:pb-10 lg:pt-24">
|
||
<div className="glow glow-drift absolute -right-40 top-1/4 h-[36rem] w-[36rem] opacity-40" />
|
||
<div className="container-x relative grid min-h-0 w-full flex-1 grid-rows-[auto_minmax(0,1fr)] gap-5 lg:grid-cols-12 lg:grid-rows-1 lg:gap-12">
|
||
{/* Title and index */}
|
||
<div className="flex flex-col lg:col-span-4" {...reveal("stagger")}>
|
||
<h2 className="font-display text-3xl font-bold leading-[1.04] md:text-4xl xl:text-5xl">
|
||
{title}
|
||
</h2>
|
||
<p className="mt-3 hidden max-w-[40ch] text-muted-foreground lg:block [@media(max-height:760px)]:!hidden">
|
||
{intro}
|
||
</p>
|
||
|
||
{/* Desktop: named index with a fill line per vertical */}
|
||
<ol className="mt-8 hidden space-y-1 lg:block">
|
||
{VERTICALS.map((v, i) => (
|
||
<li key={v.n}>
|
||
<button
|
||
type="button"
|
||
onClick={() => goTo(i)}
|
||
aria-current={i === active ? "step" : undefined}
|
||
className="group flex min-h-10 w-full items-center gap-4 text-left"
|
||
>
|
||
<span
|
||
className={`w-6 font-display text-xs font-semibold tabular-nums transition-colors duration-500 ${
|
||
i <= active ? "text-primary" : "text-muted-foreground"
|
||
}`}
|
||
>
|
||
{v.n}
|
||
</span>
|
||
<span className="relative flex-1">
|
||
<span
|
||
className={`font-display text-base font-semibold transition-colors duration-500 xl:text-lg ${
|
||
i === active
|
||
? "text-foreground"
|
||
: "text-muted-foreground group-hover:text-foreground"
|
||
}`}
|
||
>
|
||
{v.short}
|
||
</span>
|
||
<span className="absolute inset-x-0 -bottom-1.5 h-px bg-white/10">
|
||
<span
|
||
ref={(el) => {
|
||
fillRefs.current[i] = el;
|
||
}}
|
||
className="block h-full origin-left scale-x-0 bg-primary"
|
||
/>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
|
||
{/* Phones and tablets: dots */}
|
||
<div className="mt-4 flex items-center gap-1 lg:hidden">
|
||
{VERTICALS.map((v, i) => (
|
||
<button
|
||
key={v.n}
|
||
type="button"
|
||
onClick={() => goTo(i)}
|
||
aria-label={v.name}
|
||
aria-current={i === active ? "step" : undefined}
|
||
className="flex h-10 min-w-8 items-center justify-center"
|
||
>
|
||
<span
|
||
className={`block h-1.5 rounded-full transition-all duration-500 ${
|
||
i === active
|
||
? "w-8 bg-primary"
|
||
: i < active
|
||
? "w-3 bg-primary/60"
|
||
: "w-3 bg-white/15"
|
||
}`}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* The deck */}
|
||
<div
|
||
// Bottom padding leaves room for the deck layers peeking below the top card.
|
||
className="relative min-h-0 pb-11 lg:col-span-8"
|
||
{...reveal("up", 150)}
|
||
>
|
||
<div className="relative h-full">
|
||
{VERTICALS.map((v, k) => (
|
||
<div
|
||
key={v.n}
|
||
ref={(el) => {
|
||
cardRefs.current[k] = el;
|
||
}}
|
||
aria-hidden={k !== active}
|
||
className="absolute inset-0 origin-top will-change-transform"
|
||
style={{ zIndex: count - k }}
|
||
>
|
||
<VerticalCard v={v} />
|
||
<div
|
||
data-veil
|
||
aria-hidden
|
||
className="pointer-events-none absolute inset-0 rounded-[2rem] bg-ink opacity-0"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** Moves a cell's cursor spotlight (see .spotlight) without re-rendering React. */
|
||
function trackSpotlight(e: ReactPointerEvent<HTMLElement>) {
|
||
const r = e.currentTarget.getBoundingClientRect();
|
||
e.currentTarget.style.setProperty("--mx", `${e.clientX - r.left}px`);
|
||
e.currentTarget.style.setProperty("--my", `${e.clientY - r.top}px`);
|
||
}
|
||
|
||
// Bento placement for the eight capabilities: one feature tile, one wide tile, six singles.
|
||
const AI_FEATURE = 0;
|
||
const AI_WIDE = 5;
|
||
|
||
function AISection() {
|
||
return (
|
||
<section id="ai" className="sheet-enter theme-light section-y bg-background text-foreground">
|
||
<div className="container-x">
|
||
<SectionHeader
|
||
compact
|
||
title={
|
||
<>
|
||
AI is not a vertical.{" "}
|
||
<Accent>It's the foundation under every company we build.</Accent>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="mt-16 grid auto-rows-[minmax(150px,auto)] grid-cols-2 gap-4 lg:grid-cols-4">
|
||
{AI_CAPS.map((a, i) => {
|
||
const Icon = a.icon;
|
||
if (i === AI_FEATURE) {
|
||
return (
|
||
<div
|
||
key={a.label}
|
||
{...reveal()}
|
||
onPointerMove={trackSpotlight}
|
||
className="spotlight theme-dark group relative col-span-2 row-span-2 flex flex-col justify-between overflow-hidden rounded-3xl [clip-path:inset(0_round_1.5rem)] bg-background p-8 text-foreground md:p-10"
|
||
>
|
||
<div className="glow absolute -right-24 -top-24 h-[26rem] w-[26rem] opacity-70" />
|
||
<span className="relative inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary">
|
||
<Icon className="h-7 w-7" aria-hidden />
|
||
</span>
|
||
<div className="relative mt-16">
|
||
<h3 className="font-display text-3xl font-bold md:text-4xl">{a.label}</h3>
|
||
<p className="mt-3 max-w-[40ch] text-lg leading-relaxed text-muted-foreground">
|
||
Every venture is designed{" "}
|
||
<span className="font-semibold text-foreground">AI-first</span>, not just
|
||
AI-enabled.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div
|
||
key={a.label}
|
||
{...reveal()}
|
||
onPointerMove={trackSpotlight}
|
||
className={`spotlight group relative flex overflow-hidden rounded-3xl p-6 transition-colors duration-300 ${
|
||
i === AI_WIDE
|
||
? "col-span-2 items-center gap-5 bg-primary/10"
|
||
: "flex-col justify-between border border-border bg-card hover:border-primary/40"
|
||
}`}
|
||
>
|
||
<IconTile
|
||
icon={Icon}
|
||
className="relative transition-colors duration-300 group-hover:bg-primary group-hover:text-primary-foreground"
|
||
/>
|
||
<span
|
||
className={`relative font-display font-semibold ${
|
||
i === AI_WIDE ? "text-xl md:text-2xl" : "mt-6 text-base md:text-lg"
|
||
}`}
|
||
>
|
||
{a.label}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Team() {
|
||
const sectionRef = useRef<HTMLElement>(null);
|
||
const trackRef = useRef<HTMLDivElement>(null);
|
||
const barRef = useRef<HTMLDivElement>(null);
|
||
const backdropRef = useRef<HTMLDivElement>(null);
|
||
const cardRefs = useRef<(HTMLElement | null)[]>([]);
|
||
// Starts as a plain swipeable row (server render, reduced motion, short screens) and
|
||
// upgrades to the pinned horizontal scroll once mounted.
|
||
const pinned = usePinnedLayout();
|
||
const [current, setCurrent] = useState(0);
|
||
|
||
useEffect(() => {
|
||
const section = sectionRef.current;
|
||
const track = trackRef.current;
|
||
const backdrop = backdropRef.current;
|
||
const cards = cardRefs.current.filter((c): c is HTMLElement => c !== null);
|
||
if (!pinned || !section || !track) return;
|
||
|
||
let distance = 0;
|
||
let frame = 0;
|
||
let shown = -1;
|
||
let eased = -1;
|
||
let last = 0;
|
||
|
||
const target = () =>
|
||
distance ? Math.min(1, Math.max(0, -section.getBoundingClientRect().top / distance)) : 0;
|
||
|
||
// The track glides toward the scroll position instead of snapping to it, which smooths
|
||
// out coarse mouse-wheel steps. Time-based, so it feels the same at 60 Hz and 120 Hz.
|
||
const tick = (now: number) => {
|
||
frame = 0;
|
||
const goal = target();
|
||
const dt = last ? Math.min(now - last, 64) : 16;
|
||
last = now;
|
||
eased = eased < 0 ? goal : eased + (goal - eased) * (1 - Math.exp(-dt / 110));
|
||
if (Math.abs(goal - eased) < 0.0005) eased = goal;
|
||
render(eased);
|
||
if (eased !== goal) frame = requestAnimationFrame(tick);
|
||
else last = 0;
|
||
};
|
||
|
||
const render = (progress: number) => {
|
||
const offset = progress * distance;
|
||
track.style.transform = `translate3d(${-offset}px, 0, 0)`;
|
||
if (barRef.current) barRef.current.style.transform = `scaleX(${progress})`;
|
||
if (backdrop) backdrop.style.transform = `translate3d(${-progress * 30}%, 0, 0)`;
|
||
|
||
// Cards grow toward the centre of the screen; photos drift against the motion.
|
||
const mid = window.innerWidth / 2;
|
||
let nearest = 0;
|
||
let best = Infinity;
|
||
cards.forEach((card, i) => {
|
||
const d = (card.offsetLeft + card.offsetWidth / 2 - offset - mid) / window.innerWidth;
|
||
const a = Math.min(Math.abs(d), 1);
|
||
card.style.transform = `scale(${1 - a * 0.12})`;
|
||
card.style.opacity = String(1 - a * 0.5);
|
||
const img = card.querySelector<HTMLElement>("[data-parallax]");
|
||
if (img) img.style.transform = `translate3d(${d * -8}%, 0, 0) scale(1.18)`;
|
||
if (a < best) {
|
||
best = a;
|
||
nearest = i;
|
||
}
|
||
});
|
||
if (nearest !== shown) {
|
||
shown = nearest;
|
||
setCurrent(nearest);
|
||
}
|
||
};
|
||
const measure = () => {
|
||
distance = Math.max(0, track.scrollWidth - window.innerWidth);
|
||
// Vertical scroll length equals the horizontal travel, so the motion feels 1:1.
|
||
section.style.height = `${distance + window.innerHeight}px`;
|
||
eased = target();
|
||
render(eased);
|
||
};
|
||
const onScroll = () => {
|
||
if (!frame) frame = requestAnimationFrame(tick);
|
||
};
|
||
|
||
measure();
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
window.addEventListener("resize", measure);
|
||
return () => {
|
||
window.removeEventListener("scroll", onScroll);
|
||
window.removeEventListener("resize", measure);
|
||
cancelAnimationFrame(frame);
|
||
section.style.height = "";
|
||
track.style.transform = "";
|
||
if (backdrop) backdrop.style.transform = "";
|
||
for (const card of cards) {
|
||
card.style.transform = "";
|
||
card.style.opacity = "";
|
||
card.querySelector<HTMLElement>("[data-parallax]")?.style.removeProperty("transform");
|
||
}
|
||
};
|
||
}, [pinned]);
|
||
|
||
return (
|
||
<section
|
||
id="team"
|
||
ref={sectionRef}
|
||
aria-label="Our team"
|
||
className={`relative bg-background ${pinned ? "" : "section-y"}`}
|
||
>
|
||
<div
|
||
className={
|
||
pinned
|
||
? "sticky top-0 flex h-[100svh] flex-col justify-center overflow-hidden pt-16"
|
||
: "relative overflow-hidden"
|
||
}
|
||
>
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2"
|
||
>
|
||
<div
|
||
ref={backdropRef}
|
||
className="whitespace-nowrap pl-[10vw] font-display text-[26vw] font-extrabold leading-none tracking-tighter text-white/[0.035] will-change-transform"
|
||
>
|
||
TEAM · TEAM · TEAM
|
||
</div>
|
||
</div>
|
||
<div className="glow glow-drift absolute -right-40 top-1/4 h-[36rem] w-[36rem] opacity-40" />
|
||
|
||
<div
|
||
ref={trackRef}
|
||
className={`relative flex items-stretch gap-6 px-6 md:gap-10 md:px-10 lg:px-[max(2.5rem,calc((100vw-1280px)/2+2.5rem))] ${
|
||
pinned
|
||
? "w-max will-change-transform"
|
||
: "snap-x snap-mandatory overflow-x-auto pb-6 [scrollbar-width:none]"
|
||
}`}
|
||
>
|
||
<div
|
||
{...reveal("stagger")}
|
||
className="flex w-[72vw] shrink-0 snap-start flex-col justify-center sm:w-[55vw] lg:w-[34vw]"
|
||
>
|
||
<Eyebrow icon={Users}>Our team</Eyebrow>
|
||
<h2 className="mt-6 font-display text-4xl font-bold leading-[1.02] md:text-5xl lg:text-6xl">
|
||
The people behind <Accent>every venture.</Accent>
|
||
</h2>
|
||
<p className="mt-6 max-w-md text-lg leading-relaxed text-muted-foreground">
|
||
The operators and builders who turn research into companies, and companies into
|
||
category leaders.
|
||
</p>
|
||
</div>
|
||
|
||
{TEAM.map((p, i) => (
|
||
<article
|
||
key={p.img}
|
||
ref={(el) => {
|
||
cardRefs.current[i] = el;
|
||
}}
|
||
className="group relative aspect-[3/4] w-[68vw] shrink-0 self-center snap-center overflow-hidden rounded-[2rem] border border-border bg-card transition-colors duration-500 will-change-transform [clip-path:inset(0_round_2rem)] hover:border-primary/50 sm:h-[62svh] sm:max-h-[640px] sm:min-h-[420px] sm:w-auto"
|
||
>
|
||
<img
|
||
data-parallax
|
||
src={p.img}
|
||
alt={p.name ? `Portrait of ${p.name}` : "KapFoundry team member"}
|
||
loading="lazy"
|
||
decoding="async"
|
||
width={680}
|
||
height={680}
|
||
className="absolute inset-0 h-full w-full scale-[1.18] object-cover object-[50%_30%] will-change-transform"
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-ink via-ink/60 to-transparent" />
|
||
<span className="absolute right-5 top-5 grid h-11 w-11 place-items-center rounded-full bg-ink/60 font-display text-base font-bold tabular-nums text-primary ring-1 ring-inset ring-white/15 backdrop-blur md:right-7 md:top-7">
|
||
{i + 1}
|
||
</span>
|
||
<div className="absolute bottom-5 left-5 right-5 md:bottom-8 md:left-8 md:right-8">
|
||
<div className="font-display text-sm font-semibold uppercase tracking-[0.18em] text-primary md:text-base">
|
||
{p.role}
|
||
</div>
|
||
{p.name ? (
|
||
<h3 className="mt-1.5 font-display text-2xl font-bold leading-tight text-white md:text-3xl">
|
||
{p.name}
|
||
</h3>
|
||
) : null}
|
||
</div>
|
||
</article>
|
||
))}
|
||
|
||
<div className="flex w-[70vw] shrink-0 snap-center flex-col justify-center sm:w-[45vw] lg:w-[28vw]">
|
||
<p className="font-display text-3xl font-bold leading-tight md:text-4xl">
|
||
Your company could be <Accent>next.</Accent>
|
||
</p>
|
||
<a
|
||
href="#contact"
|
||
className="press group mt-8 inline-flex w-fit items-center gap-2 rounded-full bg-primary px-7 py-3.5 font-display text-sm font-semibold text-primary-foreground hover:brightness-110"
|
||
>
|
||
Become a partner
|
||
<ArrowUpRight
|
||
className="h-4 w-4 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
|
||
aria-hidden
|
||
/>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{pinned ? (
|
||
<div className="container-x relative mt-8 flex items-center gap-6">
|
||
<span className="font-display text-sm font-semibold tabular-nums" aria-live="polite">
|
||
<span className="text-primary">{String(current + 1).padStart(2, "0")}</span>
|
||
<span className="text-muted-foreground">
|
||
{" "}
|
||
/ {String(TEAM.length).padStart(2, "0")}
|
||
</span>
|
||
</span>
|
||
<div className="h-px flex-1 overflow-hidden bg-white/10">
|
||
<div ref={barRef} className="h-full origin-left scale-x-0 bg-primary" />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Investors() {
|
||
return (
|
||
// overflow-x-clip (not overflow-hidden) so the pinned column can stick.
|
||
<section id="investors" className="section-y relative overflow-x-clip bg-ink">
|
||
<div className="glow glow-drift absolute -left-60 top-0 h-[40rem] w-[40rem] opacity-40" />
|
||
<div className="container-x relative grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||
{/* The argument stays pinned while the reasons scroll past it. */}
|
||
<div className="lg:col-span-5">
|
||
<div className="lg:sticky lg:top-28" {...reveal("stagger")}>
|
||
<h2 className="font-display text-4xl font-bold leading-[1.02] md:text-5xl">
|
||
A diversified portfolio, <Accent>not a single bet.</Accent>
|
||
</h2>
|
||
<p className="mt-6 max-w-[65ch] text-lg leading-relaxed text-muted-foreground">
|
||
Rather than depending on one company, investors gain exposure to a portfolio of
|
||
ventures across multiple industries, spreading risk while multiplying the chances to
|
||
participate in high-growth markets.
|
||
</p>
|
||
<p className="mt-6 font-display text-xl font-semibold leading-snug md:text-2xl">
|
||
Instead of asking{" "}
|
||
<span className="text-muted-foreground">"Which startup will succeed?"</span>{" "}
|
||
<Accent>We build an ecosystem where many companies can succeed.</Accent>
|
||
</p>
|
||
<a
|
||
href="#contact"
|
||
className="press group mt-10 inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 font-display text-sm font-semibold text-primary-foreground hover:brightness-110"
|
||
>
|
||
Speak with our team
|
||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<ol className="space-y-4 lg:col-span-7">
|
||
{INVESTOR_REASONS.map((r, i) => (
|
||
<li
|
||
key={r}
|
||
{...reveal()}
|
||
className="group relative flex items-center gap-6 overflow-hidden rounded-3xl border border-border bg-card/70 p-6 transition-colors duration-500 hover:border-primary/40 md:gap-8 md:p-8"
|
||
>
|
||
<span
|
||
aria-hidden
|
||
className="w-16 shrink-0 font-display text-4xl font-extrabold tabular-nums text-transparent transition-colors duration-500 [-webkit-text-stroke:1px_color-mix(in_oklab,var(--primary)_65%,transparent)] group-hover:text-primary md:w-20 md:text-5xl"
|
||
>
|
||
{String(i + 1).padStart(2, "0")}
|
||
</span>
|
||
<span className="font-display text-lg font-semibold md:text-2xl">{r}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const INPUT_CLASS =
|
||
"mt-2 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-base text-foreground placeholder:text-muted-foreground/60 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30";
|
||
|
||
type ContactStatus = "idle" | "sending" | "sent" | "mailto" | "error";
|
||
|
||
const CONTACT_STATUS_TEXT: Record<ContactStatus, string> = {
|
||
idle: "We respond to investor enquiries within 48 hours.",
|
||
sending: "Sending…",
|
||
sent: "Thank you. Your message is with our team and we'll be in touch within 48 hours.",
|
||
mailto: `Your email app should open with the message ready. If it doesn't, write to ${CONTACT_EMAIL}.`,
|
||
error: `Something went wrong. Please check your details, or write to ${CONTACT_EMAIL}.`,
|
||
};
|
||
|
||
function openMailto(data: ContactInput) {
|
||
const body = [
|
||
`Name: ${data.name}`,
|
||
`Organization: ${data.organization || "-"}`,
|
||
`Email: ${data.email}`,
|
||
`Interested in: ${data.interest}`,
|
||
"",
|
||
data.message ?? "",
|
||
].join("\n");
|
||
window.location.href = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(
|
||
`Enquiry: ${data.interest}`,
|
||
)}&body=${encodeURIComponent(body)}`;
|
||
}
|
||
|
||
function Contact() {
|
||
const [status, setStatus] = useState<ContactStatus>("idle");
|
||
|
||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
const form = e.currentTarget;
|
||
const f = new FormData(form);
|
||
const data: ContactInput = {
|
||
name: String(f.get("name") ?? ""),
|
||
organization: String(f.get("organization") ?? ""),
|
||
email: String(f.get("email") ?? ""),
|
||
interest: String(f.get("interest")) as ContactInput["interest"],
|
||
message: String(f.get("message") ?? ""),
|
||
website: String(f.get("website") ?? ""),
|
||
};
|
||
setStatus("sending");
|
||
try {
|
||
const result = await sendContactEnquiry({ data });
|
||
if (result.ok) {
|
||
form.reset();
|
||
setStatus("sent");
|
||
} else if (result.reason === "not-configured") {
|
||
// No email service set up on the server yet, so the enquiry goes through the visitor's email app.
|
||
openMailto(data);
|
||
setStatus("mailto");
|
||
} else {
|
||
setStatus("error");
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
setStatus("error");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section
|
||
id="contact"
|
||
className="sheet-enter theme-light section-y bg-background text-foreground"
|
||
>
|
||
<div className="container-x">
|
||
<div className="grid gap-12 md:grid-cols-12">
|
||
<div className="md:col-span-5" {...reveal("stagger")}>
|
||
<h2 className="font-display text-4xl font-bold leading-[1.05] md:text-5xl">
|
||
Build with us.
|
||
<br />
|
||
<Accent>Invest with us.</Accent>
|
||
</h2>
|
||
<p className="mt-6 text-lg leading-relaxed text-muted-foreground">
|
||
We work with institutional investors, family offices, and operators who want exposure
|
||
to India's next generation of AI-first companies.
|
||
</p>
|
||
|
||
<ul className="mt-10 divide-y divide-border border-y border-border">
|
||
<li>
|
||
<a href={`mailto:${CONTACT_EMAIL}`} className="group flex items-center gap-4 py-5">
|
||
<IconTile icon={Mail} />
|
||
<span className="flex-1">
|
||
<span className="block text-sm text-muted-foreground">Email</span>
|
||
<span className="font-display text-lg font-semibold transition-colors duration-300 group-hover:text-primary md:text-xl">
|
||
{CONTACT_EMAIL}
|
||
</span>
|
||
</span>
|
||
<ArrowUpRight
|
||
aria-hidden
|
||
className="h-5 w-5 text-primary transition-transform duration-300 group-hover:-translate-y-1 group-hover:translate-x-1"
|
||
/>
|
||
</a>
|
||
</li>
|
||
<li className="flex items-center gap-4 py-5">
|
||
<IconTile icon={MapPin} />
|
||
<span>
|
||
<span className="block text-sm text-muted-foreground">Studio</span>
|
||
<span className="font-display text-lg font-semibold md:text-xl">
|
||
Bangalore, India
|
||
</span>
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="md:col-span-7" {...reveal("up", 200)}>
|
||
<form
|
||
onSubmit={onSubmit}
|
||
{...reveal("stagger", 300)}
|
||
className="theme-dark space-y-5 rounded-3xl bg-background p-8 text-foreground shadow-2xl md:p-10"
|
||
>
|
||
<h3 className="font-display text-2xl font-bold">Send us a message</h3>
|
||
<div className="grid gap-5 sm:grid-cols-2">
|
||
<label className="block text-sm font-medium">
|
||
Full name
|
||
<input
|
||
required
|
||
name="name"
|
||
type="text"
|
||
autoComplete="name"
|
||
className={INPUT_CLASS}
|
||
/>
|
||
</label>
|
||
<label className="block text-sm font-medium">
|
||
Organization
|
||
<input
|
||
name="organization"
|
||
type="text"
|
||
autoComplete="organization"
|
||
className={INPUT_CLASS}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label className="block text-sm font-medium">
|
||
Email
|
||
<input
|
||
required
|
||
name="email"
|
||
type="email"
|
||
autoComplete="email"
|
||
placeholder="you@company.com"
|
||
className={INPUT_CLASS}
|
||
/>
|
||
</label>
|
||
<label className="block text-sm font-medium">
|
||
I'm interested in
|
||
<select name="interest" className={INPUT_CLASS}>
|
||
{INTERESTS.map((i) => (
|
||
<option key={i} className="bg-card">
|
||
{i}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="block text-sm font-medium">
|
||
Message
|
||
<textarea
|
||
name="message"
|
||
rows={4}
|
||
placeholder="Tell us about what you're looking for…"
|
||
className={`${INPUT_CLASS} resize-none`}
|
||
/>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="website"
|
||
tabIndex={-1}
|
||
autoComplete="off"
|
||
aria-hidden
|
||
className="absolute -left-[9999px] h-px w-px opacity-0"
|
||
/>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={status === "sending"}
|
||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-primary px-6 py-4 font-display text-sm font-semibold text-primary-foreground press hover:brightness-110 disabled:cursor-wait disabled:opacity-60"
|
||
>
|
||
{status === "sending" ? "Sending…" : "Send message"}
|
||
<ArrowRight
|
||
className="h-4 w-4 transition group-hover:translate-x-0.5"
|
||
aria-hidden
|
||
/>
|
||
</button>
|
||
<p
|
||
className={`text-center text-xs ${
|
||
status === "error" ? "text-destructive" : "text-muted-foreground"
|
||
} ${status === "sent" ? "font-medium text-primary" : ""}`}
|
||
aria-live="polite"
|
||
>
|
||
<span
|
||
key={status}
|
||
className="inline-block duration-500 animate-in fade-in slide-in-from-bottom-1"
|
||
>
|
||
{CONTACT_STATUS_TEXT[status]}
|
||
</span>
|
||
</p>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Footer() {
|
||
return (
|
||
<footer className="relative overflow-hidden bg-ink pt-20">
|
||
<div className="container-x">
|
||
<div className="grid gap-12 md:grid-cols-12" {...reveal("stagger")}>
|
||
<div className="md:col-span-6">
|
||
<img
|
||
src={logoFull}
|
||
alt="KapFoundry, Build Bold"
|
||
width={900}
|
||
height={697}
|
||
loading="lazy"
|
||
className="h-28 w-auto md:h-32"
|
||
/>
|
||
<p className="mt-6 max-w-md text-sm leading-relaxed text-muted-foreground">
|
||
We identify the problems, engineer the companies, and scale them with a shared engine,
|
||
building an ecosystem where many companies can succeed.
|
||
</p>
|
||
</div>
|
||
<nav aria-label="Footer" className="md:col-span-3">
|
||
<div className="mb-4 font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||
Explore
|
||
</div>
|
||
<ul className="grid grid-cols-2 gap-x-6 text-sm text-muted-foreground">
|
||
{NAV.map((n) => (
|
||
<li key={n.href}>
|
||
<a
|
||
href={n.href}
|
||
className="inline-flex min-h-10 min-w-10 items-center transition duration-300 hover:translate-x-1 hover:text-foreground"
|
||
>
|
||
{n.label}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</nav>
|
||
<div className="space-y-2 text-sm text-muted-foreground md:col-span-3">
|
||
<div className="mb-4 font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||
Studio
|
||
</div>
|
||
<div>Bangalore, India</div>
|
||
<a
|
||
href={`mailto:${CONTACT_EMAIL}`}
|
||
className="inline-flex min-h-10 items-center transition hover:text-primary"
|
||
>
|
||
{CONTACT_EMAIL}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
aria-hidden
|
||
{...reveal("mask")}
|
||
className="wordmark-fill mt-16 select-none text-center font-display text-[18vw] font-extrabold leading-[0.8] tracking-tighter text-transparent [-webkit-text-stroke:1px_rgb(255_255_255/0.12)]"
|
||
>
|
||
KAPFOUNDRY
|
||
</div>
|
||
<div className="border-t border-border py-6">
|
||
<div className="container-x text-xs text-muted-foreground">
|
||
© {new Date().getFullYear()} KapFoundry. All rights reserved.
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
function BackToTop() {
|
||
const visible = useScrolledPast(800);
|
||
return (
|
||
<a
|
||
href="#top"
|
||
aria-label="Back to top"
|
||
className={`fixed bottom-6 right-6 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-black/40 transition-all duration-300 hover:-translate-y-0.5 active:scale-95 ${
|
||
visible ? "opacity-100" : "pointer-events-none translate-y-4 opacity-0"
|
||
}`}
|
||
>
|
||
<ArrowUp className="h-5 w-5" aria-hidden />
|
||
</a>
|
||
);
|
||
}
|
||
|
||
/** Intro band for the About page. */
|
||
function AboutHero() {
|
||
return (
|
||
<section id="top" className="relative overflow-hidden bg-ink pb-20 pt-32 md:pb-28 md:pt-40">
|
||
<div className="mesh-gold absolute inset-0 opacity-30" aria-hidden />
|
||
<div className="dot-grid absolute inset-0" aria-hidden />
|
||
<div className="container-x relative grid items-end gap-12 lg:grid-cols-12">
|
||
<div className="lg:col-span-8">
|
||
<a
|
||
{...rise(80)}
|
||
href="/"
|
||
className="rise-in inline-flex min-h-10 items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-primary"
|
||
>
|
||
<ArrowRight className="h-4 w-4 rotate-180" aria-hidden />
|
||
Back to home
|
||
</a>
|
||
<h1
|
||
{...rise(180)}
|
||
className="rise-in mt-6 font-display text-5xl font-extrabold leading-[0.98] sm:text-6xl lg:text-7xl"
|
||
>
|
||
We don't wait for the future. <Accent>We build it.</Accent>
|
||
</h1>
|
||
<p
|
||
{...rise(380)}
|
||
className="rise-in mt-8 max-w-[60ch] text-lg leading-relaxed text-muted-foreground md:text-xl"
|
||
>
|
||
A venture studio and technology holding company that engineers category-defining,
|
||
AI-first companies, before they even exist.
|
||
</p>
|
||
</div>
|
||
<div {...rise(520)} className="rise-in hidden justify-end lg:col-span-4 lg:flex">
|
||
<img
|
||
src={logoFull}
|
||
alt=""
|
||
aria-hidden
|
||
width={900}
|
||
height={697}
|
||
className="w-[min(22rem,100%)] drop-shadow-[0_40px_60px_oklch(0.05_0.01_60/0.7)]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** Shared page shell: skip link, header, main content, footer and back-to-top. */
|
||
function SitePage({ page, children }: { page: SitePageName; children: ReactNode }) {
|
||
useScrollReveal();
|
||
|
||
// Arriving from another page with a hash (e.g. /#framework): the router restores scroll to
|
||
// the top after hydration, so jump to the requested section once it has settled.
|
||
useEffect(() => {
|
||
const id = decodeURIComponent(window.location.hash.slice(1));
|
||
if (!id) return;
|
||
const timer = window.setTimeout(() => {
|
||
document.getElementById(id)?.scrollIntoView({ behavior: "instant", block: "start" });
|
||
}, 60);
|
||
return () => window.clearTimeout(timer);
|
||
}, []);
|
||
return (
|
||
<>
|
||
<a
|
||
href="#main"
|
||
className="sr-only rounded-full bg-primary px-5 py-3 font-display text-sm font-semibold text-primary-foreground focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[60]"
|
||
>
|
||
Skip to content
|
||
</a>
|
||
<Nav page={page} />
|
||
<main id="main" className="min-h-screen bg-background text-foreground">
|
||
{children}
|
||
</main>
|
||
<Footer />
|
||
<BackToTop />
|
||
</>
|
||
);
|
||
}
|
||
|
||
export {
|
||
SitePage,
|
||
Hero,
|
||
AboutHero,
|
||
Ticker,
|
||
WhoWeAre,
|
||
VisionMission,
|
||
Different,
|
||
StudioAdvantage,
|
||
Framework,
|
||
Portfolio,
|
||
AISection,
|
||
Team,
|
||
Investors,
|
||
Contact,
|
||
};
|