diff --git a/package-lock.json b/package-lock.json index fd11e35..2d970c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", + "lenis": "^1.3.26", "lucide-react": "^0.575.0", "react": "^19.2.0", "react-day-picker": "^9.14.0", @@ -2500,7 +2501,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2517,7 +2517,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2534,7 +2533,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2551,7 +2549,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2568,7 +2565,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2585,7 +2581,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2602,7 +2597,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2619,7 +2613,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2636,7 +2629,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2653,7 +2645,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2670,7 +2661,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2687,7 +2677,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2704,7 +2693,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2723,7 +2711,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2740,7 +2727,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5480,6 +5466,37 @@ "shell-quote": "^1.8.4" } }, + "node_modules/lenis": { + "version": "1.3.26", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.26.tgz", + "integrity": "sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==", + "license": "MIT", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5927,18 +5944,6 @@ } } }, - "node_modules/nitro/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro/node_modules/unstorage": { "version": "2.0.0-alpha.7", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-2.0.0-alpha.7.tgz", diff --git a/package.json b/package.json index 9efd318..301aae3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", + "lenis": "^1.3.26", "lucide-react": "^0.575.0", "react": "^19.2.0", "react-day-picker": "^9.14.0", diff --git a/src/assets/cards/CREDITS.md b/src/assets/cards/CREDITS.md new file mode 100644 index 0000000..64bf069 --- /dev/null +++ b/src/assets/cards/CREDITS.md @@ -0,0 +1,19 @@ +# Card photography + +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 | diff --git a/src/assets/cards/ai-agentic.webp b/src/assets/cards/ai-agentic.webp new file mode 100644 index 0000000..908d14e Binary files /dev/null and b/src/assets/cards/ai-agentic.webp differ diff --git a/src/assets/cards/ai-automation.webp b/src/assets/cards/ai-automation.webp new file mode 100644 index 0000000..aa44802 Binary files /dev/null and b/src/assets/cards/ai-automation.webp differ diff --git a/src/assets/cards/ai-decision.webp b/src/assets/cards/ai-decision.webp new file mode 100644 index 0000000..9dbd499 Binary files /dev/null and b/src/assets/cards/ai-decision.webp differ diff --git a/src/assets/cards/ai-enterprise.webp b/src/assets/cards/ai-enterprise.webp new file mode 100644 index 0000000..5a30d68 Binary files /dev/null and b/src/assets/cards/ai-enterprise.webp differ diff --git a/src/assets/cards/ai-llm.webp b/src/assets/cards/ai-llm.webp new file mode 100644 index 0000000..1802305 Binary files /dev/null and b/src/assets/cards/ai-llm.webp differ diff --git a/src/assets/cards/ai-multiagent.webp b/src/assets/cards/ai-multiagent.webp new file mode 100644 index 0000000..02fed72 Binary files /dev/null and b/src/assets/cards/ai-multiagent.webp differ diff --git a/src/assets/cards/ai-predictive.webp b/src/assets/cards/ai-predictive.webp new file mode 100644 index 0000000..725d1bb Binary files /dev/null and b/src/assets/cards/ai-predictive.webp differ diff --git a/src/assets/cards/ai-vision.webp b/src/assets/cards/ai-vision.webp new file mode 100644 index 0000000..343f952 Binary files /dev/null and b/src/assets/cards/ai-vision.webp differ diff --git a/src/assets/cards/compound-spiral.webp b/src/assets/cards/compound-spiral.webp new file mode 100644 index 0000000..d8b1d80 Binary files /dev/null and b/src/assets/cards/compound-spiral.webp differ diff --git a/src/assets/cards/holding-tower.webp b/src/assets/cards/holding-tower.webp new file mode 100644 index 0000000..0f04194 Binary files /dev/null and b/src/assets/cards/holding-tower.webp differ diff --git a/src/assets/cards/studio-foundry.webp b/src/assets/cards/studio-foundry.webp new file mode 100644 index 0000000..6aea158 Binary files /dev/null and b/src/assets/cards/studio-foundry.webp differ diff --git a/src/assets/sections/CREDITS.md b/src/assets/sections/CREDITS.md new file mode 100644 index 0000000..6d5f095 --- /dev/null +++ b/src/assets/sections/CREDITS.md @@ -0,0 +1,32 @@ +# Section photography + +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 WebP. + +| File | Photographer | Unsplash photo | +| --------------------------- | ------------------------- | --------------------------------------- | +| `stage-discover.webp` | Sergey Sokolov | https://unsplash.com/photos/-g657KN4Ah4 | +| `stage-validate.webp` | Nathália Rosa | https://unsplash.com/photos/P1SdQAhcJz8 | +| `stage-build.webp` | Abu Saeid | https://unsplash.com/photos/fdGTi4IcaJc | +| `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-design.webp` | Amélie Mourichon | https://unsplash.com/photos/sv8oOQaUb-o | +| `stack-cloud.webp` | Rebecca Campbell | https://unsplash.com/photos/5nN9NX6dJxc | +| `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-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-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/investors-skyline.webp b/src/assets/sections/investors-skyline.webp new file mode 100644 index 0000000..b86c400 Binary files /dev/null and b/src/assets/sections/investors-skyline.webp differ diff --git a/src/assets/sections/model-build.webp b/src/assets/sections/model-build.webp new file mode 100644 index 0000000..ba0a400 Binary files /dev/null and b/src/assets/sections/model-build.webp differ diff --git a/src/assets/sections/model-compound.webp b/src/assets/sections/model-compound.webp new file mode 100644 index 0000000..b5c9ca7 Binary files /dev/null and b/src/assets/sections/model-compound.webp differ diff --git a/src/assets/sections/model-spinoff.webp b/src/assets/sections/model-spinoff.webp new file mode 100644 index 0000000..5cbc803 Binary files /dev/null and b/src/assets/sections/model-spinoff.webp differ diff --git a/src/assets/sections/principle-engine.webp b/src/assets/sections/principle-engine.webp new file mode 100644 index 0000000..f7dfb2d Binary files /dev/null and b/src/assets/sections/principle-engine.webp differ diff --git a/src/assets/sections/principle-evidence.webp b/src/assets/sections/principle-evidence.webp new file mode 100644 index 0000000..836db7a Binary files /dev/null and b/src/assets/sections/principle-evidence.webp differ diff --git a/src/assets/sections/principle-standalone.webp b/src/assets/sections/principle-standalone.webp new file mode 100644 index 0000000..39b5e1c Binary files /dev/null and b/src/assets/sections/principle-standalone.webp differ diff --git a/src/assets/sections/stack-cloud.webp b/src/assets/sections/stack-cloud.webp new file mode 100644 index 0000000..4f8d6c3 Binary files /dev/null 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 new file mode 100644 index 0000000..f651675 Binary files /dev/null and b/src/assets/sections/stack-data.webp differ diff --git a/src/assets/sections/stack-design.webp b/src/assets/sections/stack-design.webp new file mode 100644 index 0000000..a8885d0 Binary files /dev/null and b/src/assets/sections/stack-design.webp differ diff --git a/src/assets/sections/stack-engineering.webp b/src/assets/sections/stack-engineering.webp new file mode 100644 index 0000000..55886b4 Binary files /dev/null and b/src/assets/sections/stack-engineering.webp differ diff --git a/src/assets/sections/stack-finance.webp b/src/assets/sections/stack-finance.webp new file mode 100644 index 0000000..bc5d411 Binary files /dev/null and b/src/assets/sections/stack-finance.webp differ diff --git a/src/assets/sections/stack-governance.webp b/src/assets/sections/stack-governance.webp new file mode 100644 index 0000000..3f30d12 Binary files /dev/null and b/src/assets/sections/stack-governance.webp differ diff --git a/src/assets/sections/stack-growth.webp b/src/assets/sections/stack-growth.webp new file mode 100644 index 0000000..f35f5b4 Binary files /dev/null and b/src/assets/sections/stack-growth.webp differ diff --git a/src/assets/sections/stack-legal.webp b/src/assets/sections/stack-legal.webp new file mode 100644 index 0000000..f20a71d Binary files /dev/null and b/src/assets/sections/stack-legal.webp differ diff --git a/src/assets/sections/stack-marketing.webp b/src/assets/sections/stack-marketing.webp new file mode 100644 index 0000000..351c648 Binary files /dev/null and b/src/assets/sections/stack-marketing.webp differ diff --git a/src/assets/sections/stack-sales.webp b/src/assets/sections/stack-sales.webp new file mode 100644 index 0000000..54c8270 Binary files /dev/null and b/src/assets/sections/stack-sales.webp differ diff --git a/src/assets/sections/stage-build.webp b/src/assets/sections/stage-build.webp new file mode 100644 index 0000000..be673f7 Binary files /dev/null and b/src/assets/sections/stage-build.webp differ diff --git a/src/assets/sections/stage-discover.webp b/src/assets/sections/stage-discover.webp new file mode 100644 index 0000000..8baaf24 Binary files /dev/null and b/src/assets/sections/stage-discover.webp differ diff --git a/src/assets/sections/stage-launch.webp b/src/assets/sections/stage-launch.webp new file mode 100644 index 0000000..d84a071 Binary files /dev/null and b/src/assets/sections/stage-launch.webp differ diff --git a/src/assets/sections/stage-scale.webp b/src/assets/sections/stage-scale.webp new file mode 100644 index 0000000..0ea9503 Binary files /dev/null and b/src/assets/sections/stage-scale.webp differ diff --git a/src/assets/sections/stage-spinoff.webp b/src/assets/sections/stage-spinoff.webp new file mode 100644 index 0000000..fbb057a Binary files /dev/null and b/src/assets/sections/stage-spinoff.webp differ diff --git a/src/assets/sections/stage-validate.webp b/src/assets/sections/stage-validate.webp new file mode 100644 index 0000000..305c5bc Binary files /dev/null and b/src/assets/sections/stage-validate.webp differ diff --git a/src/assets/sections/vision-city.webp b/src/assets/sections/vision-city.webp new file mode 100644 index 0000000..cde1376 Binary files /dev/null and b/src/assets/sections/vision-city.webp differ diff --git a/src/components/site/creative.tsx b/src/components/site/creative.tsx new file mode 100644 index 0000000..d11620b --- /dev/null +++ b/src/components/site/creative.tsx @@ -0,0 +1,540 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { Eye, Sparkles } from "lucide-react"; + +const REDUCED = "(prefers-reduced-motion: reduce)"; + +/** Milliseconds until the first-visit intro has lifted (0 when it isn't showing). */ +function introRemaining() { + return document.documentElement.classList.contains("intro") + ? Math.max(0, 1700 - performance.now()) + : 0; +} + +// --------------------------------------------------------------------------------------------- +// Molten mark: the brand mark rebuilt from gold particles +// --------------------------------------------------------------------------------------------- + +type Particle = { + x: number; + y: number; + vx: number; + vy: number; + tx: number; + ty: number; + dx: number; + dy: number; + ph: number; + c: string; +}; + +/** + * The mark as ~1,500 particles sampled from the logo's own pixels. They fly in and cast the + * mark once the page is ready, scatter away from the cursor like sparks, and melt away as the + * section scrolls out. Reduced motion draws the finished mark once. + */ +export function MoltenMark({ src, className = "" }: { src: string; className?: string }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + + useEffect(() => { + const wrap = wrapRef.current; + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!wrap || !canvas || !ctx) return; + const reduce = window.matchMedia(REDUCED).matches; + const section = wrap.closest("section"); + + let W = 0; + let H = 0; + let size = 2; + let ps: Particle[] = []; + let raf = 0; + let visible = true; + let dead = false; + const pointer = { x: -1e4, y: -1e4 }; + const castAt = performance.now() + introRemaining() + 250; + const img = new Image(); + img.src = src; + + const build = () => { + const r = wrap.getBoundingClientRect(); + if (!r.width || !img.width) return; + const dpr = Math.min(2, window.devicePixelRatio || 1); + W = r.width; + H = r.height; + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Sample the logo on a grid; each opaque cell becomes one particle in its own colour. + const fit = Math.min(W / img.width, H / img.height) * 0.86; + const sw = Math.round(img.width * fit); + const sh = Math.round(img.height * fit); + const gap = Math.max(3, Math.round(sw / 64)); + size = gap * 0.62; + const off = document.createElement("canvas"); + off.width = sw; + off.height = sh; + const o = off.getContext("2d", { willReadFrequently: true }); + if (!o) return; + o.drawImage(img, 0, 0, sw, sh); + const data = o.getImageData(0, 0, sw, sh).data; + const ox = (W - sw) / 2; + const oy = (H - sh) / 2; + + const old = ps; + ps = []; + for (let y = 0; y < sh; y += gap) { + for (let x = 0; x < sw; x += gap) { + const i = (y * sw + x) * 4; + if (data[i + 3] < 140) continue; + const prev = old[ps.length]; + const a = Math.random() * Math.PI * 2; + const far = Math.max(W, H) * (0.6 + Math.random() * 0.8); + ps.push({ + x: prev?.x ?? (reduce ? ox + x : W / 2 + Math.cos(a) * far), + y: prev?.y ?? (reduce ? oy + y : H / 2 + Math.sin(a) * far), + vx: 0, + vy: 0, + tx: ox + x, + ty: oy + y, + dx: Math.cos(a), + dy: Math.sin(a), + ph: Math.random() * Math.PI * 2, + c: `rgb(${data[i]},${data[i + 1]},${data[i + 2]})`, + }); + } + } + }; + + const draw = (alpha: number) => { + ctx.clearRect(0, 0, W, H); + ctx.globalAlpha = alpha; + for (const p of ps) { + ctx.fillStyle = p.c; + ctx.fillRect(p.x, p.y, size, size); + } + }; + + const frame = (now: number) => { + raf = 0; + const t = now / 1000; + const cast = now >= castAt; + // Melt: 0 while the section fills the screen, 1 once it has scrolled away. + let melt = 0; + if (section) { + const r = section.getBoundingClientRect(); + melt = Math.min(1, Math.max(0, -r.top / (r.height * 0.8))); + } + const R = 90; + for (const p of ps) { + let ax = 0; + let ay = 0; + if (cast) { + const gx = p.tx + p.dx * melt * W * 0.5 + Math.sin(t * 1.4 + p.ph) * 0.8; + const gy = + p.ty + p.dy * melt * H * 0.4 + melt * melt * H * 0.6 + Math.cos(t * 1.2 + p.ph) * 0.8; + ax = (gx - p.x) * 0.045; + ay = (gy - p.y) * 0.045; + } + const ddx = p.x - pointer.x; + const ddy = p.y - pointer.y; + const d2 = ddx * ddx + ddy * ddy; + if (d2 < R * R) { + const d = Math.sqrt(d2) || 1; + const f = (1 - d / R) * 7; + ax += (ddx / d) * f; + ay += (ddy / d) * f; + } + p.vx = (p.vx + ax) * 0.84; + p.vy = (p.vy + ay) * 0.84; + p.x += p.vx; + p.y += p.vy; + } + draw(1 - melt * 0.75); + if (visible && !dead) raf = requestAnimationFrame(frame); + }; + + const onMove = (e: PointerEvent) => { + const r = canvas.getBoundingClientRect(); + pointer.x = e.clientX - r.left; + pointer.y = e.clientY - r.top; + }; + const onLeave = () => { + pointer.x = pointer.y = -1e4; + }; + + const io = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + if (visible && !reduce && !raf) raf = requestAnimationFrame(frame); + }); + const ro = new ResizeObserver(() => { + build(); + if (reduce) draw(1); + }); + + img + .decode() + .catch(() => undefined) + .then(() => { + if (dead) return; + build(); + if (reduce) { + draw(1); + } else { + window.addEventListener("pointermove", onMove, { passive: true }); + document.documentElement.addEventListener("pointerleave", onLeave); + io.observe(wrap); + } + ro.observe(wrap); + }); + + return () => { + dead = true; + cancelAnimationFrame(raf); + io.disconnect(); + ro.disconnect(); + window.removeEventListener("pointermove", onMove); + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [src]); + + return ( +
+ +
+ ); +} + +// --------------------------------------------------------------------------------------------- +// Velocity ticker +// --------------------------------------------------------------------------------------------- + +/** + * Two giant rows of words drifting in opposite directions. Scrolling speeds them up, leans + * them into the motion and flips their direction to follow the scroll. + */ +export function VelocityTicker({ words }: { words: string[] }) { + const rowRefs = useRef<(HTMLDivElement | null)[]>([]); + const wrapRef = useRef(null); + + useEffect(() => { + const wrap = wrapRef.current; + if (!wrap || window.matchMedia(REDUCED).matches) return; + const rows = rowRefs.current; + const x = [0, 0]; + let last = window.scrollY; + let vel = 0; + let skew = 0; + let dir = 1; + let raf = 0; + let visible = false; + + const loop = () => { + raf = 0; + const y = window.scrollY; + const d = y - last; + last = y; + if (d !== 0) dir = d > 0 ? 1 : -1; + vel += (d - vel) * 0.12; + skew += (Math.max(-12, Math.min(12, vel * 0.4)) - skew) * 0.12; + rows.forEach((el, i) => { + if (!el) return; + const half = el.scrollWidth / 2; + const speed = (0.7 + Math.min(Math.abs(vel), 60) * 0.3) * dir * (i ? -1 : 1); + x[i] = (x[i] - speed) % half; + if (x[i] > 0) x[i] -= half; + el.style.transform = `translate3d(${x[i].toFixed(1)}px,0,0) skewX(${(-skew).toFixed(2)}deg)`; + }); + if (visible) raf = requestAnimationFrame(loop); + }; + const io = new IntersectionObserver(([e]) => { + visible = e.isIntersecting; + last = window.scrollY; + if (visible && !raf) raf = requestAnimationFrame(loop); + }); + io.observe(wrap); + return () => { + io.disconnect(); + cancelAnimationFrame(raf); + }; + }, []); + + const row = (outline: boolean) => + [0, 1].map((copy) => ( +
+ {words.map((w) => ( + + + {w} + + + + ))} +
+ )); + + return ( +
+ {[false, true].map((outline, i) => ( +
{ + rowRefs.current[i] = el; + }} + className="flex w-max will-change-transform" + > + {row(outline)} +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------------------------- +// Look-closer lens +// --------------------------------------------------------------------------------------------- + +/** + * One sentence you read, another you discover: a lens follows the cursor (or drifts on its own + * on touch screens and while idle) and swaps the text beneath it for the studio's answer. + */ +export function LensStatement({ + before, + after, + children, +}: { + before: string; + after: string; + children?: ReactNode; +}) { + const stageRef = useRef(null); + + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const reduce = window.matchMedia(REDUCED).matches; + let W = stage.offsetWidth; + let H = stage.offsetHeight; + let x = W * 0.3; + let y = H * 0.5; + let target: { x: number; y: number } | null = null; + let raf = 0; + let visible = false; + const t0 = performance.now(); + + const set = () => { + stage.style.setProperty("--lx", `${x.toFixed(1)}px`); + stage.style.setProperty("--ly", `${y.toFixed(1)}px`); + }; + const loop = (now: number) => { + raf = 0; + const t = (now - t0) / 1000; + // Idle: a slow figure-of-eight across the text. + const gx = target ? target.x : W * (0.5 + 0.38 * Math.sin(t * 0.45)); + const gy = target ? target.y : H * (0.5 + 0.3 * Math.sin(t * 0.9)); + x += (gx - x) * (target ? 0.18 : 0.05); + y += (gy - y) * (target ? 0.18 : 0.05); + set(); + if (visible) raf = requestAnimationFrame(loop); + }; + if (reduce) { + x = W * 0.5; + set(); + return; + } + + const onMove = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + const r = stage.getBoundingClientRect(); + target = { x: e.clientX - r.left, y: e.clientY - r.top }; + }; + const onLeave = () => { + target = null; + }; + const ro = new ResizeObserver(() => { + W = stage.offsetWidth; + H = stage.offsetHeight; + }); + const io = new IntersectionObserver(([e]) => { + visible = e.isIntersecting; + if (visible && !raf) raf = requestAnimationFrame(loop); + }); + stage.addEventListener("pointermove", onMove); + stage.addEventListener("pointerleave", onLeave); + ro.observe(stage); + io.observe(stage); + return () => { + stage.removeEventListener("pointermove", onMove); + stage.removeEventListener("pointerleave", onLeave); + ro.disconnect(); + io.disconnect(); + cancelAnimationFrame(raf); + }; + }, []); + + return ( +
+
+
+
+ + + Look closer + + + Move over the text + +
+ +
+

+ {before} +

+

+ {after} +

+ +

{after}

+
+ + {children ? ( +
+ {children} +
+ ) : null} +
+
+ ); +} + +// --------------------------------------------------------------------------------------------- +// Industry index with a floating photo +// --------------------------------------------------------------------------------------------- + +type IndexItem = { n: string; name: string; tag: string; img: string }; + +/** + * A typographic index: each industry is a giant line of type. Hovering one floats its photo + * under the cursor, leaning with the pointer's speed; the other lines recede. Touch screens + * get an inline thumbnail instead. + */ +export function IndustryIndex({ items }: { items: IndexItem[] }) { + const floatRef = useRef(null); + const [active, setActive] = useState(null); + + useEffect(() => { + const el = floatRef.current; + if (!el || !window.matchMedia("(pointer: fine)").matches) return; + let x = window.innerWidth / 2; + let y = window.innerHeight / 2; + let tx = x; + let ty = y; + let rot = 0; + let raf = 0; + const tick = () => { + raf = 0; + const dx = tx - x; + x += dx * 0.14; + y += (ty - y) * 0.14; + rot += (Math.max(-14, Math.min(14, dx * 0.12)) - rot) * 0.15; + el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) translate(-50%, -50%) rotate(${rot.toFixed(2)}deg)`; + if (Math.abs(dx) > 0.2 || Math.abs(ty - y) > 0.2 || Math.abs(rot) > 0.05) + raf = requestAnimationFrame(tick); + }; + const onMove = (e: PointerEvent) => { + tx = e.clientX; + ty = e.clientY; + if (!raf) raf = requestAnimationFrame(tick); + }; + window.addEventListener("pointermove", onMove, { passive: true }); + return () => { + window.removeEventListener("pointermove", onMove); + cancelAnimationFrame(raf); + }; + }, []); + + return ( +
+
+
+

The index.

+ + {String(items.length).padStart(2, "0")} industries + +
+ +
    setActive(null)}> + {items.map((it, i) => ( +
  1. setActive(i)} + className="relative grid grid-cols-12 items-center gap-4 border-t border-white/10 py-5 transition-opacity duration-500 last:border-b group-hover/list:opacity-30 hover:opacity-100! md:py-7" + > + + {it.n} + + + + {it.name} + + + {it.tag} + +
  2. + ))} +
+
+ + {/* Floating photo, fine pointers only. */} +
+
+ {items.map((it, i) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/site/motion.tsx b/src/components/site/motion.tsx new file mode 100644 index 0000000..e1fe1f9 --- /dev/null +++ b/src/components/site/motion.tsx @@ -0,0 +1,243 @@ +import { useEffect, useRef, type CSSProperties } from "react"; +import Lenis from "lenis"; + +const MOTION_OK = "(prefers-reduced-motion: no-preference)"; + +/** + * Inertial smooth scrolling for wheel and trackpad (touch keeps native scrolling). Pauses while + * the mobile menu holds the page (html.menu-open) and hands anchor links a smooth glide. + */ +export function useSmoothScroll() { + useEffect(() => { + if (!window.matchMedia(MOTION_OK).matches) return; + const lenis = new Lenis({ lerp: 0.1, anchors: { offset: -80 }, autoRaf: true }); + const root = document.documentElement; + const sync = () => (root.classList.contains("menu-open") ? lenis.stop() : lenis.start()); + const watch = new MutationObserver(sync); + watch.observe(root, { attributes: true, attributeFilter: ["class"] }); + return () => { + watch.disconnect(); + lenis.destroy(); + }; + }, []); +} + +const LINKISH = "a, button, [role='button'], summary, [data-cursor]"; +const TEXTISH = "input, textarea, select, [contenteditable='true']"; + +/** + * A ring that trails the pointer: swells over links, shows the data-cursor label (e.g. "Next") + * and steps aside over text fields. Also drives [data-magnetic] buttons, which lean toward the + * pointer while it is over them. Fine pointers only. + */ +export function Cursor() { + const ref = useRef(null); + const labelRef = useRef(null); + + useEffect(() => { + const el = ref.current; + const label = labelRef.current; + if (!el || !label) return; + if (!window.matchMedia(`(pointer: fine) and ${MOTION_OK}`).matches) return; + + let x = -100; + let y = -100; + let tx = -100; + let ty = -100; + let frame = 0; + let magnet: HTMLElement | null = null; + + const tick = () => { + frame = 0; + x += (tx - x) * 0.22; + y += (ty - y) * 0.22; + el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`; + if (Math.abs(tx - x) > 0.1 || Math.abs(ty - y) > 0.1) frame = requestAnimationFrame(tick); + }; + + const release = () => { + if (magnet) magnet.style.translate = ""; + magnet = null; + }; + + const onMove = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + tx = e.clientX; + ty = e.clientY; + el.dataset.on = "true"; + if (!frame) frame = requestAnimationFrame(tick); + + const target = e.target instanceof Element ? e.target : null; + const labelled = target?.closest("[data-cursor]"); + if (target?.closest(`${TEXTISH}, [data-cursor-hide]`)) el.dataset.state = "text"; + else if (labelled) { + el.dataset.state = "label"; + label.textContent = labelled.dataset.cursor ?? ""; + } else if (target?.closest(LINKISH)) el.dataset.state = "link"; + else el.dataset.state = ""; + + const m = target?.closest("[data-magnetic]") ?? null; + if (m !== magnet) release(); + if (m) { + magnet = m; + const r = m.getBoundingClientRect(); + const dx = (e.clientX - (r.left + r.width / 2)) * 0.3; + const dy = (e.clientY - (r.top + r.height / 2)) * 0.4; + m.style.translate = `${dx.toFixed(1)}px ${dy.toFixed(1)}px`; + } + }; + const onLeave = () => { + el.dataset.on = "false"; + release(); + }; + + window.addEventListener("pointermove", onMove, { passive: true }); + document.documentElement.addEventListener("pointerleave", onLeave); + return () => { + window.removeEventListener("pointermove", onMove); + document.documentElement.removeEventListener("pointerleave", onLeave); + cancelAnimationFrame(frame); + release(); + }; + }, []); + + return ( +
+
+ +
+
+ ); +} + +const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+/"; + +/** + * Decodes an element's text from random glyphs, left to right. Only touches a lone text node + * (never React's structure), and holds the element's width so nothing around it shifts. + */ +export function scramble(el: HTMLElement) { + const node = el.firstChild; + if ( + el.dataset.scrambling || + !node || + node.nodeType !== Node.TEXT_NODE || + el.childNodes.length > 1 + ) + return; + const text = el.dataset.text ?? node.nodeValue ?? ""; + el.dataset.text = text; + el.dataset.scrambling = "1"; + el.style.minWidth = `${el.getBoundingClientRect().width}px`; + const steps = Math.min(22, text.length + 8); + let step = 0; + const id = window.setInterval(() => { + step++; + const shown = Math.floor((step / steps) * text.length); + node.nodeValue = Array.from(text, (ch, i) => + i < shown || ch === " " ? ch : GLYPHS[(Math.random() * GLYPHS.length) | 0], + ).join(""); + if (step >= steps) { + window.clearInterval(id); + node.nodeValue = text; + delete el.dataset.scrambling; + el.style.minWidth = ""; + } + }, 32); +} + +/** + * [data-scramble] elements decode on hover; [data-scramble="in"] ones also decode once on + * arrival (after the intro, when it is showing). + */ +export function useScramble() { + useEffect(() => { + if (!window.matchMedia(MOTION_OK).matches) return; + const onOver = (e: PointerEvent) => { + const el = + e.target instanceof Element ? e.target.closest("[data-scramble]") : null; + if (el && !(e.relatedTarget instanceof Node && el.contains(e.relatedTarget))) scramble(el); + }; + document.addEventListener("pointerover", onOver); + const wait = document.documentElement.classList.contains("intro") ? 1800 : 350; + const timer = window.setTimeout(() => { + document.querySelectorAll('[data-scramble="in"]').forEach(scramble); + }, wait); + return () => { + document.removeEventListener("pointerover", onOver); + window.clearTimeout(timer); + }; + }, []); +} + +/** A title segment: plain words, or words picked out in the accent colour. */ +export type TitlePart = string | { accent: string }; + +/** + * Headline whose words rise one after another through their own masks (see .word). + * Screen readers get the sentence once, from a visually hidden copy. + */ +export function SplitTitle({ parts, start = 0 }: { parts: TitlePart[]; start?: number }) { + let w = start; + const text = parts.map((p) => (typeof p === "string" ? p : p.accent)).join(" "); + return ( + <> + {text} + {parts.map((p, pi) => { + const accent = typeof p !== "string"; + const words = (accent ? p.accent : p).split(" ").filter(Boolean); + return ( + + {words.map((word, i) => ( + + + + {word} + + {" "} + + ))} + + ); + })} + + ); +} + +/** + * First-visit intro. Always in the server HTML but only displayed when the head script + * (INTRO_SCRIPT) has put html.intro on the page, so it never flashes for returning visitors. + */ +export function Intro({ mark }: { mark: string }) { + return ( +
+
+ Venture studio + Bangalore, India +
+
+ +
+ +
+
+
+
+ + KapFoundry + + +
+
+
+
+
+
+ ); +} + +/** Runs before first paint: turns the intro on once per session unless motion is reduced. */ +export const INTRO_SCRIPT = `try{if(!sessionStorage.getItem("kf-intro")&&matchMedia("${MOTION_OK}").matches){document.documentElement.classList.add("intro");sessionStorage.setItem("kf-intro","1")}}catch(e){}`; diff --git a/src/components/site/pages.tsx b/src/components/site/pages.tsx new file mode 100644 index 0000000..7075543 --- /dev/null +++ b/src/components/site/pages.tsx @@ -0,0 +1,965 @@ +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { ArrowRight, ArrowUpRight, Check, Compass, Layers, Minus, Sparkles } from "lucide-react"; +import { CONTACT_EMAIL } from "@/lib/contact"; +import { SplitTitle, type TitlePart } from "@/components/site/motion"; +import { IndustryIndex, MoltenMark } from "@/components/site/creative"; +// 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"; +import principleStandalone from "@/assets/sections/principle-standalone.webp"; +import modelBuild from "@/assets/sections/model-build.webp"; +import modelSpinoff from "@/assets/sections/model-spinoff.webp"; +import modelCompound from "@/assets/sections/model-compound.webp"; +import { + Accent, + DuoPhoto, + EASE_STAGE, + Eyebrow, + FRAMEWORK, + FillWords, + NAV, + STACK, + SectionHeader, + VERTICALS, + logoFull, + logoMark, + orbitPosition, + reveal, + rise, + type SitePageName, +} from "@/components/site/sections"; + +/** Position of a page in the nav, as "01".."07". */ +function pageNumber(page: SitePageName) { + return String(NAV.findIndex((n) => n.href === `/${page}`) + 1).padStart(2, "0"); +} + +// --------------------------------------------------------------------------------------------- +// Page hero +// --------------------------------------------------------------------------------------------- + +/** + * Full-height opener for inner pages: page number and label, a headline whose words rise in + * one by one, the lead paragraph and a page-specific visual. As it scrolls away the visual + * drifts and the copy lifts (hero-timeline in styles.css). + */ +function PageHero({ + page, + label, + title, + intro, + visual, + children, +}: { + page: SitePageName; + label: string; + title: TitlePart[]; + intro: ReactNode; + visual: ReactNode; + children?: ReactNode; +}) { + return ( +
+
+
+
+
+
{visual}
+
+
+
+
+ +
+
+ + ({pageNumber(page)}) + + + + {label} + +
+ +

+ +

+ +
+
+

+ {intro} +

+ {children ? ( +
+ {children} +
+ ) : null} +
+ + Scroll to explore + + + + +
+
+ {/* Target for the scroll cue: the first section after the hero. */} + +
+ ); +} + +// --------------------------------------------------------------------------------------------- +// Hero visuals (decorative) +// --------------------------------------------------------------------------------------------- + +/** Place n items evenly on a circle of the given radius (% of the box), starting at 12 o'clock. */ +function ring(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)}%`, + }; +} + +/** Studio: the shared stack orbits the studio, the ventures circle closer in. */ +function StudioVisual() { + return ( +
+
+
+
+
+
+ {STACK.map((s, i) => ( + + + {s.label} + + + ))} +
+
+ {VERTICALS.map((v, i) => ( + + + + {v.short} + + + ))} +
+ +
+
+ ); +} + +/** Framework: the six stages as a loop with a comet running round it. */ +function FrameworkVisual() { + return ( +
+
+
+
+
+ {FRAMEWORK.map((f, i) => { + const Icon = f.icon; + return ( +
+ + + + + {f.n} {f.title} + +
+ ); + })} +
+
+
+ 06 +
+
+ Stages, one loop +
+
+
+
+
+ ); +} + +/** Portfolio: columns of vertical photos drifting past each other. */ +function PortfolioVisual() { + const columns = [ + { items: VERTICALS, cls: "column-up" }, + { items: [...VERTICALS.slice(3), ...VERTICALS.slice(0, 3)], cls: "column-down" }, + { items: [...VERTICALS.slice(5), ...VERTICALS.slice(0, 5)], cls: "column-up" }, + ]; + return ( +
+
+ {columns.map((col, c) => ( +
+
+ {[...col.items, ...col.items].map((v, i) => ( +
+ +
+
+ {v.short} +
+
+ ))} +
+
+ ))} +
+
+ ); +} + +// Network layout for the AI visual, in a 400×400 viewBox. +const AI_LAYERS = [3, 5, 5, 3].map((count, l, all) => + Array.from({ length: count }, (_, i) => ({ + x: 50 + (l * 300) / (all.length - 1), + y: 200 + (i - (count - 1) / 2) * 62, + })), +); +const AI_EDGES = AI_LAYERS.slice(0, -1).flatMap((layer, l) => + layer.flatMap((a) => AI_LAYERS[l + 1].map((b) => ({ a, b }))), +); + +/** AI: a small neural network with signals flowing through it. */ +function AIVisual() { + return ( +
+ + {AI_EDGES.map(({ a, b }, i) => ( + + ))} + {AI_EDGES.map(({ a, b }, i) => ( + + ))} + {AI_LAYERS.flat().map((n, i) => ( + + + + + ))} + +
+ ); +} + +/** Investors: a field of companies, lighting up in a wave across the ecosystem. */ +function InvestorsVisual() { + const cols = 11; + const rows = 11; + return ( +
+
+ {Array.from({ length: cols * rows }, (_, i) => { + const r = Math.floor(i / cols); + const c = i % cols; + const hub = r === 5 && c === 5; + return ( + + ); + })} +
+
+ ); +} + +function AboutVisual() { + return ( +
+
+
+
+ +
+
+ ); +} + +const IST = new Intl.DateTimeFormat("en-GB", { + timeZone: "Asia/Kolkata", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}); + +/** Contact: the studio's local time inside a rotating ring of text. */ +function ContactVisual() { + const [time, setTime] = useState(null); + useEffect(() => { + const tick = () => setTime(IST.format(new Date())); + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, []); + return ( +
+
+ + + + + + + BUILD WITH US · INVEST WITH US · PARTNER WITH US · + + + +
+
+
+
+
+ Bangalore, IST +
+
+ {time ?? "--:--:--"} +
+
+ + Studio open to new partners +
+
+
+
+
+ ); +} + +// --------------------------------------------------------------------------------------------- +// Heroes per page +// --------------------------------------------------------------------------------------------- + +export function StudioHero() { + return ( + } + /> + ); +} + +export function FrameworkHero() { + return ( + } + /> + ); +} + +export function PortfolioHero() { + return ( + } + /> + ); +} + +export function AIHero() { + return ( + } + /> + ); +} + +export function InvestorsHero() { + return ( + } + > + + Speak with our team + + + + ); +} + +export function AboutHero() { + return ( + } + /> + ); +} + +export function ContactHero() { + return ( + } + > + + {CONTACT_EMAIL} + + + + ); +} + +// --------------------------------------------------------------------------------------------- +// New content sections +// --------------------------------------------------------------------------------------------- + +const VC_ROWS = [ + { + topic: "When capital arrives", + vc: "After a company already exists", + kf: "Before the company exists, once research proves the problem", + }, + { + topic: "Who builds", + vc: "Founders alone, starting from scratch", + kf: "Founders with a studio team behind them from day one", + }, + { + topic: "Infrastructure", + vc: "Assembled again, company by company", + kf: "Engineering, design, legal, finance and growth, built once and shared", + }, + { + topic: "Technology", + vc: "AI added later, if at all", + kf: "AI-first architecture from the first line of code", + }, + { + topic: "Risk", + vc: "Concentrated in a single bet", + kf: "Spread across a portfolio of ventures", + }, +]; + +/** Studio: how the studio model differs from traditional venture capital, row by row. */ +export function StudioVsVC() { + return ( +
+
+ + Venture capital waits. We build. + + } + > +

+ Traditional venture capital invests in startups after they exist. KapFoundry creates + them, which changes almost everything about how a company begins. +

+
+ +
+
+ + Traditional venture capital + KapFoundry venture studio +
+
    + {VC_ROWS.map((r, i) => ( +
  1. + + +

    + 0{i + 1} + {r.topic} +

    +

    + + + Traditional venture capital: + {r.vc} + +

    +

    + + + KapFoundry: + {r.kf} + +

    +
  2. + ))} +
+
+
+
+ ); +} + +const PRINCIPLES = [ + { + t: "Evidence before capital", + img: principleEvidence, + d: "Every venture begins with market research, customer discovery and product validation, before a single rupee is deployed.", + }, + { + t: "One engine, many companies", + img: principleEngine, + d: "Engineering, design, legal, finance and growth are built once and shared, so each venture launches with a mature company's stack.", + }, + { + t: "Built to stand alone", + img: principleStandalone, + d: "Each venture is designed to spin off into an independent, high-growth company that strengthens the wider ecosystem.", + }, +]; + +/** Framework: the statement and principles behind the six stages. */ +export function FrameworkPrinciples() { + return ( +
+
+
+

+ + +

+ +
    + {PRINCIPLES.map((p, i) => ( +
  1. +
    + +
    + + 0{i + 1} + +
    +
    +

    {p.t}

    +

    {p.d}

    +
    +
  2. + ))} +
+
+
+ ); +} + +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 }; +}); + +/** Portfolio: ventures already taking shape in the studio. */ +export function Ventures() { + return ( +
+
+ + Ventures taking shape. + + } + > +

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

+
+
+ ); + })} +
+ + + + + Next in the studio + + + Build the next one with us. + + + + + + +
+
+ ); +} + +/** AI: what the foundation does inside each vertical; the row at the centre of the screen is lit. */ +export function AIAcrossVerticals() { + return ( +
+
+
+
+ AI in every vertical +

+ One foundation. Seven industries. +

+

+ The same AI capabilities show up differently in every market we enter. Here is where + they do the heavy lifting. +

+
+
+
    + {VERTICALS.map((v) => { + const Icon = v.icon; + return ( +
  1. +
    + + + + + + +

    {v.name}

    + + {v.n} + +
    +
      + {v.solution.slice(0, 3).map((s) => ( +
    • + {s} +
    • + ))} +
    +
  2. + ); + })} +
+
+
+ ); +} + +const MODEL = [ + { + t: "The studio builds", + img: modelBuild, + d: "Ventures are researched, validated and built on KapFoundry's shared engine.", + }, + { + t: "Ventures spin off", + img: modelSpinoff, + d: "Each company grows into an independent, high-growth business in its own market.", + }, + { + t: "Value compounds", + img: modelCompound, + d: "Every success strengthens the ecosystem, and investors share in the portfolio, not a single bet.", + }, +]; + +/** Investors: how value flows from the studio to the portfolio. */ +export function InvestorModel() { + return ( +
+
+ + How value compounds. + + } + > +

A holding company at the centre, many independent companies around it.

+
+ +
+
    + {MODEL.map((m, i) => ( +
  1. +
    + +
    + + 0{i + 1} + +

    {m.t}

    +

    + {m.d} +

    +
  2. + ))} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------------------------- +// Next page handoff +// --------------------------------------------------------------------------------------------- + +const NEXT_BLURB: Record = { + "/studio": "How one shared engine builds many companies.", + "/framework": "Six stages from a billion-dollar problem to an independent company.", + "/portfolio": "The industries we're rebuilding, AI-first.", + "/ai": "The foundation under every venture we build.", + "/investors": "Exposure to a portfolio, not a single bet.", + "/about": "Our vision, our mission and the people behind them.", + "/contact": "Build with us, invest with us, or simply say hello.", +}; + +/** Giant link to the next page in nav order; grows in as it enters and fills gold on hover. */ +export function NextPage({ page }: { page: SitePageName }) { + const i = NAV.findIndex((n) => n.href === `/${page}`); + const k = (i + 1) % NAV.length; + const next = NAV[k]; + return ( +
+
+ ); +} + +/** Portfolio: the seven industries as a typographic index with floating photos. */ +export function PortfolioIndex() { + return ( + ({ n: v.n, name: v.short, tag: v.tag, img: v.img }))} + /> + ); +} diff --git a/src/components/site/sections.tsx b/src/components/site/sections.tsx index c7498ca..c9a7a00 100644 --- a/src/components/site/sections.tsx +++ b/src/components/site/sections.tsx @@ -50,6 +50,8 @@ import { Workflow, type LucideIcon, } from "lucide-react"; +import { Cursor, SplitTitle, 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). import logoMark from "@/assets/kapfoundry-mark.webp"; @@ -63,24 +65,58 @@ import vAgri from "@/assets/vertical-agri.jpg"; import vFoodtech from "@/assets/vertical-foodtech.jpg"; import vCarbon from "@/assets/vertical-carbon.jpg"; import vHealthtech from "@/assets/vertical-healthtech.jpg"; +// 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 imgVision from "@/assets/cards/ai-vision.webp"; +import imgAgentic from "@/assets/cards/ai-agentic.webp"; +import imgEnterprise from "@/assets/cards/ai-enterprise.webp"; +import imgPredictive from "@/assets/cards/ai-predictive.webp"; +import imgLlm from "@/assets/cards/ai-llm.webp"; +import imgAutomation from "@/assets/cards/ai-automation.webp"; +import imgMultiAgent from "@/assets/cards/ai-multiagent.webp"; +import imgDecision from "@/assets/cards/ai-decision.webp"; +// Section photography (Unsplash License): see src/assets/sections/CREDITS.md. +import stageDiscover from "@/assets/sections/stage-discover.webp"; +import stageValidate from "@/assets/sections/stage-validate.webp"; +import stageBuild from "@/assets/sections/stage-build.webp"; +import stageLaunch from "@/assets/sections/stage-launch.webp"; +import stageScale from "@/assets/sections/stage-scale.webp"; +import stageSpinoff from "@/assets/sections/stage-spinoff.webp"; +import stackEngineering from "@/assets/sections/stack-engineering.webp"; +import stackDesign from "@/assets/sections/stack-design.webp"; +import stackCloud from "@/assets/sections/stack-cloud.webp"; +import stackLegal from "@/assets/sections/stack-legal.webp"; +import stackFinance from "@/assets/sections/stack-finance.webp"; +import stackMarketing from "@/assets/sections/stack-marketing.webp"; +import stackSales from "@/assets/sections/stack-sales.webp"; +import stackGrowth from "@/assets/sections/stack-growth.webp"; +import stackData from "@/assets/sections/stack-data.webp"; +import stackGovernance from "@/assets/sections/stack-governance.webp"; +import visionCity from "@/assets/sections/vision-city.webp"; +import investorsSkyline from "@/assets/sections/investors-skyline.webp"; -// Home-page sections are "/#id" so they work from any page; About is its own route. +// Every nav entry is its own route; the home page previews them all. const NAV = [ - { label: "Studio", href: "/#studio" }, - { label: "Framework", href: "/#framework" }, - { label: "Portfolio", href: "/#portfolio" }, - { label: "AI", href: "/#ai" }, - { label: "Investors", href: "/#investors" }, + { label: "Studio", href: "/studio" }, + { label: "Framework", href: "/framework" }, + { label: "Portfolio", href: "/portfolio" }, + { label: "AI", href: "/ai" }, + { label: "Investors", href: "/investors" }, { label: "About", href: "/about" }, - { label: "Contact", href: "/#contact" }, + { label: "Contact", href: "/contact" }, ]; -export type SitePageName = "home" | "about"; - -/** Section id a nav link points at ("" for page links). */ -function sectionOf(href: string) { - return href.split("#")[1] ?? ""; -} +export type SitePageName = + | "home" + | "studio" + | "framework" + | "portfolio" + | "ai" + | "investors" + | "about" + | "contact"; const VERTICALS = [ { @@ -249,53 +285,109 @@ const FRAMEWORK = [ { n: "01", title: "Discover", + img: stageDiscover, icon: Search, body: "Identify billion-dollar industry problems.", }, { n: "02", title: "Validate", + img: stageValidate, icon: FlaskConical, body: "Customer interviews, market research, prototype testing.", }, - { n: "03", title: "Build", icon: Hammer, body: "Scalable products on AI-first architecture." }, + { + n: "03", + title: "Build", + img: stageBuild, + icon: Hammer, + body: "Scalable products on AI-first architecture.", + }, { n: "04", title: "Launch", + img: stageLaunch, icon: Rocket, body: "Acquire customers and reach product-market fit.", }, - { n: "05", title: "Scale", icon: TrendingUp, body: "Expand nationally and internationally." }, + { + n: "05", + title: "Scale", + img: stageScale, + icon: TrendingUp, + body: "Expand nationally and internationally.", + }, { n: "06", title: "Spin-Off", + img: stageSpinoff, icon: GitBranch, body: "Grow into independent portfolio companies.", }, ]; -const STACK: { label: string; icon: LucideIcon }[] = [ - { label: "AI and Engineering", icon: Cpu }, - { label: "Product Design", icon: Palette }, - { label: "Cloud Infrastructure", icon: Cloud }, - { label: "Legal and Compliance", icon: Scale }, - { label: "Finance", icon: Wallet }, - { label: "Marketing", icon: Megaphone }, - { label: "Sales Enablement", icon: Handshake }, - { label: "Growth Teams", icon: Users }, - { label: "Data Science", icon: Database }, - { label: "Corporate Governance", icon: Landmark }, +const STACK: { label: string; icon: LucideIcon; img: string }[] = [ + { label: "AI and Engineering", img: stackEngineering, icon: Cpu }, + { label: "Product Design", img: stackDesign, icon: Palette }, + { label: "Cloud Infrastructure", img: stackCloud, icon: Cloud }, + { label: "Legal and Compliance", img: stackLegal, icon: Scale }, + { label: "Finance", img: stackFinance, icon: Wallet }, + { label: "Marketing", img: stackMarketing, icon: Megaphone }, + { label: "Sales Enablement", img: stackSales, icon: Handshake }, + { label: "Growth Teams", img: stackGrowth, icon: Users }, + { label: "Data Science", img: stackData, icon: Database }, + { label: "Corporate Governance", img: stackGovernance, icon: Landmark }, ]; -const AI_CAPS: { label: string; icon: LucideIcon }[] = [ - { label: "Computer Vision", icon: Eye }, - { label: "Agentic AI", icon: Bot }, - { label: "Enterprise AI", icon: Building2 }, - { label: "Predictive Analytics", icon: ChartLine }, - { label: "LLM Applications", icon: Sparkles }, - { label: "AI Automation", icon: Workflow }, - { label: "Multi-Agent Systems", icon: Network }, - { label: "Decision Intelligence", icon: Brain }, +const AI_CAPS: { label: string; icon: LucideIcon; img: string; desc: string }[] = [ + { + label: "Computer Vision", + icon: Eye, + img: imgVision, + desc: "Machines that see: inspection, recognition and visual search at scale.", + }, + { + label: "Agentic AI", + icon: Bot, + img: imgAgentic, + desc: "Software agents that plan, decide and act on their own to finish real work.", + }, + { + label: "Enterprise AI", + icon: Building2, + img: imgEnterprise, + desc: "Secure, governed AI woven into the systems businesses already run on.", + }, + { + label: "Predictive Analytics", + icon: ChartLine, + img: imgPredictive, + desc: "Models that turn history into foresight, from demand to risk.", + }, + { + label: "LLM Applications", + icon: Sparkles, + img: imgLlm, + desc: "Large language models shaped into products people use every day.", + }, + { + label: "AI Automation", + icon: Workflow, + img: imgAutomation, + desc: "Repetitive workflows handed to machines, end to end.", + }, + { + label: "Multi-Agent Systems", + icon: Network, + img: imgMultiAgent, + desc: "Specialised agents coordinating like a flock, toward one goal.", + }, + { + label: "Decision Intelligence", + icon: Brain, + img: imgDecision, + desc: "Data, models and judgement combined to make better calls, faster.", + }, ]; const MISSION = [ @@ -463,27 +555,6 @@ function useHideOnScroll(topZone = 120, tolerance = 6) { return [hidden, reveal] as const; } -const SECTION_IDS = ["top", ...NAV.map((n) => sectionOf(n.href)).filter(Boolean)]; - -/** Id of the section crossing the middle of the viewport, for highlighting the nav. */ -function useActiveSection() { - const [active, setActive] = useState("top"); - useEffect(() => { - const io = new IntersectionObserver( - (entries) => { - for (const entry of entries) if (entry.isIntersecting) setActive(entry.target.id); - }, - { rootMargin: "-45% 0px -50% 0px" }, - ); - for (const id of SECTION_IDS) { - const el = document.getElementById(id); - if (el) io.observe(el); - } - return () => io.disconnect(); - }, []); - return active; -} - /** Staggered entrance for hero content (pairs with the `rise-in` class). */ function rise(delay: number) { return { style: { "--rise-delay": `${delay}ms` } as CSSProperties }; @@ -557,7 +628,6 @@ function SectionHeader({ function Nav({ page }: { page: SitePageName }) { const scrolled = useScrolledPast(20); const [scrollingDown, revealHeader] = useHideOnScroll(); - const active = useActiveSection(); const [open, setOpen] = useState(false); const toggleRef = useRef(null); const overlayRef = useRef(null); @@ -619,13 +689,11 @@ function Nav({ page }: { page: SitePageName }) { const solid = scrolled && !open; const hidden = scrollingDown && !open; - // Home highlights the section in view; other pages highlight their own link. - const isCurrent = (href: string) => - page === "home" ? active === sectionOf(href) : href === `/${page}`; + const isCurrent = (href: string) => href === `/${page}`; return ( <>