Add venture detail pages, CropSphere showcase, and update Dockerfile

This commit is contained in:
R-Bharathraj
2026-10-06 18:38:03 +05:30
parent 1582052c98
commit bebf78cefd
22 changed files with 2026 additions and 153 deletions

View File

@@ -1,5 +1,8 @@
# Node 24 to match local development: package-lock.json written by a local `npm install`
# (npm 11) must also pass `npm ci` here, or the build fails with "lock file out of sync".
# 1. Build Stage
FROM node:22-alpine AS build
FROM node:24-alpine AS build
WORKDIR /app
@@ -7,7 +10,7 @@ WORKDIR /app
COPY package*.json ./
# Install dependencies
RUN npm ci
RUN npm ci --no-audit --no-fund
# Copy source code
COPY . .
@@ -16,17 +19,24 @@ COPY . .
RUN NITRO_PRESET=node-server npm run build
# 2. Production Stage
FROM node:22-alpine
FROM node:24-alpine
WORKDIR /app
# Copy built output from build stage
COPY --from=build /app/.output ./.output
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# The Nitro output is self-contained (dependencies are bundled), so nothing else is copied.
COPY --from=build --chown=node:node /app/.output ./.output
# Run as the image's unprivileged user rather than root.
USER node
EXPOSE 3000
# Lets Dokploy/Docker see when the server is actually answering requests.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["node", ".output/server/index.mjs"]

View File

@@ -4,16 +4,16 @@ All photos are from [Unsplash](https://unsplash.com) under the
[Unsplash License](https://unsplash.com/license) (free for commercial use, no attribution
required; credited here anyway). Resized to 1200px WebP.
| File | Photographer | Unsplash photo |
| ---------------------- | ------------------ | --------------------------------------- |
| `studio-foundry.webp` | Morteza Mohammadi | https://unsplash.com/photos/ZLzkwU49BdY |
| `holding-tower.webp` | Verne Ho | https://unsplash.com/photos/0LAJfSNa-xQ |
| `compound-spiral.webp` | Jean-Luc Benazet | https://unsplash.com/photos/dLpP34ADQPI |
| `ai-vision.webp` | Yael Yañez | https://unsplash.com/photos/jw245ZR2lz4 |
| `ai-agentic.webp` | Gabriele Malaspina | https://unsplash.com/photos/CjWsslYVnPI |
| `ai-enterprise.webp` | Winston Chen | https://unsplash.com/photos/iZe21DzHnUg |
| `ai-predictive.webp` | Tötös Ádám | https://unsplash.com/photos/3r8rcSy0Ffg |
| `ai-llm.webp` | Andrew Seaman | https://unsplash.com/photos/-m88z7ily-w |
| `ai-automation.webp` | Simon Kadula | https://unsplash.com/photos/8gr6bObQLOI |
| `ai-multiagent.webp` | James Wainscoat | https://unsplash.com/photos/b7MZ6iGIoSI |
| `ai-decision.webp` | Hassan Pasha | https://unsplash.com/photos/7SjEuEF06Zw |
| File | Photographer | Unsplash photo |
| ----------------------------- | ------------------ | --------------------------------------- |
| `pillar-studio-team.webp` | Annie Spratt | https://unsplash.com/photos/MChSQHxGZrQ |
| `pillar-holding-hq.webp` | Juzzepo | https://unsplash.com/photos/vcee2hRsjl0 |
| `pillar-compound-growth.webp` | Carlos Muza | https://unsplash.com/photos/hpjSkU2UYSU |
| `ai-vision.webp` | Yael Yañez | https://unsplash.com/photos/jw245ZR2lz4 |
| `ai-agentic.webp` | Gabriele Malaspina | https://unsplash.com/photos/CjWsslYVnPI |
| `ai-enterprise.webp` | Winston Chen | https://unsplash.com/photos/iZe21DzHnUg |
| `ai-predictive.webp` | Tötös Ádám | https://unsplash.com/photos/3r8rcSy0Ffg |
| `ai-llm.webp` | Andrew Seaman | https://unsplash.com/photos/-m88z7ily-w |
| `ai-automation.webp` | Simon Kadula | https://unsplash.com/photos/8gr6bObQLOI |
| `ai-multiagent.webp` | James Wainscoat | https://unsplash.com/photos/b7MZ6iGIoSI |
| `ai-decision.webp` | Hassan Pasha | https://unsplash.com/photos/7SjEuEF06Zw |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -12,21 +12,21 @@ required; credited here anyway). Resized WebP.
| `stage-launch.webp` | SpaceX | https://unsplash.com/photos/TV2gg2kZD1o |
| `stage-scale.webp` | Mara F | https://unsplash.com/photos/tZdFQvqJuQQ |
| `stage-spinoff.webp` | Curioso Photography | https://unsplash.com/photos/OOiygaquXZs |
| `stack-engineering.webp` | Manuel | https://unsplash.com/photos/CANL3bzp6wU |
| `stack-engineering.webp` | Peaky Frames | https://unsplash.com/photos/GAoGLvYLt3c |
| `stack-design.webp` | Amélie Mourichon | https://unsplash.com/photos/sv8oOQaUb-o |
| `stack-cloud.webp` | Rebecca Campbell | https://unsplash.com/photos/5nN9NX6dJxc |
| `stack-cloud.webp` | Eric Stoynov | https://unsplash.com/photos/UfR_yqIcd4g |
| `stack-legal.webp` | Sasun Bughdaryan | https://unsplash.com/photos/qVOJqYI37qE |
| `stack-finance.webp` | Kelly Sikkema | https://unsplash.com/photos/M98NRBuzbpc |
| `stack-marketing.webp` | Kayle Kaupanger | https://unsplash.com/photos/GVi0ijl8ExM |
| `stack-sales.webp` | Rock Staar | https://unsplash.com/photos/NzIV4vOBA7s |
| `stack-growth.webp` | Sable Flow | https://unsplash.com/photos/T74mVg__F_k |
| `stack-data.webp` | Luke Chesser | https://unsplash.com/photos/JKUTrJ4vK00 |
| `stack-data.webp` | Resource Database | https://unsplash.com/photos/TIUyoNGM0no |
| `stack-governance.webp` | Danielle Cerullo | https://unsplash.com/photos/bIZJRVBLfOM |
| `vision-city.webp` | Pradeep Gopal (Bengaluru) | https://unsplash.com/photos/4J3CHmrFiIg |
| `investors-skyline.webp` | Himmel S | https://unsplash.com/photos/Io1lz5bZ0as |
| `principle-evidence.webp` | Mika Baumeister | https://unsplash.com/photos/Wpnoqo2plFA |
| `principle-engine.webp` | Nikita Turkovich | https://unsplash.com/photos/ioxS2dYRiJw |
| `principle-standalone.webp` | Nathan Jennings | https://unsplash.com/photos/VsPsf4F5Pi0 |
| `model-build.webp` | Art Institute of Chicago | https://unsplash.com/photos/sAlWjm2huck |
| `model-build.webp` | Lucas Kepner | https://unsplash.com/photos/Yn8D5B8C-eY |
| `model-spinoff.webp` | Markus Spiske | https://unsplash.com/photos/vrbZVyX2k4I |
| `model-compound.webp` | Marek Studzinski | https://unsplash.com/photos/tnT54YSnlL0 |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -0,0 +1,766 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import {
ArrowDown,
ArrowRight,
BrainCircuit,
Coins,
Database,
Globe,
Recycle,
ShoppingBasket,
Sprout,
Store,
User,
Wheat,
type LucideIcon,
} from "lucide-react";
import {
Accent,
EASE_STAGE,
FillWords,
SectionHeader,
reveal,
usePinnedLayout,
} from "@/components/site/sections";
// Cropsphere's story, adapted from the founding team's own description. Plans and potential
// revenue are worded as such; nothing here claims traction.
const CHAIN = ["Farm", "Food", "Consumer", "Regeneration"];
const LAYERS: { layer: string; product: string; role: string; icon: LucideIcon }[] = [
{
layer: "Agriculture",
product: "HarvestHQ",
role: "An AI + IoT agricultural operating system.",
icon: Sprout,
},
{
layer: "Commerce",
product: "Cropsphere Produce",
role: "A traceable farm-to-consumer marketplace.",
icon: ShoppingBasket,
},
{
layer: "Regeneration",
product: "O-Points",
role: "Rewards and measures regenerative actions.",
icon: Recycle,
},
{
layer: "Consumer",
product: "Living",
role: "The physical consumer and retail experience layer.",
icon: Store,
},
];
/** Section 1: what Cropsphere is, and the four layers it connects. */
function Overview() {
return (
<section className="sheet-enter theme-light section-y bg-background text-foreground">
<div className="container-x">
<SectionHeader
eyebrow="What is Cropsphere"
icon={Sprout}
title={
<>
Not an app. Not a marketplace.{" "}
<Accent>An operating ecosystem for regenerative agriculture.</Accent>
</>
}
compact
>
<p>
Cropsphere is an integrated regenerative-agriculture operating ecosystem. It connects
four things that operate separately today:
</p>
</SectionHeader>
{/* Farm → Food → Consumer → Regeneration */}
{/* Each step lights up in turn as the row crosses the screen (.chain-step). */}
<ol className="mt-10 flex flex-wrap items-center gap-3 font-display text-xl font-bold md:text-3xl">
{CHAIN.map((c, i) => (
<li
key={c}
className="chain-step flex items-center gap-3"
style={{ "--i": i } as CSSProperties}
>
<span className="rounded-full border border-primary/40 bg-primary/10 px-5 py-2.5 text-primary">
{c}
</span>
{i < CHAIN.length - 1 ? (
<ArrowRight className="h-5 w-5 text-foreground/40" aria-hidden />
) : null}
</li>
))}
</ol>
<blockquote
{...reveal("up", 120)}
className="mt-14 max-w-4xl border-l-2 border-primary pl-6 font-display text-2xl font-semibold leading-snug tracking-tight md:pl-8 md:text-4xl"
>
Use technology to make farming more intelligent, make food more traceable, and make
regenerative actions economically valuable.
</blockquote>
<div className="mt-20">
<div className="flex flex-wrap items-end justify-between gap-4">
<h3 className="font-display text-3xl font-bold md:text-4xl">
Four layers, one system.
</h3>
<span className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Regenerative Agri Operating Ecosystem (RAOE)
</span>
</div>
{/* The layers stack on top of each other as you scroll (.layer-stack). */}
<ul className="layer-stack mt-8 flex flex-col gap-5 pb-[10vh]">
{LAYERS.map((l, i) => {
const Icon = l.icon;
return (
<li
key={l.product}
style={{ "--i": i } as CSSProperties}
className="theme-dark group relative flex min-h-[13rem] flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/10 bg-background p-7 text-foreground shadow-[0_-24px_60px_-30px_oklch(0.05_0.01_60/0.8)] md:flex-row md:items-center md:p-10"
>
<div className="glow absolute -right-16 -top-16 h-56 w-56 opacity-40 transition-opacity duration-500 group-hover:opacity-90" />
<div className="relative flex items-center gap-5">
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-primary text-primary-foreground">
<Icon className="h-6 w-6" aria-hidden />
</span>
<div>
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
0{i + 1} · {l.layer}
</div>
<div className="mt-1 font-display text-3xl font-bold tracking-tight md:text-5xl">
{l.product}
</div>
</div>
</div>
<p className="relative text-lg leading-relaxed text-muted-foreground md:max-w-sm md:text-right">
{l.role}
</p>
</li>
);
})}
</ul>
</div>
</div>
</section>
);
}
/** A small left-to-right (wrapping) chain of steps. */
function Chain({ items, tone = "dark" }: { items: string[]; tone?: "dark" | "gold" }) {
return (
<ol className="mt-5 flex flex-wrap items-center gap-2">
{items.map((t, i) => (
<li key={t} className="flex items-center gap-2">
<span
className={`rounded-full px-3.5 py-1.5 text-sm ${
tone === "gold"
? "bg-primary text-primary-foreground"
: "border border-white/10 bg-white/[0.05] text-foreground"
}`}
>
{t}
</span>
{i < items.length - 1 ? (
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
) : null}
</li>
))}
</ol>
);
}
const STEPS: { n: string; title: string; product: string; body: ReactNode }[] = [
{
n: "01",
title: "Connect farms",
product: "Network",
body: (
<>
<p>
Farmers and landowners join the Cropsphere network. HarvestHQ gathers what each farm needs
to be understood, combining AI, IoT, satellite and farm-level data to support agricultural
decisions.
</p>
<Chain
items={[
"Land and crop information",
"Soil and environmental data",
"Weather",
"Farm activity",
"Crop images",
"Market demand",
]}
/>
</>
),
},
{
n: "02",
title: "HarvestHQ helps run the farm",
product: "HarvestHQ",
body: (
<>
<p>The intelligence layer, across the whole season:</p>
<Chain
tone="gold"
items={[
"Crop planning",
"Irrigation",
"Disease monitoring",
"Input optimisation",
"Yield prediction",
"Market planning",
]}
/>
<div className="mt-6 rounded-2xl border border-white/10 bg-white/[0.04] p-5">
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
For example
</div>
<p className="mt-2">
A farmer has two acres. Instead of only asking “What should I grow?”, HarvestHQ can
analyse the farm's information and help work out{" "}
<span className="font-semibold text-foreground">
what to grow, how to grow it, how much input to use, the expected yield and the likely
market demand.
</span>
</p>
</div>
<p className="mt-6">
The long-term vision is to move from advice alone to an operating system that connects
recommendations with execution and automation.
</p>
</>
),
},
{
n: "03",
title: "Cropsphere Produce takes it to market",
product: "Cropsphere Produce",
body: (
<>
<p>Once production is connected, the next question is where the produce goes.</p>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 p-5">
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Today
</div>
<Chain items={["Farmer", "Many intermediaries", "Retailer", "Consumer"]} />
</div>
<div className="rounded-2xl border border-primary/40 bg-primary/10 p-5">
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
With Cropsphere
</div>
<Chain tone="gold" items={["Farm", "Cropsphere", "Consumer"]} />
</div>
</div>
<p className="mt-6">
With digital traceability, a consumer could see which farm, what was grown and how, right
through to purchase. The marketplace can support subscriptions, quick commerce, standard
e-commerce, farm experiences and institutional or B2B supply.
</p>
</>
),
},
{
n: "04",
title: "O-Points rewards regeneration",
product: "O-Points",
body: (
<>
<p>
Whenever people take part in regenerative activity, they can earn O-Points, turning
otherwise invisible environmental actions into a measurable, digital layer of
participation.
</p>
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
{[
"Buy produce",
"Plant a sapling or seedball",
"Register and verify an existing tree",
"Join regenerative activities",
].map((a) => (
<li
key={a}
className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-4"
>
<span className="text-foreground">{a}</span>
<span className="shrink-0 rounded-full bg-primary/15 px-3 py-1 text-xs font-semibold text-primary">
+ O-Points
</span>
</li>
))}
</ul>
<p className="mt-6">
Behind it: reward logic, verification, a ledger and wallet, and over time a mechanism for
aggregating carbon value.
</p>
</>
),
},
{
n: "05",
title: "Living brings it to the consumer",
product: "Living",
body: (
<>
<p>
Living is Cropsphere's physical, offline layer: hubs, product sampling, farm experiences,
wellness and consumer engagement. Its first touchpoint is a vending-machine network in
tier-1 and tier-2 cities.
</p>
<Chain
items={[
"A consumer buys a smoothie or produce",
"The sale earns revenue",
"They discover Cropsphere",
"They join O-Points",
]}
/>
<p className="mt-6 font-display text-xl font-semibold text-foreground">
Not merely a vending machine: revenue, brand, customer acquisition and an entry point to
the ecosystem, in one.
</p>
</>
),
},
];
/** Section 2: the five steps, each pinned beside its number as you scroll. */
function HowItWorks() {
return (
<section className="section-y relative bg-ink">
<div className="container-x">
<SectionHeader
title={
<>
How it <Accent>actually works.</Accent>
</>
}
>
<p>Four products, one flywheel. Here is the path from a farm to a consumer and back.</p>
</SectionHeader>
{/* A stem grows down the steps as you read; each step's node lights as it passes. */}
<ol className="relative mt-16 pl-10 md:pl-16">
<span
aria-hidden
className="absolute bottom-0 left-[11px] top-0 w-px bg-white/10 md:left-[19px]"
>
<span className="stem-grow block h-full w-full bg-gradient-to-b from-primary via-primary to-primary/30" />
</span>
{STEPS.map((s) => (
<li
key={s.n}
className="relative grid gap-6 border-t border-white/10 py-12 md:py-16 lg:grid-cols-12 lg:gap-12"
>
<span
aria-hidden
className="stem-node absolute -left-10 top-14 grid h-6 w-6 place-items-center rounded-full border-2 border-primary bg-primary md:-left-16 md:top-[4.5rem] md:h-10 md:w-10"
>
<Sprout className="h-3 w-3 text-ink md:h-4 md:w-4" />
</span>
<div className="lg:col-span-4">
<div className="lg:sticky lg:top-32" {...reveal("stagger")}>
<span className="font-display text-6xl font-extrabold tabular-nums text-transparent [-webkit-text-stroke:1px_color-mix(in_oklab,var(--primary)_75%,transparent)] md:text-7xl">
{s.n}
</span>
<h3 className="mt-4 font-display text-3xl font-bold leading-tight md:text-4xl">
{s.title}
</h3>
<span className="mt-4 inline-block rounded-full bg-primary/15 px-3 py-1 font-display text-xs font-semibold uppercase tracking-[0.14em] text-primary">
{s.product}
</span>
</div>
</div>
<div
{...reveal("up", 100)}
className="space-y-0 text-lg leading-relaxed text-muted-foreground lg:col-span-8"
>
{s.body}
</div>
</li>
))}
</ol>
</div>
</section>
);
}
const FLYWHEEL: { label: string; icon: LucideIcon; desc: string }[] = [
{ label: "Farm", icon: Sprout, desc: "HarvestHQ improves agricultural intelligence." },
{ label: "Food", icon: Wheat, desc: "Cropsphere Produce brings verified production to market." },
{ label: "Consumer", icon: User, desc: "Consumers purchase and take part." },
{
label: "O-Points",
icon: Recycle,
desc: "Their regenerative actions are recorded and rewarded.",
},
{
label: "Impact",
icon: Globe,
desc: "Regenerative activity creates measurable environmental data.",
},
{
label: "Value",
icon: Coins,
desc: "That data and activity can support future carbon and ESG monetisation.",
},
{ label: "Data", icon: Database, desc: "More farms, transactions and users create more data." },
{
label: "Better intelligence",
icon: BrainCircuit,
desc: "More data sharpens how the platform understands farms, demand and regeneration. More farms join, and the cycle repeats.",
},
];
const SPIN_MS = 2800;
/** Place n items evenly on a circle (radius in % of the box), starting at 12 o'clock. */
function onRing(i: number, n: number, radius: number): CSSProperties {
const a = ((-90 + (i * 360) / n) * Math.PI) / 180;
return {
left: `${(50 + radius * Math.cos(a)).toFixed(2)}%`,
top: `${(50 + radius * Math.sin(a)).toFixed(2)}%`,
};
}
// Scroll length per flywheel stage while the section is pinned.
const FLYWHEEL_STEP_SVH = 32;
/**
* Section 3: the flywheel. Large screens: the section pins and scrolling turns the wheel one
* stage at a time (pointer and keyboard can also pick a stage); the centre explains it. Short
* screens and reduced motion: the wheel advances on its own. Small screens: a looping list.
*/
function Flywheel() {
const [active, setActive] = useState(0);
const [held, setHeld] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const sectionRef = useRef<HTMLElement>(null);
const ringRef = useRef<SVGGElement>(null);
const n = FLYWHEEL.length;
const pinned = usePinnedLayout("(min-width: 1024px)");
// Pinned: scroll position picks the stage and spins the outer ring.
useEffect(() => {
const section = sectionRef.current;
if (!pinned || !section) return;
let frame = 0;
const update = () => {
frame = 0;
const distance = section.offsetHeight - window.innerHeight;
const p =
distance > 0
? Math.min(1, Math.max(0, -section.getBoundingClientRect().top / distance))
: 0;
setActive(Math.min(n - 1, Math.floor(p * n)));
if (ringRef.current) ringRef.current.style.transform = `rotate(${(p * 120).toFixed(2)}deg)`;
};
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, n]);
// Not pinned: advance on a timer while in view.
useEffect(() => {
const el = ref.current;
if (!el || pinned || held || window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return;
let timer = 0;
const io = new IntersectionObserver(([e]) => {
window.clearInterval(timer);
if (e.isIntersecting)
timer = window.setInterval(() => setActive((a) => (a + 1) % n), SPIN_MS);
});
io.observe(el);
return () => {
io.disconnect();
window.clearInterval(timer);
};
}, [pinned, held, n]);
const current = FLYWHEEL[active];
const CurrentIcon = current.icon;
return (
<section
ref={sectionRef}
className={`sheet-enter theme-light relative bg-background text-foreground ${pinned ? "" : "section-y"}`}
style={pinned ? { height: `${100 + n * FLYWHEEL_STEP_SVH}svh` } : undefined}
>
<div className={pinned ? "sticky top-0 flex h-[100svh] items-center pt-16" : ""}>
<div className="container-x grid w-full items-center gap-14 lg:grid-cols-12">
<div className="lg:col-span-5" {...reveal("stagger")}>
<span className="eyebrow">The flywheel</span>
<h2 className="mt-5 font-display text-4xl font-bold leading-[1.04] md:text-5xl">
Every turn makes the <Accent>next one stronger.</Accent>
</h2>
<p className="mt-6 max-w-[48ch] text-lg leading-relaxed text-muted-foreground">
Cropsphere is a flywheel, not four separate products. Better farming creates better
food; engaged consumers create data and revenue; regenerative activity creates
measurable environmental value, and all of it brings more farms in.
</p>
{pinned ? (
<div className="mt-10 flex items-center gap-5">
<span className="font-display text-sm font-semibold tabular-nums">
<span className="text-primary">{String(active + 1).padStart(2, "0")}</span>
<span className="text-muted-foreground"> / {String(n).padStart(2, "0")}</span>
</span>
<span className="h-px flex-1 overflow-hidden bg-foreground/10">
<span
className="block h-full origin-left bg-primary transition-transform duration-500"
style={{ transform: `scaleX(${(active + 1) / n})` }}
/>
</span>
<span className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Scroll to turn
</span>
</div>
) : null}
</div>
{/* Wheel (large screens) */}
<div
ref={ref}
className="relative mx-auto hidden aspect-square w-full max-w-[34rem] lg:col-span-7 lg:block"
onPointerEnter={() => setHeld(true)}
onPointerLeave={() => setHeld(false)}
>
<svg
viewBox="0 0 400 400"
className="absolute inset-0 h-full w-full overflow-visible"
aria-hidden
>
{/* Outer dashed ring: spins as the flywheel is scrolled. */}
<g ref={ringRef} style={{ transformOrigin: "200px 200px" }}>
<circle
cx="200"
cy="200"
r="194"
fill="none"
strokeWidth="1.5"
strokeDasharray="2 12"
className="stroke-primary/50"
/>
</g>
<circle
cx="200"
cy="200"
r="168"
fill="none"
strokeWidth="1.5"
className="stroke-foreground/10"
/>
<circle
cx="200"
cy="200"
r="168"
fill="none"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 168}
strokeDashoffset={2 * Math.PI * 168 * (1 - (active + 1) / n)}
transform="rotate(-90 200 200)"
className="stroke-primary transition-[stroke-dashoffset] duration-700"
/>
</svg>
{FLYWHEEL.map((f, i) => {
const Icon = f.icon;
const on = i === active;
return (
<button
key={f.label}
type="button"
style={onRing(i, n, 42)}
onPointerEnter={() => setActive(i)}
onFocus={() => {
setHeld(true);
setActive(i);
}}
onBlur={() => setHeld(false)}
aria-label={`${f.label}: ${f.desc}`}
aria-pressed={on}
className="group absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2"
>
<span
className={`grid h-14 w-14 place-items-center rounded-2xl border transition-all duration-500 ${EASE_STAGE} ${
on
? "scale-110 border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/30"
: "border-border bg-card text-primary group-hover:border-primary/60"
}`}
>
<Icon className="h-5 w-5" aria-hidden />
</span>
<span
className={`whitespace-nowrap font-display text-xs font-semibold uppercase tracking-wider transition-colors ${
on ? "text-foreground" : "text-muted-foreground"
}`}
>
{f.label}
</span>
</button>
);
})}
<div className="absolute inset-[24%] grid place-items-center text-center">
<div key={active} className="duration-700 animate-in fade-in zoom-in-95">
<CurrentIcon className="mx-auto h-8 w-8 text-primary" aria-hidden />
<div className="mt-3 font-display text-2xl font-bold">{current.label}</div>
<p className="mx-auto mt-2 max-w-[22ch] text-sm leading-snug text-muted-foreground">
{current.desc}
</p>
</div>
</div>
</div>
{/* Loop list (small screens) */}
<ol className="space-y-2 lg:hidden" {...reveal("stagger")}>
{FLYWHEEL.map((f, i) => {
const Icon = f.icon;
return (
<li key={f.label}>
<div className="flex items-start gap-4 rounded-2xl border border-border bg-card p-4">
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground">
<Icon className="h-5 w-5" aria-hidden />
</span>
<span>
<span className="block font-display text-lg font-bold">{f.label}</span>
<span className="block text-sm leading-snug text-muted-foreground">
{f.desc}
</span>
</span>
</div>
{i < FLYWHEEL.length - 1 ? (
<ArrowDown className="mx-auto my-1 h-4 w-4 text-primary/60" aria-hidden />
) : null}
</li>
);
})}
<li className="pt-2 text-center font-display text-sm font-semibold text-primary">
↺ More farms join, and the cycle repeats
</li>
</ol>
</div>
</div>
</section>
);
}
const REVENUE: { product: string; items: string[] }[] = [
{
product: "HarvestHQ",
items: ["AI and platform subscriptions", "Farm management and technology services"],
},
{
product: "Cropsphere Produce",
items: ["Produce margins", "Subscriptions", "Marketplace commissions", "Value-added products"],
},
{
product: "Living",
items: ["Vending-machine sales", "Retail", "Experiences", "Wellness and consumer offerings"],
},
{
product: "O-Points and regeneration",
items: [
"Corporate sustainability programmes",
"Carbon-value realisation",
"ESG and impact data services",
"Partnerships",
],
},
];
/** Section 4: where revenue can come from, layer by layer. */
function Revenue() {
return (
<section className="section-y relative overflow-hidden bg-ink">
<div className="glow glow-drift absolute -right-48 top-0 h-[36rem] w-[36rem] opacity-40" />
<div className="container-x relative">
<SectionHeader
eyebrow="Business model"
icon={Coins}
title={
<>
Revenue from <Accent>every layer.</Accent>
</>
}
>
<p>
A multi-layer revenue architecture rather than a business that depends on selling
software alone. Potential revenue streams:
</p>
</SectionHeader>
{/* Columns rise at different speeds, like bars growing (.rise-staggered). */}
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{REVENUE.map((r, i) => (
<div
key={r.product}
style={{ "--i": i } as CSSProperties}
className="rise-staggered rounded-3xl border border-border bg-card/70 p-7"
>
<div className="font-display text-xl font-bold text-primary">{r.product}</div>
<ul className="mt-5 space-y-3">
{r.items.map((it) => (
<li key={it} className="flex gap-3 text-foreground/90">
<span className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
{it}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</section>
);
}
/** Section 5: the whole story in one paragraph, brightening word by word as it scrolls. */
function InOneParagraph() {
return (
<section className="sheet-enter theme-light section-y bg-background text-foreground">
<div className="container-x">
<span className="eyebrow">In one paragraph</span>
<p className="mt-8 max-w-5xl font-display text-3xl font-bold leading-[1.12] tracking-tight md:text-5xl">
<FillWords
text="Cropsphere is building the operating infrastructure for regenerative agriculture."
className="text-primary"
/>
<FillWords text="We connect farms through AI, move their produce through a traceable marketplace, reward consumers and farmers for regenerative actions through O-Points, and bring the ecosystem to consumers through Living." />
</p>
<p
{...reveal("up", 150)}
className="mt-10 max-w-[65ch] text-lg leading-relaxed text-muted-foreground"
>
The result is a closed loop: better farming creates better food, better consumer
engagement creates more data and revenue, and regenerative activity creates measurable
environmental value.
</p>
</div>
</section>
);
}
/** Cropsphere's full story, shown on its venture page in place of the generic vertical lists. */
export function CropsphereStory() {
return (
<>
<Overview />
<HowItWorks />
<Flywheel />
<Revenue />
<InOneParagraph />
</>
);
}

View File

@@ -22,6 +22,23 @@ export function useSmoothScroll() {
}, []);
}
/**
* Marks page sections that are well off screen with data-offscreen, which pauses their looping
* CSS animations (see styles.css) so hidden decoration never competes with the scroll.
*/
export function usePauseOffscreen() {
useEffect(() => {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) e.target.toggleAttribute("data-offscreen", !e.isIntersecting);
},
{ rootMargin: "200px 0px" },
);
document.querySelectorAll("main > section, main > div").forEach((el) => io.observe(el));
return () => io.disconnect();
}, []);
}
const LINKISH = "a, button, [role='button'], summary, [data-cursor]";
const TEXTISH = "input, textarea, select, [contenteditable='true']";

View File

@@ -3,6 +3,8 @@ import { ArrowRight, ArrowUpRight, Check, Compass, Layers, Minus, Sparkles } fro
import { CONTACT_EMAIL } from "@/lib/contact";
import { SplitTitle, type TitlePart } from "@/components/site/motion";
import { IndustryIndex, MoltenMark } from "@/components/site/creative";
import { CropsphereStory } from "@/components/site/cropsphere";
import { DoorMileStory, LoyalyStory } from "@/components/site/venture-stories";
// Section photography (Unsplash License): see src/assets/sections/CREDITS.md.
import principleEvidence from "@/assets/sections/principle-evidence.webp";
import principleEngine from "@/assets/sections/principle-engine.webp";
@@ -701,9 +703,23 @@ export function FrameworkPrinciples() {
);
}
const VENTURES = VERTICALS.filter((v) => v.example).map((v) => {
/** Ventures in the studio, from each vertical's example; each gets a page at /ventures/<slug>. */
export const VENTURES = VERTICALS.filter((v) => v.example).map((v) => {
const [name, ...rest] = (v.example ?? "").split(". ");
return { name, pitch: rest.join(". "), vertical: v.name, img: v.img, icon: v.icon };
return {
name,
slug: name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, ""),
pitch: rest.join(". "),
vertical: v.name,
tag: v.tag,
problem: v.problem,
solution: v.solution,
img: v.img,
icon: v.icon,
};
});
/** Portfolio: ventures already taking shape in the studio. */
@@ -723,14 +739,14 @@ export function Ventures() {
<p>A first look at companies being engineered on the KapFoundry engine.</p>
</SectionHeader>
<div className="mt-16 grid gap-6 md:grid-cols-2">
<div className="mt-16 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{VENTURES.map((v, i) => {
const Icon = v.icon;
return (
<article
key={v.name}
{...reveal("clip", i * 120)}
className="theme-dark group relative flex min-h-[30rem] flex-col justify-end overflow-hidden rounded-[2rem] bg-background p-8 text-foreground md:min-h-[36rem] md:p-10"
className="theme-dark group relative flex min-h-[30rem] flex-col justify-end overflow-hidden rounded-[2rem] bg-background p-8 text-foreground md:min-h-[36rem] md:p-10 md:[&:last-child:nth-child(odd)]:col-span-2 lg:[&:last-child:nth-child(odd)]:col-span-1"
>
<img
src={v.img}
@@ -745,13 +761,30 @@ export function Ventures() {
<Icon className="h-3.5 w-3.5" aria-hidden />
{v.vertical}
</span>
<h3 className="mt-6 font-display text-4xl font-extrabold tracking-tight lg:text-6xl">
<h3 className="mt-6 font-display text-[2rem] font-extrabold tracking-tight min-[360px]:text-4xl xl:text-5xl">
{v.name}
</h3>
<p className="mt-4 max-w-[40ch] text-lg leading-relaxed text-muted-foreground">
{v.pitch}
</p>
{/* Visible label for the card-wide link below. */}
<span
aria-hidden
className="mt-8 inline-flex items-center gap-3 rounded-full border border-white/20 bg-white/10 py-2 pl-6 pr-2 font-display text-sm font-semibold text-foreground backdrop-blur-md transition-colors duration-500 group-hover:border-primary group-hover:bg-primary group-hover:text-primary-foreground"
>
Explore venture
<span className="grid h-9 w-9 place-items-center rounded-full bg-primary text-primary-foreground transition-[background-color,color,rotate] duration-500 group-hover:rotate-45 group-hover:bg-primary-foreground group-hover:text-primary">
<ArrowUpRight className="h-4 w-4" aria-hidden />
</span>
</span>
</div>
{/* One link over the whole card: click anywhere, one tab stop. */}
<a
href={`/ventures/${v.slug}`}
data-cursor="Explore"
aria-label={`Explore ${v.name}`}
className="absolute inset-0 z-10 rounded-[2rem]"
/>
</article>
);
})}
@@ -963,3 +996,301 @@ export function PortfolioIndex() {
/>
);
}
// ---------------------------------------------------------------------------------------------
// Venture detail page (/ventures/$slug)
// ---------------------------------------------------------------------------------------------
/** Ventures with a full story of their own; the rest get the generic vertical sections. */
const VENTURE_STORIES: Record<string, () => ReactNode> = {
cropsphere: CropsphereStory,
"doormile-wings": DoorMileStory,
"loyaly-ai": LoyalyStory,
};
/** Looks a venture up by its URL slug. */
export function findVenture(slug: string) {
return VENTURES.find((v) => v.slug === slug);
}
/**
* Everything on this page comes from what the site already says about the venture and its
* vertical; nothing about stage, traction or team is implied.
*/
export function VentureDetail({ slug }: { slug: string }) {
const v = findVenture(slug);
if (!v) return null;
const i = VENTURES.indexOf(v);
const next = VENTURES[(i + 1) % VENTURES.length];
const Icon = v.icon;
const Story = VENTURE_STORIES[v.slug];
return (
<>
{/* Hero: the venture's own photo, full bleed */}
<section
id="top"
className="hero-timeline relative flex min-h-[100svh] flex-col overflow-hidden bg-ink"
>
<div className="hero-media absolute inset-0" aria-hidden>
{/* Pulls back into a rounded card as the hero scrolls away (.venture-photo). */}
<div className="venture-photo absolute inset-0 overflow-hidden">
<img
src={v.img}
alt=""
fetchPriority="high"
className="hero-zoom absolute inset-0 h-full w-full object-cover opacity-70"
/>
<div className="absolute inset-0 bg-gradient-to-r from-ink via-ink/75 to-ink/20" />
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-ink to-transparent" />
</div>
</div>
<div className="hero-copy container-x relative flex flex-1 flex-col justify-between gap-12 pb-12 pt-28 md:pt-36">
<nav
aria-label="Breadcrumb"
{...rise(100)}
className="rise-in flex flex-wrap items-center gap-3 font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground"
>
<a href="/portfolio" className="transition-colors hover:text-primary">
Portfolio
</a>
<span className="h-px w-8 bg-primary/50" aria-hidden />
<span className="text-primary">{v.vertical}</span>
</nav>
<div>
<span
{...rise(250)}
className="rise-in inline-flex items-center gap-2 rounded-full bg-primary px-3.5 py-1.5 font-display text-[11px] font-semibold uppercase tracking-wider text-primary-foreground"
>
<Icon className="h-3.5 w-3.5" aria-hidden />
In the studio
</span>
<h1 className="venture-name mt-6 max-w-[14ch] font-display text-[clamp(2.75rem,11vw,4rem)] font-extrabold leading-[0.92] tracking-tighter md:text-[clamp(4rem,9vw,8.5rem)]">
<SplitTitle parts={[{ accent: v.name }]} />
</h1>
<p
{...rise(700)}
className="rise-in mt-8 max-w-[46ch] text-xl leading-relaxed text-white/80 md:text-2xl"
>
{v.pitch}
</p>
<div {...rise(850)} className="rise-in mt-10 flex flex-wrap gap-4">
<a
href="/contact"
data-magnetic
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 hover:brightness-110"
>
Partner 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:border-white/30 hover:bg-white/10"
>
Back to portfolio
</a>
</div>
</div>
</div>
</section>
{Story ? (
<Story />
) : (
<>
{/* The problem: the market this venture is built for */}
<section className="sheet-enter theme-light section-y bg-background text-foreground">
<div className="container-x grid gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<div className="lg:sticky lg:top-32" {...reveal("stagger")}>
<span className="eyebrow">The problem</span>
<h2 className="mt-5 font-display text-4xl font-bold leading-[1.04] md:text-5xl">
{v.vertical}: <Accent>{v.tag.toLowerCase()}.</Accent>
</h2>
<p className="mt-6 max-w-[48ch] text-lg leading-relaxed text-muted-foreground">
{v.name} is being built for a market held back by the same problems, again and
again.
</p>
</div>
</div>
<ol className="lg:col-span-7">
{v.problem.map((p, k) => (
<li
key={p}
{...reveal("up", k * 40)}
className="relative flex items-baseline gap-6 py-6 md:py-7"
>
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-border" />
<span
aria-hidden
className="line-draw absolute inset-x-0 top-0 h-px bg-primary/60"
/>
<span className="w-10 shrink-0 font-display text-sm font-semibold tabular-nums text-primary">
0{k + 1}
</span>
<span className="font-display text-2xl font-bold tracking-tight md:text-3xl">
{p}
</span>
</li>
))}
</ol>
</div>
</section>
{/* What we build */}
<section className="section-y relative overflow-hidden bg-ink">
<div className="glow glow-drift absolute -right-48 top-0 h-[36rem] w-[36rem] opacity-40" />
<div className="container-x relative">
<SectionHeader
title={
<>
What we build, <Accent>AI-first.</Accent>
</>
}
>
<p>
Where KapFoundry focuses in {v.vertical.toLowerCase()}, and the ground {v.name} is
built on.
</p>
</SectionHeader>
<ul className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{v.solution.map((s) => (
<li
key={s}
className="tilt-in group flex items-start gap-4 rounded-3xl border border-border bg-card/70 p-6 transition-colors duration-500 hover:border-primary/50 md:p-7"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary/15 text-primary transition-colors duration-500 group-hover:bg-primary group-hover:text-primary-foreground">
<Check className="h-4 w-4" aria-hidden />
</span>
<span className="pt-1.5 font-display text-lg font-semibold leading-snug">
{s}
</span>
</li>
))}
</ul>
</div>
</section>
</>
)}
{/* Built on the engine: the same framework and stack as every venture */}
<section className="sheet-enter theme-light section-y bg-background text-foreground">
<div className="container-x">
<SectionHeader
title={
<>
Built on the <Accent>KapFoundry engine.</Accent>
</>
}
>
<p>
Like every venture in the studio, {v.name} moves through the same six stages and
launches with a mature company's stack.
</p>
</SectionHeader>
<ol
className="mt-14 grid grid-cols-1 gap-3 min-[400px]:grid-cols-2 md:grid-cols-3 lg:grid-cols-6"
{...reveal("stagger")}
>
{FRAMEWORK.map((f) => {
const StageIcon = f.icon;
return (
<li key={f.n} className="rounded-3xl border border-border bg-card p-5">
<div className="flex items-center justify-between">
<span className="font-display text-sm font-semibold tabular-nums text-primary">
{f.n}
</span>
<StageIcon className="h-4 w-4 text-primary" aria-hidden />
</div>
<div className="mt-6 font-display text-xl font-bold">{f.title}</div>
<p className="mt-2 text-sm leading-snug text-muted-foreground">{f.body}</p>
</li>
);
})}
</ol>
<div className="mt-10 flex flex-wrap gap-2" {...reveal("up", 150)}>
{STACK.map((s) => (
<span
key={s.label}
className="rounded-full border border-border px-4 py-2 text-sm text-muted-foreground"
>
{s.label}
</span>
))}
</div>
</div>
</section>
{/* Call to action */}
<section className="section-y relative overflow-hidden bg-ink">
<div className="glow glow-drift absolute -left-40 top-0 h-[34rem] w-[34rem] opacity-40" />
<div className="container-x relative grid items-end gap-10 md:grid-cols-12">
<h2
{...reveal("mask")}
className="font-display text-4xl font-bold leading-[1.02] md:col-span-8 md:text-6xl"
>
Interested in {v.name}? <Accent>Let's talk.</Accent>
</h2>
<div className="md:col-span-4 md:justify-self-end" {...reveal("up", 150)}>
<p className="text-lg leading-relaxed text-muted-foreground">
Partners, investors and operators: we'd like to hear from you.
</p>
<a
href="/contact"
data-magnetic
className="press group mt-6 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>
</section>
{/* Next venture */}
{next !== v ? (
<section
aria-label="Next venture"
className="relative overflow-hidden border-t border-border bg-ink"
>
<a
href={`/ventures/${next.slug}`}
data-cursor="Next"
className="group relative block py-24 md:py-32"
>
<div className="container-x">
<div className="flex items-center justify-between font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
<span data-scramble>Next venture</span>
<span>{next.vertical}</span>
</div>
<div className="next-grow mt-8 flex flex-col items-start gap-6 md:flex-row md:items-center md:justify-between">
<span className="next-fill font-display text-[clamp(2.5rem,13vw,10rem)] font-extrabold leading-[0.9] tracking-tighter md:text-[min(10vw,10rem)]">
{next.name}
</span>
<span
className={`grid h-16 w-16 shrink-0 place-items-center rounded-full border border-primary/40 text-primary transition-all duration-700 group-hover:bg-primary group-hover:text-primary-foreground md:h-28 md:w-28 ${EASE_STAGE}`}
>
<ArrowUpRight
className={`h-7 w-7 transition-transform duration-700 group-hover:rotate-45 md:h-10 md:w-10 ${EASE_STAGE}`}
aria-hidden
/>
</span>
</div>
<div className="mt-10 h-px bg-white/10">
<div className="line-draw h-full bg-primary/70" />
</div>
<p className="mt-6 max-w-md text-lg text-muted-foreground">{next.pitch}</p>
</div>
</a>
</section>
) : null}
</>
);
}

View File

@@ -1,4 +1,5 @@
import {
memo,
useCallback,
useEffect,
useRef,
@@ -50,7 +51,13 @@ import {
Workflow,
type LucideIcon,
} from "lucide-react";
import { Cursor, SplitTitle, useScramble, useSmoothScroll } from "@/components/site/motion";
import {
Cursor,
SplitTitle,
usePauseOffscreen,
useScramble,
useSmoothScroll,
} from "@/components/site/motion";
import { LensStatement, MoltenMark, VelocityTicker } from "@/components/site/creative";
import { CONTACT_EMAIL, INTERESTS, sendContactEnquiry, type ContactInput } from "@/lib/contact";
// Brand assets cut from kapfoundry-logo-1.png (emblem, wordmark, full lockup).
@@ -66,9 +73,9 @@ import vFoodtech from "@/assets/vertical-foodtech.jpg";
import vCarbon from "@/assets/vertical-carbon.jpg";
import vHealthtech from "@/assets/vertical-healthtech.jpg";
// Card photography (Unsplash License): see src/assets/cards/CREDITS.md.
import imgFoundry from "@/assets/cards/studio-foundry.webp";
import imgTower from "@/assets/cards/holding-tower.webp";
import imgSpiral from "@/assets/cards/compound-spiral.webp";
import imgStudioTeam from "@/assets/cards/pillar-studio-team.webp";
import imgHoldingHq from "@/assets/cards/pillar-holding-hq.webp";
import imgCompoundGrowth from "@/assets/cards/pillar-compound-growth.webp";
import imgVision from "@/assets/cards/ai-vision.webp";
import imgAgentic from "@/assets/cards/ai-agentic.webp";
import imgEnterprise from "@/assets/cards/ai-enterprise.webp";
@@ -109,14 +116,7 @@ const NAV = [
];
export type SitePageName =
| "home"
| "studio"
| "framework"
| "portfolio"
| "ai"
| "investors"
| "about"
| "contact";
"home" | "studio" | "framework" | "portfolio" | "ai" | "investors" | "about" | "contact";
const VERTICALS = [
{
@@ -210,6 +210,7 @@ const VERTICALS = [
"Supply-chain digitization",
"Yield prediction and farm analytics",
],
example: "Cropsphere. The operating infrastructure for regenerative agriculture.",
},
{
n: "05",
@@ -462,22 +463,19 @@ 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 pending = new Set<HTMLElement>();
// Reveal a group of elements, cascading them in reading order.
const revealAll = (els: HTMLElement[]) => {
els
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.sort((a, b) => a.r.top - b.r.top || a.r.left - b.r.left)
.forEach(({ el }, i) => {
if (!pending.delete(el)) return;
io.unobserve(el);
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;
@@ -488,9 +486,29 @@ function useScrollReveal() {
),
);
});
},
};
const io = new IntersectionObserver(
(entries) =>
revealAll(entries.filter((e) => e.isIntersecting).map((e) => e.target as HTMLElement)),
{ rootMargin: "0px 0px -10% 0px" },
);
// A fast fling, an anchor jump or the End key can carry an element past the screen between
// two frames, so the observer never sees it on screen and it would stay hidden. Catch those:
// anything whose top is already above the reveal line is revealed straight away.
let frame = 0;
const sweep = () => {
frame = 0;
const line = window.innerHeight * 0.9;
const passed = [...pending].filter((el) => el.getBoundingClientRect().top < line);
if (passed.length) revealAll(passed);
if (!pending.size) window.removeEventListener("scroll", onScroll);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(sweep);
};
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;
@@ -500,10 +518,14 @@ function useScrollReveal() {
);
}
el.classList.add("reveal-pending");
pending.add(el);
io.observe(el);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
io.disconnect();
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(frame);
timers.forEach(clearTimeout);
};
}, []);
@@ -692,96 +714,98 @@ function Nav({ page }: { page: SitePageName }) {
const isCurrent = (href: string) => href === `/${page}`;
return (
<>
{/* Loyaly-style header: transparent over the hero with a dark nav capsule; once scrolled
it becomes a floating cream island with rounded lower corners. */}
<header
className={`vt-header 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} ${
className={`vt-header fixed inset-x-0 top-0 z-50 transition-transform 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"
<div className={`header-island relative overflow-hidden ${solid ? "is-solid" : ""}`}>
<div
aria-hidden
className={`scroll-progress absolute inset-x-0 bottom-0 h-0.5 bg-gradient-to-r from-primary/40 via-primary to-primary/40 transition-opacity duration-300 ${
solid ? "" : "opacity-0"
}`}
>
{NAV.map((n) => {
const current = isCurrent(n.href);
return (
<a
key={n.href}
href={n.href}
data-scramble
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"
data-magnetic
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>
/>
<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="nav-capsule hidden items-center gap-1 rounded-full p-1.5 lg:flex"
>
{NAV.map((n) => {
const current = isCurrent(n.href);
return (
<a
key={n.href}
href={n.href}
data-scramble
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 xl:px-4 ${
current
? "bg-white/10 text-primary"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{n.label}
</a>
);
})}
</nav>
<a
href="/investors"
data-magnetic
className={`press header-cta hidden items-center gap-2 rounded-full px-5 py-2.5 font-display text-xs font-semibold uppercase tracking-wider lg:inline-flex ${
solid ? "is-outline" : ""
}`}
>
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>
{/* 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 nav-capsule group flex h-11 items-center gap-3 rounded-full pl-4 pr-3 text-white transition-colors duration-300 lg:hidden ${
open ? "ring-1 ring-primary/60" : ""
}`}
>
<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>
</div>
</header>
@@ -968,19 +992,19 @@ const PILLARS = [
{
t: "Venture Studio",
icon: Building2,
img: imgFoundry,
img: imgStudioTeam,
d: "Companies are systematically created, validated and scaled on shared infrastructure, not simply funded after formation.",
},
{
t: "Holding Company",
icon: Layers,
img: imgTower,
img: imgHoldingHq,
d: "Every venture strengthens the ecosystem while remaining capable of becoming an independent, high-growth business.",
},
{
t: "Built to Compound",
icon: TrendingUp,
img: imgSpiral,
img: imgCompoundGrowth,
d: "Build faster, reduce risk, and maximize long-term value with one shared engine behind many companies.",
},
];
@@ -1703,8 +1727,14 @@ const PORTFOLIO_STEP_SVH = 70;
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] }) {
// Portfolio cards show only the top problems and solutions of each vertical, to stay scannable.
const CARD_POINTS = 3;
/**
* One vertical as a deck card: photo side and details side. Memoised: the deck re-renders on
* every card change, and rebuilding all seven cards each time stalled the scroll.
*/
const VerticalCard = memo(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">
@@ -1735,13 +1765,13 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
<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) => (
<ul className="space-y-2">
{v.problem.slice(0, CARD_POINTS).map((p) => (
<li
key={p}
className="flex gap-2 text-[13px] leading-snug text-muted-foreground md:text-sm"
className="flex gap-2 text-[13px] leading-snug text-muted-foreground min-[360px]:text-sm md:text-base"
>
<span className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-muted-foreground/60" />
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground/60" />
{p}
</li>
))}
@@ -1749,11 +1779,11 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
</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) => (
<ul className="space-y-2">
{v.solution.slice(0, CARD_POINTS).map((p) => (
<li
key={p}
className="flex gap-2 text-[13px] leading-snug text-foreground md:text-sm"
className="flex gap-2 text-[13px] leading-snug text-foreground min-[360px]:text-sm md:text-base"
>
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" aria-hidden />
{p}
@@ -1774,7 +1804,7 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
</div>
</div>
);
}
});
/**
* Card deck. Pinned: the verticals sit stacked like a deck; scrolling flies the top card away
@@ -2813,6 +2843,7 @@ function SitePage({ page, children }: { page: SitePageName; children: ReactNode
useScrollReveal();
useSmoothScroll();
useScramble();
usePauseOffscreen();
// 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.
@@ -2847,6 +2878,7 @@ function SitePage({ page, children }: { page: SitePageName; children: ReactNode
export {
NAV,
DuoPhoto,
usePinnedLayout,
VERTICALS,
FRAMEWORK,
STACK,

View File

@@ -0,0 +1,472 @@
import type { CSSProperties, ReactNode } from "react";
import {
BarChart3,
Boxes,
Brain,
Eye,
Gamepad2,
Gift,
Package,
PackageCheck,
Pill,
Plane,
ScanLine,
Snowflake,
Store,
Tag,
Truck,
Users,
Warehouse,
Zap,
type LucideIcon,
} from "lucide-react";
import { Accent, SectionHeader } from "@/components/site/sections";
// Short venture stories, summarised from each venture's own website (doormile.com,
// loyaly.ai). Plans are worded as plans; marketing outcome figures are left out.
type Item = { title: string; body: string; icon: LucideIcon };
/** Cards that stack on top of one another as you scroll (.layer-stack). */
function StackedCards({ items }: { items: Item[] }) {
return (
<ul className="layer-stack mt-12 flex flex-col gap-5 pb-[10vh]">
{items.map((it, i) => {
const Icon = it.icon;
return (
<li
key={it.title}
style={{ "--i": i } as CSSProperties}
className="theme-dark group relative flex min-h-[11rem] flex-col justify-between gap-5 overflow-hidden rounded-3xl border border-white/10 bg-background p-7 text-foreground shadow-[0_-24px_60px_-30px_oklch(0.05_0.01_60/0.8)] md:flex-row md:items-center md:p-10"
>
<div className="glow absolute -right-16 -top-16 h-56 w-56 opacity-40 transition-opacity duration-500 group-hover:opacity-90" />
<div className="relative flex items-center gap-5">
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-primary text-primary-foreground">
<Icon className="h-6 w-6" aria-hidden />
</span>
<div>
<div className="font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
0{i + 1}
</div>
<div className="mt-1 font-display text-3xl font-bold tracking-tight md:text-4xl">
{it.title}
</div>
</div>
</div>
<p className="relative text-lg leading-relaxed text-muted-foreground md:max-w-md md:text-right">
{it.body}
</p>
</li>
);
})}
</ul>
);
}
/** Steps in a row that light up one after another as they cross the screen (.chain-step). */
function StepRow({ items }: { items: Item[] }) {
return (
<ol className="mt-12 grid gap-3 sm:grid-cols-2 lg:grid-flow-col lg:auto-cols-fr lg:grid-cols-none">
{items.map((it, i) => {
const Icon = it.icon;
return (
<li
key={it.title}
style={{ "--i": i } as CSSProperties}
className="chain-step relative rounded-3xl border border-border bg-card p-6"
>
<div className="flex items-center justify-between">
<span className="grid h-11 w-11 place-items-center rounded-xl bg-primary text-primary-foreground">
<Icon className="h-5 w-5" aria-hidden />
</span>
<span className="font-display text-sm font-semibold tabular-nums text-primary">
0{i + 1}
</span>
</div>
<div className="mt-6 font-display text-xl font-bold">{it.title}</div>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{it.body}</p>
</li>
);
})}
</ol>
);
}
/** A vertical timeline: a stem grows down it and each node lights as it passes. */
function Timeline({ items }: { items: { label: string; title: string; body: string }[] }) {
return (
<ol className="relative mt-12 pl-10 md:pl-16">
<span
aria-hidden
className="absolute bottom-0 left-[11px] top-0 w-px bg-white/10 md:left-[19px]"
>
<span className="stem-grow block h-full w-full bg-gradient-to-b from-primary via-primary to-primary/30" />
</span>
{items.map((it) => (
<li
key={it.label}
className="relative grid gap-2 border-t border-white/10 py-8 md:grid-cols-12 md:gap-8 md:py-10"
>
<span
aria-hidden
className="stem-node absolute -left-10 top-9 h-6 w-6 rounded-full border-2 border-primary bg-primary md:-left-16 md:top-11 md:h-10 md:w-10"
/>
<div className="font-display text-sm font-semibold uppercase tracking-[0.16em] text-primary md:col-span-3">
{it.label}
</div>
<div className="md:col-span-9">
<div className="font-display text-2xl font-bold md:text-3xl">{it.title}</div>
<p className="mt-2 text-lg leading-relaxed text-muted-foreground">{it.body}</p>
</div>
</li>
))}
</ol>
);
}
/** Light (cream) or dark section wrapper. */
function Band({ light, children }: { light?: boolean; children: ReactNode }) {
return (
<section
className={
light
? "sheet-enter theme-light section-y bg-background text-foreground"
: "section-y relative overflow-hidden bg-ink"
}
>
<div className="container-x relative">{children}</div>
</section>
);
}
// ---------------------------------------------------------------------------------------------
// DoorMile Wings (from doormile.com/doormile-wings)
// ---------------------------------------------------------------------------------------------
export function DoorMileStory() {
return (
<>
<Band light>
<SectionHeader
eyebrow="What is DoorMile Wings"
icon={Plane}
title={
<>
Where quick commerce meets <Accent>quick logistics.</Accent>
</>
}
>
<p>
DoorMile Wings is a dedicated domestic air-cargo network: same-day logistics between
India's major regional capitals, with AI coordinating every handoff from runway to
doorstep.
</p>
</SectionHeader>
<StackedCards
items={[
{
title: "Same-day air",
body: "A maximum of three hours door to door between major regional capitals.",
icon: Plane,
},
{
title: "MileTruth™ AI",
body: "Forecasts demand and optimises every route and load in real time.",
icon: Brain,
},
{
title: "Dedicated freighters",
body: "Scheduled cargo capacity, not passenger-belly space competing with baggage.",
icon: Boxes,
},
{
title: "EV last mile",
body: "Electric vehicles complete every first- and final-mile leg.",
icon: Zap,
},
]}
/>
</Band>
<Band>
<SectionHeader
title={
<>
Pickup to arrival, <Accent>one connected flow.</Accent>
</>
}
/>
<StepRow
items={[
{
title: "Local pickup",
body: "On-demand first mile by electric vehicle.",
icon: Truck,
},
{
title: "Airport clearance",
body: "Automated screening and rapid cross-docking at the hub.",
icon: ScanLine,
},
{
title: "Air transit",
body: "Regional cargo flights bypass surface congestion.",
icon: Plane,
},
{
title: "Destination hub",
body: "Immediate de-consolidation and screening on arrival.",
icon: Warehouse,
},
{ title: "EV last mile", body: "Zero-emission delivery to the door.", icon: Zap },
]}
/>
</Band>
<Band light>
<SectionHeader
title={
<>
Built for shipments that <Accent>can't wait.</Accent>
</>
}
/>
<ul className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[
{ t: "Quick commerce", d: "Same-day air capacity from metros to dark stores.", i: Zap },
{ t: "Express B2B cargo", d: "Time-critical parts, airport to airport.", i: Package },
{ t: "Healthcare & pharma", d: "Cold-chain packaging with chain of custody.", i: Pill },
{ t: "Cold chain", d: "Perishables tracked at airline speed.", i: Snowflake },
{
t: "High-value cargo",
d: "Secure handling with electronic custody.",
i: PackageCheck,
},
{
t: "E-commerce",
d: "Same- and next-day reach into tier-1 and tier-2 cities.",
i: Store,
},
].map(({ t, d, i: Icon }) => (
<li
key={t}
className="tilt-in flex items-start gap-4 rounded-3xl border border-border bg-card p-6"
>
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-primary/15 text-primary">
<Icon className="h-5 w-5" aria-hidden />
</span>
<span>
<span className="block font-display text-lg font-bold">{t}</span>
<span className="mt-1 block text-sm leading-relaxed text-muted-foreground">
{d}
</span>
</span>
</li>
))}
</ul>
</Band>
<Band>
<SectionHeader
title={
<>
A ten-year path to <Accent>national scale.</Accent>
</>
}
>
<p>The roadmap DoorMile has set out for Wings.</p>
</SectionHeader>
<Timeline
items={[
{
label: "Year 1",
title: "Launch",
body: "Clearance hubs and cargo services across 5–6 key commercial cities.",
},
{
label: "Year 2",
title: "Network growth",
body: "Serving 15 regional and secondary cargo airports.",
},
{
label: "Year 3",
title: "Cross-border",
body: "International regional routes across South Asian freight corridors.",
},
{
label: "Year 5",
title: "Scale",
body: "A fleet of 25 leased cargo aircraft serving 50+ regional airports.",
},
{
label: "Year 10",
title: "Global",
body: "The asset-light, AI-powered model replicated across Asia, Africa and the Middle East.",
},
]}
/>
</Band>
</>
);
}
// ---------------------------------------------------------------------------------------------
// Loyaly.ai (from loyaly.ai)
// ---------------------------------------------------------------------------------------------
export function LoyalyStory() {
return (
<>
<Band light>
<div className="grid gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<SectionHeader
eyebrow="The problem"
icon={Eye}
title={
<>
Retail is busy. <Accent>But blind.</Accent>
</>
}
>
<p>
Stores struggle to win new customers and keep existing ones without giving away
margin on generic discounts.
</p>
</SectionHeader>
<div className="mt-10 font-display text-7xl font-extrabold tracking-tighter text-primary md:text-8xl">
68%+
</div>
<p className="mt-2 font-display text-lg font-semibold">
of shoppers walk in once and never return.
</p>
</div>
<ol className="lg:col-span-7">
{[
"No compelling reason to visit the store",
"Discounts erode profit margins",
"Digital ads rarely turn into footfall",
"No real-time link between online and offline",
"Customers forget brands between visits",
].map((p, k) => (
<li key={p} className="relative flex items-baseline gap-6 py-6">
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-border" />
<span
aria-hidden
className="line-draw absolute inset-x-0 top-0 h-px bg-primary/60"
/>
<span className="w-10 shrink-0 font-display text-sm font-semibold tabular-nums text-primary">
0{k + 1}
</span>
<span className="font-display text-2xl font-bold tracking-tight">{p}</span>
</li>
))}
</ol>
</div>
</Band>
<Band>
<SectionHeader
title={
<>
Every visit, <Accent>a loyal customer.</Accent>
</>
}
>
<p>
Loyaly bridges digital engagement and offline shopping in one loop that resets with
every visit.
</p>
</SectionHeader>
<StepRow
items={[
{ title: "Understand", body: "See who walks in, when, and why they buy.", icon: Eye },
{
title: "Engage",
body: "Daily streaks, challenges and mini games build a habit.",
icon: Gamepad2,
},
{
title: "Convert",
body: "Shoppers play to unlock a deal, then buy on the spot.",
icon: Tag,
},
{ title: "Retain", body: "Every redemption sets up the next visit.", icon: Users },
]}
/>
</Band>
<Band light>
<SectionHeader
eyebrow="The platform"
icon={Brain}
title={
<>
Four agents. <Accent>One AI.</Accent>
</>
}
/>
<StackedCards
items={[
{
title: "Behavision",
body: "Footfall analytics: how customers move, dwell and engage in store.",
icon: Eye,
},
{
title: "SpendSense",
body: "Spending intelligence: high-value segments and buying behaviour.",
icon: BarChart3,
},
{
title: "Dyscount",
body: "Smart discounts: the right offer, to the right shopper, without killing margin.",
icon: Tag,
},
{
title: "Identiq",
body: "Consumer loyalty: earn LYTs online, redeem them in real stores.",
icon: Gift,
},
]}
/>
</Band>
<Band>
<SectionHeader
title={
<>
For shoppers: <Accent>earn as you live.</Accent>
</>
}
>
<p>Through the LytsUp app, shoppers earn LYTs online and spend them in store.</p>
</SectionHeader>
<Timeline
items={[
{
label: "Step 1",
title: "Earn online",
body: "Daily logins, step challenges and games like Maze and Spin Wheel earn LYTs and surprise coupons.",
},
{
label: "Step 2",
title: "Unlock deals",
body: "Merchants run LYT campaigns; shoppers play once to reveal their reward.",
},
{
label: "Step 3",
title: "Discover and visit",
body: "Find participating stores nearby, then walk in and shop in person.",
},
{
label: "Step 4",
title: "Redeem and repeat",
body: "Use LYTs and coupons at checkout, then come back tomorrow to earn more.",
},
]}
/>
</Band>
</>
);
}

View File

@@ -18,6 +18,7 @@ import { Route as ContactRouteImport } from './routes/contact'
import { Route as AiRouteImport } from './routes/ai'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as VenturesSlugRouteImport } from './routes/ventures/$slug'
const StudioRoute = StudioRouteImport.update({
id: '/studio',
@@ -64,6 +65,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const VenturesSlugRoute = VenturesSlugRouteImport.update({
id: '/ventures/$slug',
path: '/ventures/$slug',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -75,6 +81,7 @@ export interface FileRoutesByFullPath {
'/portfolio': typeof PortfolioRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/studio': typeof StudioRoute
'/ventures/$slug': typeof VenturesSlugRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -86,6 +93,7 @@ export interface FileRoutesByTo {
'/portfolio': typeof PortfolioRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/studio': typeof StudioRoute
'/ventures/$slug': typeof VenturesSlugRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -98,6 +106,7 @@ export interface FileRoutesById {
'/portfolio': typeof PortfolioRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/studio': typeof StudioRoute
'/ventures/$slug': typeof VenturesSlugRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -111,6 +120,7 @@ export interface FileRouteTypes {
| '/portfolio'
| '/sitemap.xml'
| '/studio'
| '/ventures/$slug'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -122,6 +132,7 @@ export interface FileRouteTypes {
| '/portfolio'
| '/sitemap.xml'
| '/studio'
| '/ventures/$slug'
id:
| '__root__'
| '/'
@@ -133,6 +144,7 @@ export interface FileRouteTypes {
| '/portfolio'
| '/sitemap.xml'
| '/studio'
| '/ventures/$slug'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -145,6 +157,7 @@ export interface RootRouteChildren {
PortfolioRoute: typeof PortfolioRoute
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
StudioRoute: typeof StudioRoute
VenturesSlugRoute: typeof VenturesSlugRoute
}
declare module '@tanstack/react-router' {
@@ -212,6 +225,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/ventures/$slug': {
id: '/ventures/$slug'
path: '/ventures/$slug'
fullPath: '/ventures/$slug'
preLoaderRoute: typeof VenturesSlugRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -225,6 +245,7 @@ const rootRouteChildren: RootRouteChildren = {
PortfolioRoute: PortfolioRoute,
SitemapDotxmlRoute: SitemapDotxmlRoute,
StudioRoute: StudioRoute,
VenturesSlugRoute: VenturesSlugRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import type {} from "@tanstack/react-start";
import { VENTURES } from "@/components/site/pages";
const BASE_URL = "";
@@ -12,6 +13,11 @@ export const Route = createFileRoute("/sitemap.xml")({
...["/studio", "/framework", "/portfolio", "/ai", "/investors", "/about", "/contact"].map(
(path) => ({ path, changefreq: "monthly", priority: "0.8" }),
),
...VENTURES.map((v) => ({
path: `/ventures/${v.slug}`,
changefreq: "monthly",
priority: "0.7",
})),
];
const urls = entries.map(
(e) =>

View File

@@ -0,0 +1,30 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { SitePage } from "@/components/site/sections";
import { VentureDetail, findVenture } from "@/components/site/pages";
export const Route = createFileRoute("/ventures/$slug")({
loader: ({ params }) => {
const v = findVenture(params.slug);
if (!v) throw notFound();
return { name: v.name, pitch: v.pitch };
},
head: ({ loaderData }) => ({
meta: loaderData
? [
{ title: `${loaderData.name} | KapFoundry` },
{ name: "description", content: loaderData.pitch },
{ property: "og:title", content: `${loaderData.name}, a KapFoundry venture` },
]
: [],
}),
component: VenturePage,
});
function VenturePage() {
const { slug } = Route.useParams();
return (
<SitePage page="portfolio">
<VentureDetail slug={slug} />
</SitePage>
);
}

View File

@@ -855,6 +855,194 @@ html.intro .intro-overlay {
rotate: 180deg;
}
/* ------------------------------------------------------------------------------------------
Venture pages: scroll-driven motion that acts out each section. Browsers without scroll
timelines, and reduced motion, simply show the finished state.
------------------------------------------------------------------------------------------ */
@keyframes venture-photo-card {
to {
clip-path: inset(10% 6% 16% 6% round 3rem);
scale: 0.9;
}
}
@keyframes venture-name-spread {
to {
letter-spacing: 0.06em;
opacity: 0;
filter: blur(8px);
}
}
@keyframes chain-lit {
from {
opacity: 0.2;
transform: translate3d(0, 14px, 0) scale(0.92);
filter: grayscale(1);
}
}
@keyframes grow-y {
from {
transform: scaleY(0);
}
}
@keyframes node-lit {
from {
background-color: var(--background);
border-color: color-mix(in oklab, var(--foreground) 20%, transparent);
scale: 0.7;
}
to {
background-color: var(--primary);
border-color: var(--primary);
scale: 1;
}
}
@keyframes rise-staggered {
from {
transform: translate3d(0, calc(70px + var(--i, 0) * 55px), 0);
opacity: 0.15;
}
}
@keyframes tilt-in {
from {
transform: perspective(900px) rotateX(32deg) translate3d(0, 50px, 0);
opacity: 0;
}
}
/* Layer cards stack on top of one another as the list scrolls (pure position: sticky). */
.layer-stack > li {
position: sticky;
top: calc(5.5rem + var(--i, 0) * 1.25rem);
}
@media (min-width: 1024px) {
.layer-stack > li {
top: calc(7rem + var(--i, 0) * 1.75rem);
}
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
/* Hero: the photo pulls back into a card and the name spreads apart as you leave. */
.venture-photo {
animation: venture-photo-card linear both;
animation-timeline: --hero;
animation-range: exit 0% exit 75%;
}
.venture-name {
animation: venture-name-spread linear both;
animation-timeline: --hero;
animation-range: exit 0% exit 55%;
}
/* Farm → Food → Consumer → Regeneration: each step lights up in turn. */
.chain-step {
animation: chain-lit linear both;
animation-timeline: view();
animation-range: entry calc(30% + var(--i, 0) * 12%) entry calc(60% + var(--i, 0) * 12%);
}
/* How it works: a stem grows down the steps, tracking the middle of the screen. */
.stem-grow {
transform-origin: top;
animation: grow-y linear both;
animation-timeline: view();
animation-range: cover 50vh cover calc(100% - 50vh);
}
.stem-node {
animation: node-lit linear both;
animation-timeline: view();
animation-range: cover 46% cover 50%;
}
/* Revenue columns rise at different speeds, like bars growing. */
.rise-staggered {
animation: rise-staggered linear both;
animation-timeline: view();
animation-range: entry 0% cover 45%;
}
/* Cards tilt up into place from a 3D angle. */
.tilt-in {
transform-origin: 50% 100%;
animation: tilt-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
}
/* Looping decoration pauses while its section is off screen (data-offscreen, set from JS). */
[data-offscreen]
:is(
.column-up,
.column-down,
.spin-slow,
.spin-slower,
.upright,
.signal,
.node-pulse,
.dot-wave,
.orbit-comet,
.scroll-cue,
.nudge-x,
.nudge-y,
.animate-ping,
.animate-pulse
) {
animation-play-state: paused;
}
/* ------------------------------------------------------------------------------------------
Header (Loyaly-style). Over the hero it is transparent; once scrolled it becomes a floating
cream island with rounded lower corners. The nav sits in a dark capsule either way.
------------------------------------------------------------------------------------------ */
.header-island {
margin-inline: 0;
border-radius: 0;
background-color: transparent;
transition:
margin 0.5s var(--ease-out-expo),
border-radius 0.5s var(--ease-out-expo),
background-color 0.4s ease,
box-shadow 0.4s ease;
}
.header-island.is-solid {
margin-inline: 0.5rem;
border-radius: 0 0 1.5rem 1.5rem;
background-color: oklch(0.975 0.012 85 / 0.97);
backdrop-filter: blur(12px);
box-shadow: 0 14px 40px -20px oklch(0.05 0.01 60 / 0.55);
}
@media (min-width: 768px) {
.header-island.is-solid {
margin-inline: 1rem;
}
}
.nav-capsule {
background-color: oklch(0.13 0.008 60);
box-shadow:
inset 0 0 0 1px oklch(1 0 0 / 0.08),
0 8px 24px -12px oklch(0.05 0.01 60 / 0.8);
}
/* Call to action: a cream pill over the hero, a gold outline on the cream island. */
.header-cta {
background-color: oklch(0.975 0.012 85);
color: oklch(0.17 0.01 60);
box-shadow: inset 0 0 0 1px transparent;
transition:
background-color 0.3s ease,
color 0.3s ease,
box-shadow 0.3s ease;
}
.header-cta:hover {
background-color: var(--primary);
color: var(--primary-foreground);
}
.header-cta.is-outline {
background-color: transparent;
box-shadow: inset 0 0 0 1.5px oklch(0.62 0.12 68);
}
.header-cta.is-outline:hover {
background-color: oklch(0.62 0.12 68);
color: oklch(0.99 0.005 85);
}
/* Magnetic buttons: JS sets `translate`; this eases them back when the pointer leaves. */
[data-magnetic] {
transition: translate 0.6s var(--ease-out-expo);