diff --git a/Dockerfile b/Dockerfile index eae7533..07c4d23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/src/assets/cards/CREDITS.md b/src/assets/cards/CREDITS.md index 64bf069..9dde0a4 100644 --- a/src/assets/cards/CREDITS.md +++ b/src/assets/cards/CREDITS.md @@ -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 | diff --git a/src/assets/cards/compound-spiral.webp b/src/assets/cards/compound-spiral.webp deleted file mode 100644 index d8b1d80..0000000 Binary files a/src/assets/cards/compound-spiral.webp and /dev/null differ diff --git a/src/assets/cards/holding-tower.webp b/src/assets/cards/holding-tower.webp deleted file mode 100644 index 0f04194..0000000 Binary files a/src/assets/cards/holding-tower.webp and /dev/null differ diff --git a/src/assets/cards/pillar-compound-growth.webp b/src/assets/cards/pillar-compound-growth.webp new file mode 100644 index 0000000..4044ddd Binary files /dev/null and b/src/assets/cards/pillar-compound-growth.webp differ diff --git a/src/assets/cards/pillar-holding-hq.webp b/src/assets/cards/pillar-holding-hq.webp new file mode 100644 index 0000000..ce9973c Binary files /dev/null and b/src/assets/cards/pillar-holding-hq.webp differ diff --git a/src/assets/cards/pillar-studio-team.webp b/src/assets/cards/pillar-studio-team.webp new file mode 100644 index 0000000..b15c903 Binary files /dev/null and b/src/assets/cards/pillar-studio-team.webp differ diff --git a/src/assets/cards/studio-foundry.webp b/src/assets/cards/studio-foundry.webp deleted file mode 100644 index 6aea158..0000000 Binary files a/src/assets/cards/studio-foundry.webp and /dev/null differ diff --git a/src/assets/sections/CREDITS.md b/src/assets/sections/CREDITS.md index 6d5f095..bcd4f88 100644 --- a/src/assets/sections/CREDITS.md +++ b/src/assets/sections/CREDITS.md @@ -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 | diff --git a/src/assets/sections/model-build.webp b/src/assets/sections/model-build.webp index ba0a400..f2e5a99 100644 Binary files a/src/assets/sections/model-build.webp and b/src/assets/sections/model-build.webp differ diff --git a/src/assets/sections/stack-cloud.webp b/src/assets/sections/stack-cloud.webp index 4f8d6c3..2dcc95f 100644 Binary files a/src/assets/sections/stack-cloud.webp and b/src/assets/sections/stack-cloud.webp differ diff --git a/src/assets/sections/stack-data.webp b/src/assets/sections/stack-data.webp index f651675..2e1954f 100644 Binary files a/src/assets/sections/stack-data.webp and b/src/assets/sections/stack-data.webp differ diff --git a/src/assets/sections/stack-engineering.webp b/src/assets/sections/stack-engineering.webp index 55886b4..6c64ef5 100644 Binary files a/src/assets/sections/stack-engineering.webp and b/src/assets/sections/stack-engineering.webp differ diff --git a/src/components/site/cropsphere.tsx b/src/components/site/cropsphere.tsx new file mode 100644 index 0000000..d1b6c7d --- /dev/null +++ b/src/components/site/cropsphere.tsx @@ -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 ( +
+
+ + Not an app. Not a marketplace.{" "} + An operating ecosystem for regenerative agriculture. + + } + compact + > +

+ Cropsphere is an integrated regenerative-agriculture operating ecosystem. It connects + four things that operate separately today: +

+
+ + {/* Farm → Food → Consumer → Regeneration */} + {/* Each step lights up in turn as the row crosses the screen (.chain-step). */} +
    + {CHAIN.map((c, i) => ( +
  1. + + {c} + + {i < CHAIN.length - 1 ? ( + + ) : null} +
  2. + ))} +
+ +
+ Use technology to make farming more intelligent, make food more traceable, and make + regenerative actions economically valuable. +
+ +
+
+

+ Four layers, one system. +

+ + Regenerative Agri Operating Ecosystem (RAOE) + +
+ {/* The layers stack on top of each other as you scroll (.layer-stack). */} +
    + {LAYERS.map((l, i) => { + const Icon = l.icon; + return ( +
  • +
    +
    + + + +
    +
    + 0{i + 1} · {l.layer} +
    +
    + {l.product} +
    +
    +
    +

    + {l.role} +

    +
  • + ); + })} +
+
+
+
+ ); +} + +/** A small left-to-right (wrapping) chain of steps. */ +function Chain({ items, tone = "dark" }: { items: string[]; tone?: "dark" | "gold" }) { + return ( +
    + {items.map((t, i) => ( +
  1. + + {t} + + {i < items.length - 1 ? ( + + ) : null} +
  2. + ))} +
+ ); +} + +const STEPS: { n: string; title: string; product: string; body: ReactNode }[] = [ + { + n: "01", + title: "Connect farms", + product: "Network", + body: ( + <> +

+ 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. +

+ + + ), + }, + { + n: "02", + title: "HarvestHQ helps run the farm", + product: "HarvestHQ", + body: ( + <> +

The intelligence layer, across the whole season:

+ +
+
+ For example +
+

+ A farmer has two acres. Instead of only asking “What should I grow?”, HarvestHQ can + analyse the farm's information and help work out{" "} + + what to grow, how to grow it, how much input to use, the expected yield and the likely + market demand. + +

+
+

+ The long-term vision is to move from advice alone to an operating system that connects + recommendations with execution and automation. +

+ + ), + }, + { + n: "03", + title: "Cropsphere Produce takes it to market", + product: "Cropsphere Produce", + body: ( + <> +

Once production is connected, the next question is where the produce goes.

+
+
+
+ Today +
+ +
+
+
+ With Cropsphere +
+ +
+
+

+ 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. +

+ + ), + }, + { + n: "04", + title: "O-Points rewards regeneration", + product: "O-Points", + body: ( + <> +

+ Whenever people take part in regenerative activity, they can earn O-Points, turning + otherwise invisible environmental actions into a measurable, digital layer of + participation. +

+ +

+ Behind it: reward logic, verification, a ledger and wallet, and over time a mechanism for + aggregating carbon value. +

+ + ), + }, + { + n: "05", + title: "Living brings it to the consumer", + product: "Living", + body: ( + <> +

+ 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. +

+ +

+ Not merely a vending machine: revenue, brand, customer acquisition and an entry point to + the ecosystem, in one. +

+ + ), + }, +]; + +/** Section 2: the five steps, each pinned beside its number as you scroll. */ +function HowItWorks() { + return ( +
+
+ + How it actually works. + + } + > +

Four products, one flywheel. Here is the path from a farm to a consumer and back.

+
+ + {/* A stem grows down the steps as you read; each step's node lights as it passes. */} +
    + + + + {STEPS.map((s) => ( +
  1. + + + +
    +
    + + {s.n} + +

    + {s.title} +

    + + {s.product} + +
    +
    +
    + {s.body} +
    +
  2. + ))} +
+
+
+ ); +} + +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(null); + const sectionRef = useRef(null); + const ringRef = useRef(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 ( +
+
+
+
+ The flywheel +

+ Every turn makes the next one stronger. +

+

+ 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. +

+ {pinned ? ( +
+ + {String(active + 1).padStart(2, "0")} + / {String(n).padStart(2, "0")} + + + + + + Scroll to turn + +
+ ) : null} +
+ + {/* Wheel (large screens) */} +
setHeld(true)} + onPointerLeave={() => setHeld(false)} + > + + {/* Outer dashed ring: spins as the flywheel is scrolled. */} + + + + + + + {FLYWHEEL.map((f, i) => { + const Icon = f.icon; + const on = i === active; + return ( + + ); + })} +
+
+ +
{current.label}
+

+ {current.desc} +

+
+
+
+ + {/* Loop list (small screens) */} +
    + {FLYWHEEL.map((f, i) => { + const Icon = f.icon; + return ( +
  1. +
    + + + + + {f.label} + + {f.desc} + + +
    + {i < FLYWHEEL.length - 1 ? ( + + ) : null} +
  2. + ); + })} +
  3. + ↺ More farms join, and the cycle repeats +
  4. +
+
+
+
+ ); +} + +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 ( +
+
+
+ + Revenue from every layer. + + } + > +

+ A multi-layer revenue architecture rather than a business that depends on selling + software alone. Potential revenue streams: +

+
+ {/* Columns rise at different speeds, like bars growing (.rise-staggered). */} +
+ {REVENUE.map((r, i) => ( +
+
{r.product}
+
    + {r.items.map((it) => ( +
  • + + {it} +
  • + ))} +
+
+ ))} +
+
+
+ ); +} + +/** Section 5: the whole story in one paragraph, brightening word by word as it scrolls. */ +function InOneParagraph() { + return ( +
+
+ In one paragraph +

+ + +

+

+ 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. +

+
+
+ ); +} + +/** Cropsphere's full story, shown on its venture page in place of the generic vertical lists. */ +export function CropsphereStory() { + return ( + <> + + + + + + + ); +} diff --git a/src/components/site/motion.tsx b/src/components/site/motion.tsx index e1fe1f9..ea78679 100644 --- a/src/components/site/motion.tsx +++ b/src/components/site/motion.tsx @@ -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']"; diff --git a/src/components/site/pages.tsx b/src/components/site/pages.tsx index 7075543..6820c7c 100644 --- a/src/components/site/pages.tsx +++ b/src/components/site/pages.tsx @@ -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/. */ +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() {

A first look at companies being engineered on the KapFoundry engine.

-
+
{VENTURES.map((v, i) => { const Icon = v.icon; return (
{v.vertical} -

+

{v.name}

{v.pitch}

+ {/* Visible label for the card-wide link below. */} + + Explore venture + + + +
+ {/* One link over the whole card: click anywhere, one tab stop. */} + ); })} @@ -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 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 */} +
+
+ + {Story ? ( + + ) : ( + <> + {/* The problem: the market this venture is built for */} +
+
+
+
+ The problem +

+ {v.vertical}: {v.tag.toLowerCase()}. +

+

+ {v.name} is being built for a market held back by the same problems, again and + again. +

+
+
+
    + {v.problem.map((p, k) => ( +
  1. + + + + 0{k + 1} + + + {p} + +
  2. + ))} +
+
+
+ + {/* What we build */} +
+
+
+ + What we build, AI-first. + + } + > +

+ Where KapFoundry focuses in {v.vertical.toLowerCase()}, and the ground {v.name} is + built on. +

+
+
    + {v.solution.map((s) => ( +
  • + + + + + {s} + +
  • + ))} +
+
+
+ + )} + + {/* Built on the engine: the same framework and stack as every venture */} +
+
+ + Built on the KapFoundry engine. + + } + > +

+ Like every venture in the studio, {v.name} moves through the same six stages and + launches with a mature company's stack. +

+
+ +
    + {FRAMEWORK.map((f) => { + const StageIcon = f.icon; + return ( +
  1. +
    + + {f.n} + + +
    +
    {f.title}
    +

    {f.body}

    +
  2. + ); + })} +
+ +
+ {STACK.map((s) => ( + + {s.label} + + ))} +
+
+
+ + {/* Call to action */} +
+
+
+

+ Interested in {v.name}? Let's talk. +

+
+

+ Partners, investors and operators: we'd like to hear from you. +

+ + Speak with our team + + +
+
+
+ + {/* Next venture */} + {next !== v ? ( +
+ +
+ ) : null} + + ); +} diff --git a/src/components/site/sections.tsx b/src/components/site/sections.tsx index c9a7a00..c4a6d62 100644 --- a/src/components/site/sections.tsx +++ b/src/components/site/sections.tsx @@ -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(); + + // 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("[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. */} @@ -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 (
@@ -1735,13 +1765,13 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
The problem
-
    - {v.problem.map((p) => ( +
      + {v.problem.slice(0, CARD_POINTS).map((p) => (
    • - + {p}
    • ))} @@ -1749,11 +1779,11 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
Our solution
-
    - {v.solution.map((p) => ( +
      + {v.solution.slice(0, CARD_POINTS).map((p) => (
    • {p} @@ -1774,7 +1804,7 @@ function VerticalCard({ v }: { v: (typeof VERTICALS)[number] }) {
); -} +}); /** * 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, diff --git a/src/components/site/venture-stories.tsx b/src/components/site/venture-stories.tsx new file mode 100644 index 0000000..778584d --- /dev/null +++ b/src/components/site/venture-stories.tsx @@ -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 ( +
    + {items.map((it, i) => { + const Icon = it.icon; + return ( +
  • +
    +
    + + + +
    +
    + 0{i + 1} +
    +
    + {it.title} +
    +
    +
    +

    + {it.body} +

    +
  • + ); + })} +
+ ); +} + +/** Steps in a row that light up one after another as they cross the screen (.chain-step). */ +function StepRow({ items }: { items: Item[] }) { + return ( +
    + {items.map((it, i) => { + const Icon = it.icon; + return ( +
  1. +
    + + + + + 0{i + 1} + +
    +
    {it.title}
    +

    {it.body}

    +
  2. + ); + })} +
+ ); +} + +/** 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 ( +
    + + + + {items.map((it) => ( +
  1. + +
    + {it.label} +
    +
    +
    {it.title}
    +

    {it.body}

    +
    +
  2. + ))} +
+ ); +} + +/** Light (cream) or dark section wrapper. */ +function Band({ light, children }: { light?: boolean; children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +// --------------------------------------------------------------------------------------------- +// DoorMile Wings (from doormile.com/doormile-wings) +// --------------------------------------------------------------------------------------------- + +export function DoorMileStory() { + return ( + <> + + + Where quick commerce meets quick logistics. + + } + > +

+ 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. +

+
+ +
+ + + + Pickup to arrival, one connected flow. + + } + /> + + + + + + Built for shipments that can't wait. + + } + /> +
    + {[ + { 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 }) => ( +
  • + + + + + {t} + + {d} + + +
  • + ))} +
+
+ + + + A ten-year path to national scale. + + } + > +

The roadmap DoorMile has set out for Wings.

+
+ +
+ + ); +} + +// --------------------------------------------------------------------------------------------- +// Loyaly.ai (from loyaly.ai) +// --------------------------------------------------------------------------------------------- + +export function LoyalyStory() { + return ( + <> + +
+
+ + Retail is busy. But blind. + + } + > +

+ Stores struggle to win new customers and keep existing ones without giving away + margin on generic discounts. +

+
+
+ 68%+ +
+

+ of shoppers walk in once and never return. +

+
+
    + {[ + "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) => ( +
  1. + + + + 0{k + 1} + + {p} +
  2. + ))} +
+
+
+ + + + Every visit, a loyal customer. + + } + > +

+ Loyaly bridges digital engagement and offline shopping in one loop that resets with + every visit. +

+
+ +
+ + + + Four agents. One AI. + + } + /> + + + + + + For shoppers: earn as you live. + + } + > +

Through the LytsUp app, shoppers earn LYTs online and spend them in store.

+
+ +
+ + ); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3451023..e6ad14a 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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) diff --git a/src/routes/sitemap[.]xml.ts b/src/routes/sitemap[.]xml.ts index a6817db..e8ed327 100644 --- a/src/routes/sitemap[.]xml.ts +++ b/src/routes/sitemap[.]xml.ts @@ -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) => diff --git a/src/routes/ventures/$slug.tsx b/src/routes/ventures/$slug.tsx new file mode 100644 index 0000000..8f250ca --- /dev/null +++ b/src/routes/ventures/$slug.tsx @@ -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 ( + + + + ); +} diff --git a/src/styles.css b/src/styles.css index 515aad6..c828bd8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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);