updated pages
59
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
19
src/assets/cards/CREDITS.md
Normal file
@@ -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 |
|
||||
BIN
src/assets/cards/ai-agentic.webp
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
src/assets/cards/ai-automation.webp
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
src/assets/cards/ai-decision.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/cards/ai-enterprise.webp
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
src/assets/cards/ai-llm.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
src/assets/cards/ai-multiagent.webp
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
src/assets/cards/ai-predictive.webp
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
src/assets/cards/ai-vision.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/cards/compound-spiral.webp
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
src/assets/cards/holding-tower.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
src/assets/cards/studio-foundry.webp
Normal file
|
After Width: | Height: | Size: 155 KiB |
32
src/assets/sections/CREDITS.md
Normal file
@@ -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 |
|
||||
BIN
src/assets/sections/investors-skyline.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
src/assets/sections/model-build.webp
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
src/assets/sections/model-compound.webp
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
src/assets/sections/model-spinoff.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/assets/sections/principle-engine.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/assets/sections/principle-evidence.webp
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/assets/sections/principle-standalone.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src/assets/sections/stack-cloud.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/sections/stack-data.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/assets/sections/stack-design.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
src/assets/sections/stack-engineering.webp
Normal file
|
After Width: | Height: | Size: 285 KiB |
BIN
src/assets/sections/stack-finance.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/sections/stack-governance.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/assets/sections/stack-growth.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
src/assets/sections/stack-legal.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/sections/stack-marketing.webp
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
src/assets/sections/stack-sales.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/assets/sections/stage-build.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
src/assets/sections/stage-discover.webp
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
src/assets/sections/stage-launch.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
src/assets/sections/stage-scale.webp
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
src/assets/sections/stage-spinoff.webp
Normal file
|
After Width: | Height: | Size: 475 KiB |
BIN
src/assets/sections/stage-validate.webp
Normal file
|
After Width: | Height: | Size: 367 KiB |
BIN
src/assets/sections/vision-city.webp
Normal file
|
After Width: | Height: | Size: 289 KiB |
540
src/components/site/creative.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
||||
<div ref={wrapRef} aria-hidden className={`relative ${className}`}>
|
||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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<HTMLDivElement>(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) => (
|
||||
<div key={copy} className="flex shrink-0 items-center" aria-hidden={copy ? true : undefined}>
|
||||
{words.map((w) => (
|
||||
<span key={w} className="flex items-center">
|
||||
<span
|
||||
className={`px-6 font-display text-6xl font-extrabold uppercase leading-[1.05] tracking-tighter md:px-10 md:text-[9rem] ${
|
||||
outline
|
||||
? "text-transparent [-webkit-text-stroke:1.5px_color-mix(in_oklab,var(--primary)_70%,transparent)]"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
<Sparkles className="h-6 w-6 shrink-0 text-primary md:h-10 md:w-10" aria-hidden />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="overflow-hidden border-y border-border bg-ink py-8 md:py-14">
|
||||
{[false, true].map((outline, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
rowRefs.current[i] = el;
|
||||
}}
|
||||
className="flex w-max will-change-transform"
|
||||
>
|
||||
{row(outline)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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<HTMLDivElement>(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 (
|
||||
<section className="section-y relative overflow-hidden bg-ink">
|
||||
<div className="glow glow-drift absolute -left-40 top-0 h-[34rem] w-[34rem] opacity-30" />
|
||||
<div className="container-x relative">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="eyebrow">
|
||||
<Eye className="h-3.5 w-3.5" aria-hidden />
|
||||
Look closer
|
||||
</span>
|
||||
<span className="hidden font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground md:block">
|
||||
Move over the text
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={stageRef}
|
||||
data-cursor-hide
|
||||
className="lens-stage relative mt-10 grid cursor-none py-6"
|
||||
>
|
||||
<p className="lens-base font-display text-4xl font-bold leading-[1.06] tracking-tight text-foreground/25 [grid-area:1/1] md:text-7xl">
|
||||
{before}
|
||||
</p>
|
||||
<p
|
||||
aria-hidden
|
||||
className="lens-top font-display text-4xl font-bold leading-[1.06] tracking-tight text-primary [grid-area:1/1] md:text-7xl"
|
||||
>
|
||||
{after}
|
||||
</p>
|
||||
<span aria-hidden className="lens-ring" />
|
||||
<p className="sr-only">{after}</p>
|
||||
</div>
|
||||
|
||||
{children ? (
|
||||
<div className="mt-12 max-w-[65ch] text-lg leading-relaxed text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState<number | null>(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 (
|
||||
<section className="section-y relative bg-ink">
|
||||
<div className="container-x">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<h2 className="font-display text-4xl font-bold leading-[1.04] md:text-5xl">The index.</h2>
|
||||
<span className="font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{String(items.length).padStart(2, "0")} industries
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol className="group/list mt-12" onPointerLeave={() => setActive(null)}>
|
||||
{items.map((it, i) => (
|
||||
<li
|
||||
key={it.n}
|
||||
onPointerEnter={() => 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"
|
||||
>
|
||||
<span className="col-span-2 font-display text-sm tabular-nums text-primary md:col-span-1">
|
||||
{it.n}
|
||||
</span>
|
||||
<img
|
||||
src={it.img}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="col-span-3 aspect-square w-full max-w-16 rounded-xl object-cover [@media(pointer:fine)]:hidden"
|
||||
/>
|
||||
<span
|
||||
className={`col-span-7 font-display text-3xl font-extrabold uppercase leading-none tracking-tighter transition-[transform,color] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] sm:text-5xl md:col-span-8 md:text-6xl lg:text-7xl [@media(pointer:fine)]:col-span-10 md:[@media(pointer:fine)]:col-span-8 ${
|
||||
active === i ? "translate-x-4 text-primary md:translate-x-8" : ""
|
||||
}`}
|
||||
>
|
||||
{it.name}
|
||||
</span>
|
||||
<span className="col-span-3 hidden text-right text-sm text-muted-foreground md:block">
|
||||
{it.tag}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Floating photo, fine pointers only. */}
|
||||
<div
|
||||
ref={floatRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed left-0 top-0 z-40 hidden [@media(pointer:fine)]:block"
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-[4/5] w-72 overflow-hidden rounded-2xl shadow-2xl shadow-black/60 transition-[opacity,scale] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
active === null ? "scale-50 opacity-0" : "scale-100 opacity-100"
|
||||
}`}
|
||||
>
|
||||
{items.map((it, i) => (
|
||||
<img
|
||||
key={it.n}
|
||||
src={it.img}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={`absolute inset-0 h-full w-full object-cover transition-[clip-path,scale] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
active === i
|
||||
? "scale-100 [clip-path:inset(0)]"
|
||||
: "scale-125 [clip-path:inset(100%_0_0_0)]"
|
||||
}`}
|
||||
style={{ zIndex: active === i ? 2 : 1 } as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
243
src/components/site/motion.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
const labelRef = useRef<HTMLSpanElement>(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<HTMLElement>("[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<HTMLElement>("[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 (
|
||||
<div ref={ref} aria-hidden className="cursor">
|
||||
<div className="cursor-ring">
|
||||
<span ref={labelRef} className="cursor-label" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLElement>("[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<HTMLElement>('[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 (
|
||||
<>
|
||||
<span className="sr-only">{text}</span>
|
||||
{parts.map((p, pi) => {
|
||||
const accent = typeof p !== "string";
|
||||
const words = (accent ? p.accent : p).split(" ").filter(Boolean);
|
||||
return (
|
||||
<span key={pi} aria-hidden className={accent ? "text-primary" : undefined}>
|
||||
{words.map((word, i) => (
|
||||
<span key={i}>
|
||||
<span className="word-mask">
|
||||
<span className="word" style={{ "--w": w++ } as CSSProperties}>
|
||||
{word}
|
||||
</span>
|
||||
</span>{" "}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
aria-hidden
|
||||
className="intro-overlay fixed inset-0 z-[80] flex-col justify-between bg-ink p-6 text-foreground md:p-10"
|
||||
>
|
||||
<div className="flex items-center justify-between font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<span>Venture studio</span>
|
||||
<span>Bangalore, India</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<img src={mark} alt="" width={201} height={192} className="h-16 w-auto md:h-20" />
|
||||
<div className="font-display text-4xl font-extrabold tracking-tighter md:text-7xl">
|
||||
<SplitTitle parts={["Build", { accent: "Bold." }]} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
KapFoundry
|
||||
</span>
|
||||
<span className="intro-count font-display text-6xl font-extrabold tabular-nums leading-none text-primary md:text-8xl" />
|
||||
</div>
|
||||
<div className="mt-4 h-px bg-white/10">
|
||||
<div className="intro-bar h-full bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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){}`;
|
||||
965
src/components/site/pages.tsx
Normal file
@@ -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 (
|
||||
<section
|
||||
id="top"
|
||||
className="hero-timeline relative flex min-h-[100svh] flex-col overflow-hidden bg-ink"
|
||||
>
|
||||
<div className="hero-media absolute inset-0">
|
||||
<div className="mesh-gold absolute inset-0 opacity-25" aria-hidden />
|
||||
<div className="dot-grid absolute inset-0" aria-hidden />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-20 lg:left-auto lg:w-[48%] lg:opacity-100 xl:w-[46%]"
|
||||
{...rise(500)}
|
||||
>
|
||||
<div className="rise-in h-full w-full">{visual}</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-gradient-to-r from-ink via-ink/75 to-transparent lg:via-ink/40"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-ink to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="hero-copy container-x relative flex flex-1 flex-col justify-between gap-12 pb-10 pt-28 md:pb-12 md:pt-36">
|
||||
<div
|
||||
{...rise(100)}
|
||||
className="rise-in flex items-center gap-4 font-display text-xs font-semibold uppercase tracking-[0.2em]"
|
||||
>
|
||||
<span data-scramble="in" className="tabular-nums text-primary">
|
||||
({pageNumber(page)})
|
||||
</span>
|
||||
<span className="h-px w-12 bg-primary/50" aria-hidden />
|
||||
<span data-scramble="in" className="text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="max-w-[14ch] font-display text-[clamp(2.25rem,10vw,3.5rem)] md:text-[clamp(3.5rem,6.6vw,6.25rem)] font-extrabold leading-[0.93] tracking-tighter">
|
||||
<SplitTitle parts={title} />
|
||||
</h1>
|
||||
|
||||
<div className="grid items-end gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-7 lg:col-span-6">
|
||||
<p
|
||||
{...rise(700)}
|
||||
className="rise-in max-w-[52ch] text-lg leading-relaxed text-muted-foreground md:text-xl"
|
||||
>
|
||||
{intro}
|
||||
</p>
|
||||
{children ? (
|
||||
<div {...rise(850)} className="rise-in mt-8">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href="#main-content"
|
||||
{...rise(1000)}
|
||||
className="rise-in hidden items-center gap-4 justify-self-end font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground transition-colors hover:text-primary md:col-span-5 md:flex lg:col-span-6"
|
||||
>
|
||||
Scroll to explore
|
||||
<span className="relative block h-12 w-px overflow-hidden bg-white/15" aria-hidden>
|
||||
<span className="scroll-cue absolute inset-x-0 top-0 h-1/2 bg-primary" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* Target for the scroll cue: the first section after the hero. */}
|
||||
<span id="main-content" className="absolute bottom-0" aria-hidden />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="relative aspect-square w-[min(40rem,92%)]">
|
||||
<div className="absolute inset-[3%] rounded-full border border-white/10" />
|
||||
<div className="absolute inset-[23%] rounded-full border border-dashed border-primary/30" />
|
||||
<div className="glow absolute inset-[20%]" />
|
||||
<div className="spin-slow absolute inset-0">
|
||||
{STACK.map((s, i) => (
|
||||
<span
|
||||
key={s.label}
|
||||
style={ring(i, STACK.length, 47)}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<span className="upright block whitespace-nowrap rounded-full border border-white/10 bg-ink/80 px-3 py-1.5 font-display text-[11px] font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur">
|
||||
{s.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="spin-slower absolute inset-0">
|
||||
{VERTICALS.map((v, i) => (
|
||||
<span
|
||||
key={v.n}
|
||||
style={ring(i, VERTICALS.length, 27)}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<span className="upright flex items-center gap-2 whitespace-nowrap font-display text-[11px] font-bold uppercase tracking-wider text-primary">
|
||||
<span className="h-2 w-2 rounded-full bg-primary shadow-[0_0_12px] shadow-primary" />
|
||||
{v.short}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<img
|
||||
src={logoMark}
|
||||
alt=""
|
||||
width={201}
|
||||
height={192}
|
||||
className="absolute left-1/2 top-1/2 w-[18%] -translate-x-1/2 -translate-y-1/2 drop-shadow-[0_20px_40px_oklch(0.05_0.01_60/0.8)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Framework: the six stages as a loop with a comet running round it. */
|
||||
function FrameworkVisual() {
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="relative aspect-square w-[min(38rem,90%)]">
|
||||
<div className="absolute inset-[12.5%] rounded-full border border-white/10" />
|
||||
<div
|
||||
className="orbit-comet absolute inset-[12.5%] rounded-full"
|
||||
style={{
|
||||
background: "conic-gradient(from 0deg, transparent 0deg 250deg, var(--primary) 360deg)",
|
||||
mask: "radial-gradient(farthest-side, transparent calc(100% - 3px), black calc(100% - 2px))",
|
||||
}}
|
||||
/>
|
||||
<div className="glow absolute inset-[25%]" />
|
||||
{FRAMEWORK.map((f, i) => {
|
||||
const Icon = f.icon;
|
||||
return (
|
||||
<div
|
||||
key={f.n}
|
||||
style={orbitPosition(i, FRAMEWORK.length)}
|
||||
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2"
|
||||
>
|
||||
<span className="grid h-14 w-14 place-items-center rounded-2xl border border-primary/30 bg-ink/90 text-primary shadow-lg shadow-black/40 backdrop-blur">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="whitespace-nowrap font-display text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="text-primary">{f.n}</span> {f.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div>
|
||||
<div className="font-display text-7xl font-extrabold tracking-tighter text-foreground md:text-8xl">
|
||||
06
|
||||
</div>
|
||||
<div className="mt-1 font-display text-xs font-semibold uppercase tracking-[0.2em] text-primary">
|
||||
Stages, one loop
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="grid h-[140%] -translate-y-[12%] rotate-[-8deg] scale-110 grid-cols-2 gap-4 px-4 sm:grid-cols-3">
|
||||
{columns.map((col, c) => (
|
||||
<div key={c} className={`overflow-hidden ${c === 2 ? "hidden sm:block" : ""}`}>
|
||||
<div className={col.cls}>
|
||||
{[...col.items, ...col.items].map((v, i) => (
|
||||
<figure
|
||||
key={i}
|
||||
className="relative mb-4 aspect-[4/5] overflow-hidden rounded-2xl border border-white/10"
|
||||
>
|
||||
<img
|
||||
src={v.img}
|
||||
alt=""
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-ink/80 to-transparent" />
|
||||
<figcaption className="absolute bottom-3 left-3 rounded-full bg-ink/70 px-3 py-1 font-display text-[11px] font-semibold uppercase tracking-wider text-primary backdrop-blur">
|
||||
{v.short}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="grid h-full place-items-center">
|
||||
<svg viewBox="0 0 400 400" className="w-[min(40rem,94%)] overflow-visible">
|
||||
{AI_EDGES.map(({ a, b }, i) => (
|
||||
<line
|
||||
key={`e${i}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke="white"
|
||||
strokeOpacity={0.07}
|
||||
/>
|
||||
))}
|
||||
{AI_EDGES.map(({ a, b }, i) => (
|
||||
<line
|
||||
key={`s${i}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
className="signal"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
style={{ "--d": `${-((i * 37) % 29) / 9}s` } as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
{AI_LAYERS.flat().map((n, i) => (
|
||||
<g key={`n${i}`}>
|
||||
<circle cx={n.x} cy={n.y} r={14} fill="var(--primary)" opacity={0.08} />
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={4}
|
||||
fill="var(--primary)"
|
||||
opacity={0.55}
|
||||
className="node-pulse"
|
||||
style={{ "--d": `${-((i * 13) % 11) / 4}s` } as CSSProperties}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Investors: a field of companies, lighting up in a wave across the ecosystem. */
|
||||
function InvestorsVisual() {
|
||||
const cols = 11;
|
||||
const rows = 11;
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div
|
||||
className="grid w-[min(34rem,88%)] gap-[3.2%]"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => {
|
||||
const r = Math.floor(i / cols);
|
||||
const c = i % cols;
|
||||
const hub = r === 5 && c === 5;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`dot-wave aspect-square rounded-full ${
|
||||
hub ? "bg-foreground" : (r * 7 + c * 3) % 5 === 0 ? "bg-primary" : "bg-primary/60"
|
||||
}`}
|
||||
style={{ "--d": `${((r + c) * 0.14).toFixed(2)}s` } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutVisual() {
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="relative">
|
||||
<div className="glow absolute -inset-16" />
|
||||
<div className="spin-slower absolute -inset-10 rounded-full border border-dashed border-primary/25" />
|
||||
<MoltenMark src={logoMark} className="aspect-square w-[min(28rem,70vw)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
useEffect(() => {
|
||||
const tick = () => setTime(IST.format(new Date()));
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="relative aspect-square w-[min(34rem,86%)]">
|
||||
<svg viewBox="0 0 400 400" className="spin-slow absolute inset-0 overflow-visible">
|
||||
<defs>
|
||||
<path
|
||||
id="contact-ring"
|
||||
d="M200,200 m-170,0 a170,170 0 1,1 340,0 a170,170 0 1,1 -340,0"
|
||||
/>
|
||||
</defs>
|
||||
<text
|
||||
className="font-display"
|
||||
fill="var(--primary)"
|
||||
fontSize="17"
|
||||
fontWeight="700"
|
||||
letterSpacing="5"
|
||||
>
|
||||
<textPath href="#contact-ring" textLength="1060">
|
||||
BUILD WITH US · INVEST WITH US · PARTNER WITH US ·
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
<div className="absolute inset-[16%] rounded-full border border-white/10" />
|
||||
<div className="glow absolute inset-[10%]" />
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div>
|
||||
<div className="font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Bangalore, IST
|
||||
</div>
|
||||
<div className="mt-3 font-display text-5xl font-extrabold tabular-nums tracking-tight md:text-7xl">
|
||||
{time ?? "--:--:--"}
|
||||
</div>
|
||||
<div className="mt-3 inline-flex items-center gap-2 text-sm text-primary">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-primary" />
|
||||
Studio open to new partners
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Heroes per page
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function StudioHero() {
|
||||
return (
|
||||
<PageHero
|
||||
page="studio"
|
||||
label="The studio"
|
||||
title={["One studio.", "Many companies.", { accent: "One engine." }]}
|
||||
intro="We don't fund companies after they form. We find the problem, build the company and scale it on infrastructure every venture shares."
|
||||
visual={<StudioVisual />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrameworkHero() {
|
||||
return (
|
||||
<PageHero
|
||||
page="framework"
|
||||
label="The framework"
|
||||
title={["Company building,", { accent: "made repeatable." }]}
|
||||
intro="Every venture moves through the same six stages, so each new company starts with the lessons of the last."
|
||||
visual={<FrameworkVisual />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioHero() {
|
||||
return (
|
||||
<PageHero
|
||||
page="portfolio"
|
||||
label="The portfolio"
|
||||
title={["Big problems.", { accent: "Bold companies." }]}
|
||||
intro="We build where problems are large, fragmented and overdue for change: seven industries, one AI engine underneath them all."
|
||||
visual={<PortfolioVisual />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIHero() {
|
||||
return (
|
||||
<PageHero
|
||||
page="ai"
|
||||
label="Artificial intelligence"
|
||||
title={["Intelligence,", { accent: "built in from day one." }]}
|
||||
intro="AI isn't bolted on later. It shapes how every KapFoundry company is designed, operated and scaled."
|
||||
visual={<AIVisual />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestorsHero() {
|
||||
return (
|
||||
<PageHero
|
||||
page="investors"
|
||||
label="For investors"
|
||||
title={["One investment.", { accent: "Many companies." }]}
|
||||
intro="Exposure to a portfolio of AI-first ventures across industries: risk spread wide, upside shared across every company the studio builds."
|
||||
visual={<InvestorsVisual />}
|
||||
>
|
||||
<a
|
||||
href="/contact"
|
||||
data-magnetic
|
||||
className="press group inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 font-display text-sm font-semibold text-primary-foreground hover:brightness-110"
|
||||
>
|
||||
Speak with our team
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||||
</a>
|
||||
</PageHero>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutHero() {
|
||||
return (
|
||||
<PageHero
|
||||
page="about"
|
||||
label="About KapFoundry"
|
||||
title={["We don't wait for the future.", { accent: "We build it." }]}
|
||||
intro="A venture studio and technology holding company that engineers category-defining, AI-first companies, before they even exist."
|
||||
visual={<AboutVisual />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactHero() {
|
||||
return (
|
||||
<PageHero
|
||||
page="contact"
|
||||
label="Contact"
|
||||
title={["Let's build", { accent: "what comes next." }]}
|
||||
intro="Whether you want to invest, partner or build with us, tell us a little about yourself and our team will be in touch."
|
||||
visual={<ContactVisual />}
|
||||
>
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
data-cursor="Write"
|
||||
className="group inline-flex items-center gap-3 border-b border-primary/40 pb-2 font-display text-2xl font-bold transition-colors hover:border-primary hover:text-primary md:text-3xl"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
<ArrowUpRight
|
||||
className="h-6 w-6 text-primary transition-transform duration-300 group-hover:-translate-y-1 group-hover:translate-x-1"
|
||||
aria-hidden
|
||||
/>
|
||||
</a>
|
||||
</PageHero>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||||
<div className="container-x">
|
||||
<SectionHeader
|
||||
eyebrow="A different model"
|
||||
icon={Compass}
|
||||
title={
|
||||
<>
|
||||
Venture capital waits. <Accent>We build.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Traditional venture capital invests in startups after they exist. KapFoundry creates
|
||||
them, which changes almost everything about how a company begins.
|
||||
</p>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="mt-16">
|
||||
<div className="hidden grid-cols-12 gap-6 pb-4 font-display text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground lg:grid">
|
||||
<span className="col-span-3" />
|
||||
<span className="col-span-4">Traditional venture capital</span>
|
||||
<span className="col-span-5 text-primary">KapFoundry venture studio</span>
|
||||
</div>
|
||||
<ol>
|
||||
{VC_ROWS.map((r, i) => (
|
||||
<li
|
||||
key={r.topic}
|
||||
{...reveal("up", i * 40)}
|
||||
className="group relative grid gap-3 py-7 lg:grid-cols-12 lg:gap-6 lg:py-9"
|
||||
>
|
||||
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-border" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="line-draw absolute inset-x-0 top-0 h-px bg-primary/60"
|
||||
/>
|
||||
<h3 className="font-display text-xl font-bold lg:col-span-3 lg:text-2xl">
|
||||
<span className="mr-3 text-sm tabular-nums text-primary">0{i + 1}</span>
|
||||
{r.topic}
|
||||
</h3>
|
||||
<p className="flex gap-3 text-muted-foreground lg:col-span-4">
|
||||
<Minus className="mt-1 h-4 w-4 shrink-0 opacity-60" aria-hidden />
|
||||
<span>
|
||||
<span className="sr-only">Traditional venture capital: </span>
|
||||
{r.vc}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={`flex gap-3 font-display text-lg font-semibold transition-transform duration-500 group-hover:translate-x-1 lg:col-span-5 lg:text-xl ${EASE_STAGE}`}
|
||||
>
|
||||
<Check className="mt-1 h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<span>
|
||||
<span className="sr-only">KapFoundry: </span>
|
||||
{r.kf}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="section-y relative overflow-hidden bg-ink">
|
||||
<div className="glow glow-drift absolute -left-48 top-10 h-[36rem] w-[36rem] opacity-40" />
|
||||
<div className="container-x relative">
|
||||
<p className="max-w-5xl font-display text-4xl font-bold leading-[1.06] tracking-tight md:text-6xl">
|
||||
<FillWords text="Great ideas fail on avoidable mistakes." />
|
||||
<FillWords
|
||||
text="A repeatable system catches them before capital is spent."
|
||||
className="text-primary"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<ol className="mt-20 grid gap-6 md:grid-cols-3" {...reveal("stagger")}>
|
||||
{PRINCIPLES.map((p, i) => (
|
||||
<li
|
||||
key={p.t}
|
||||
tabIndex={0}
|
||||
className="group relative overflow-hidden rounded-3xl border border-border bg-card/60 transition-colors duration-500 hover:border-primary/50"
|
||||
>
|
||||
<div className="relative h-56 overflow-hidden">
|
||||
<DuoPhoto src={p.img} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-card via-card/20 to-transparent" />
|
||||
<span className="absolute bottom-3 left-8 font-display text-6xl font-extrabold tabular-nums text-transparent transition-colors duration-500 [-webkit-text-stroke:1px_color-mix(in_oklab,var(--primary)_80%,transparent)] group-hover:text-primary">
|
||||
0{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-8 pt-5">
|
||||
<h3 className="font-display text-2xl font-bold">{p.t}</h3>
|
||||
<p className="mt-3 leading-relaxed text-muted-foreground">{p.d}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||||
<div className="container-x">
|
||||
<SectionHeader
|
||||
eyebrow="In the studio"
|
||||
icon={Sparkles}
|
||||
title={
|
||||
<>
|
||||
Ventures <Accent>taking shape.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>A first look at companies being engineered on the KapFoundry engine.</p>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="mt-16 grid gap-6 md:grid-cols-2">
|
||||
{VENTURES.map((v, i) => {
|
||||
const Icon = v.icon;
|
||||
return (
|
||||
<article
|
||||
key={v.name}
|
||||
{...reveal("clip", i * 120)}
|
||||
className="theme-dark group relative flex min-h-[30rem] flex-col justify-end overflow-hidden rounded-[2rem] bg-background p-8 text-foreground md:min-h-[36rem] md:p-10"
|
||||
>
|
||||
<img
|
||||
src={v.img}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`absolute inset-0 h-full w-full object-cover transition-transform duration-[1.6s] group-hover:scale-105 ${EASE_STAGE}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-ink via-ink/70 to-ink/10" />
|
||||
<div className="relative">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary px-3 py-1.5 font-display text-[11px] font-semibold uppercase tracking-wider text-primary-foreground">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{v.vertical}
|
||||
</span>
|
||||
<h3 className="mt-6 font-display text-4xl font-extrabold tracking-tight lg:text-6xl">
|
||||
{v.name}
|
||||
</h3>
|
||||
<p className="mt-4 max-w-[40ch] text-lg leading-relaxed text-muted-foreground">
|
||||
{v.pitch}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/contact"
|
||||
data-cursor="Talk"
|
||||
{...reveal("up", 150)}
|
||||
className="group mt-6 flex flex-col items-start justify-between gap-6 rounded-[2rem] border border-dashed border-foreground/25 p-8 transition-colors duration-500 hover:border-primary hover:bg-primary/5 md:flex-row md:items-center md:p-10"
|
||||
>
|
||||
<span>
|
||||
<span className="block font-display text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Next in the studio
|
||||
</span>
|
||||
<span className="mt-3 block font-display text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Build the next one with us.
|
||||
</span>
|
||||
</span>
|
||||
<span className="grid h-16 w-16 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-transform duration-500 group-hover:rotate-45">
|
||||
<ArrowUpRight className="h-6 w-6" aria-hidden />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** AI: what the foundation does inside each vertical; the row at the centre of the screen is lit. */
|
||||
export function AIAcrossVerticals() {
|
||||
return (
|
||||
<section className="section-y relative bg-ink">
|
||||
<div className="container-x grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-32" {...reveal("stagger")}>
|
||||
<Eyebrow icon={Layers}>AI in every vertical</Eyebrow>
|
||||
<h2 className="mt-6 font-display text-4xl font-bold leading-[1.04] md:text-5xl">
|
||||
One foundation. <Accent>Seven industries.</Accent>
|
||||
</h2>
|
||||
<p className="mt-6 max-w-[48ch] text-lg leading-relaxed text-muted-foreground">
|
||||
The same AI capabilities show up differently in every market we enter. Here is where
|
||||
they do the heavy lifting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="lg:col-span-7">
|
||||
{VERTICALS.map((v) => {
|
||||
const Icon = v.icon;
|
||||
return (
|
||||
<li
|
||||
key={v.n}
|
||||
className="row-focus border-t border-white/10 py-8 last:border-b md:py-10"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl md:h-16 md:w-16">
|
||||
<img
|
||||
src={v.img}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="absolute bottom-1 right-1 grid h-6 w-6 place-items-center rounded-lg bg-ink/80 text-primary backdrop-blur">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
</span>
|
||||
</span>
|
||||
<h3 className="font-display text-2xl font-bold md:text-4xl">{v.name}</h3>
|
||||
<span className="ml-auto font-display text-sm tabular-nums text-primary">
|
||||
{v.n}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-5 flex flex-wrap gap-2 md:pl-20">
|
||||
{v.solution.slice(0, 3).map((s) => (
|
||||
<li
|
||||
key={s}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-foreground"
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="sheet-enter theme-light section-y bg-background text-foreground">
|
||||
<div className="container-x">
|
||||
<SectionHeader
|
||||
title={
|
||||
<>
|
||||
How value <Accent>compounds.</Accent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>A holding company at the centre, many independent companies around it.</p>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="relative mt-20">
|
||||
<ol className="grid gap-12 md:grid-cols-3 md:gap-10" {...reveal("stagger")}>
|
||||
{MODEL.map((m, i) => (
|
||||
<li key={m.t} tabIndex={0} className="group relative">
|
||||
<div className="theme-dark relative aspect-[4/3] overflow-hidden rounded-3xl bg-ink">
|
||||
<DuoPhoto src={m.img} />
|
||||
</div>
|
||||
<span className="relative -mt-7 ml-6 grid h-14 w-14 place-items-center rounded-full bg-primary font-display text-lg font-bold tabular-nums text-primary-foreground shadow-lg shadow-primary/30 ring-4 ring-background">
|
||||
0{i + 1}
|
||||
</span>
|
||||
<h3 className="mt-8 font-display text-3xl font-bold">{m.t}</h3>
|
||||
<p className="mt-3 max-w-[36ch] text-lg leading-relaxed text-muted-foreground">
|
||||
{m.d}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Next page handoff
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const NEXT_BLURB: Record<string, string> = {
|
||||
"/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 (
|
||||
<section
|
||||
aria-label="Next page"
|
||||
className="relative overflow-hidden border-t border-border bg-ink"
|
||||
>
|
||||
<div className="glow glow-drift absolute -right-40 top-0 h-[34rem] w-[34rem] opacity-30" />
|
||||
<a href={next.href} data-cursor="Next" className="group relative block py-24 md:py-36">
|
||||
<div className="container-x">
|
||||
<div className="flex items-center justify-between font-display text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<span data-scramble>Next page</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-primary">{String(k + 1).padStart(2, "0")}</span> /{" "}
|
||||
{String(NAV.length).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="next-grow mt-8 flex flex-col items-start gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<span className="next-fill font-display text-[clamp(2.5rem,15vw,12rem)] md:text-[min(12vw,12rem)] font-extrabold leading-[0.9] tracking-tighter">
|
||||
{next.label}
|
||||
</span>
|
||||
<span
|
||||
className={`grid h-16 w-16 shrink-0 place-items-center rounded-full border border-primary/40 text-primary transition-all duration-700 group-hover:bg-primary group-hover:text-primary-foreground md:h-28 md:w-28 ${EASE_STAGE}`}
|
||||
>
|
||||
<ArrowUpRight
|
||||
className={`h-7 w-7 transition-transform duration-700 group-hover:rotate-45 md:h-10 md:w-10 ${EASE_STAGE}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-10 h-px bg-white/10">
|
||||
<div className="line-draw h-full bg-primary/70" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-md text-lg text-muted-foreground">{NEXT_BLURB[next.href]}</p>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Portfolio: the seven industries as a typographic index with floating photos. */
|
||||
export function PortfolioIndex() {
|
||||
return (
|
||||
<IndustryIndex
|
||||
items={VERTICALS.map((v) => ({ n: v.n, name: v.short, tag: v.tag, img: v.img }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,15 +9,51 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as StudioRouteImport } from './routes/studio'
|
||||
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
|
||||
import { Route as PortfolioRouteImport } from './routes/portfolio'
|
||||
import { Route as InvestorsRouteImport } from './routes/investors'
|
||||
import { Route as FrameworkRouteImport } from './routes/framework'
|
||||
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'
|
||||
|
||||
const StudioRoute = StudioRouteImport.update({
|
||||
id: '/studio',
|
||||
path: '/studio',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({
|
||||
id: '/sitemap.xml',
|
||||
path: '/sitemap.xml',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PortfolioRoute = PortfolioRouteImport.update({
|
||||
id: '/portfolio',
|
||||
path: '/portfolio',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const InvestorsRoute = InvestorsRouteImport.update({
|
||||
id: '/investors',
|
||||
path: '/investors',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FrameworkRoute = FrameworkRouteImport.update({
|
||||
id: '/framework',
|
||||
path: '/framework',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ContactRoute = ContactRouteImport.update({
|
||||
id: '/contact',
|
||||
path: '/contact',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AiRoute = AiRouteImport.update({
|
||||
id: '/ai',
|
||||
path: '/ai',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
@@ -32,35 +68,94 @@ const IndexRoute = IndexRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/ai': typeof AiRoute
|
||||
'/contact': typeof ContactRoute
|
||||
'/framework': typeof FrameworkRoute
|
||||
'/investors': typeof InvestorsRoute
|
||||
'/portfolio': typeof PortfolioRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/studio': typeof StudioRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/ai': typeof AiRoute
|
||||
'/contact': typeof ContactRoute
|
||||
'/framework': typeof FrameworkRoute
|
||||
'/investors': typeof InvestorsRoute
|
||||
'/portfolio': typeof PortfolioRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/studio': typeof StudioRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/ai': typeof AiRoute
|
||||
'/contact': typeof ContactRoute
|
||||
'/framework': typeof FrameworkRoute
|
||||
'/investors': typeof InvestorsRoute
|
||||
'/portfolio': typeof PortfolioRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/studio': typeof StudioRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/about' | '/sitemap.xml'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/ai'
|
||||
| '/contact'
|
||||
| '/framework'
|
||||
| '/investors'
|
||||
| '/portfolio'
|
||||
| '/sitemap.xml'
|
||||
| '/studio'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/about' | '/sitemap.xml'
|
||||
id: '__root__' | '/' | '/about' | '/sitemap.xml'
|
||||
to:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/ai'
|
||||
| '/contact'
|
||||
| '/framework'
|
||||
| '/investors'
|
||||
| '/portfolio'
|
||||
| '/sitemap.xml'
|
||||
| '/studio'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/ai'
|
||||
| '/contact'
|
||||
| '/framework'
|
||||
| '/investors'
|
||||
| '/portfolio'
|
||||
| '/sitemap.xml'
|
||||
| '/studio'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
AiRoute: typeof AiRoute
|
||||
ContactRoute: typeof ContactRoute
|
||||
FrameworkRoute: typeof FrameworkRoute
|
||||
InvestorsRoute: typeof InvestorsRoute
|
||||
PortfolioRoute: typeof PortfolioRoute
|
||||
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
|
||||
StudioRoute: typeof StudioRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/studio': {
|
||||
id: '/studio'
|
||||
path: '/studio'
|
||||
fullPath: '/studio'
|
||||
preLoaderRoute: typeof StudioRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/sitemap.xml': {
|
||||
id: '/sitemap.xml'
|
||||
path: '/sitemap.xml'
|
||||
@@ -68,6 +163,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SitemapDotxmlRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/portfolio': {
|
||||
id: '/portfolio'
|
||||
path: '/portfolio'
|
||||
fullPath: '/portfolio'
|
||||
preLoaderRoute: typeof PortfolioRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/investors': {
|
||||
id: '/investors'
|
||||
path: '/investors'
|
||||
fullPath: '/investors'
|
||||
preLoaderRoute: typeof InvestorsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/framework': {
|
||||
id: '/framework'
|
||||
path: '/framework'
|
||||
fullPath: '/framework'
|
||||
preLoaderRoute: typeof FrameworkRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/contact': {
|
||||
id: '/contact'
|
||||
path: '/contact'
|
||||
fullPath: '/contact'
|
||||
preLoaderRoute: typeof ContactRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/ai': {
|
||||
id: '/ai'
|
||||
path: '/ai'
|
||||
fullPath: '/ai'
|
||||
preLoaderRoute: typeof AiRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
@@ -88,7 +218,13 @@ declare module '@tanstack/react-router' {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
AiRoute: AiRoute,
|
||||
ContactRoute: ContactRoute,
|
||||
FrameworkRoute: FrameworkRoute,
|
||||
InvestorsRoute: InvestorsRoute,
|
||||
PortfolioRoute: PortfolioRoute,
|
||||
SitemapDotxmlRoute: SitemapDotxmlRoute,
|
||||
StudioRoute: StudioRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
import appCss from "../styles.css?url";
|
||||
import logoMark from "../assets/kapfoundry-mark.webp";
|
||||
import { INTRO_SCRIPT, Intro } from "../components/site/motion";
|
||||
import { reportLovableError } from "../lib/lovable-error-reporting";
|
||||
|
||||
function NotFoundComponent() {
|
||||
@@ -112,11 +114,14 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||
|
||||
function RootShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
// The intro script may add a class to <html> before React hydrates.
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<HeadContent />
|
||||
<script dangerouslySetInnerHTML={{ __html: INTRO_SCRIPT }} />
|
||||
</head>
|
||||
<body>
|
||||
<Intro mark={logoMark} />
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AboutHero,
|
||||
Contact,
|
||||
Different,
|
||||
SitePage,
|
||||
Team,
|
||||
VisionMission,
|
||||
} from "@/components/site/sections";
|
||||
import { Different, SitePage, Team, VisionMission } from "@/components/site/sections";
|
||||
import { AboutHero, NextPage } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/about")({
|
||||
head: () => ({
|
||||
@@ -30,7 +24,7 @@ function About() {
|
||||
<Different />
|
||||
<VisionMission />
|
||||
<Team />
|
||||
<Contact />
|
||||
<NextPage page="about" />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/routes/ai.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AISection, SitePage } from "@/components/site/sections";
|
||||
import { AIAcrossVerticals, AIHero, NextPage } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/ai")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "AI | KapFoundry" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Artificial intelligence is the foundation beneath every KapFoundry venture, not a vertical of its own.",
|
||||
},
|
||||
{ property: "og:title", content: "KapFoundry AI" },
|
||||
],
|
||||
}),
|
||||
component: AIPage,
|
||||
});
|
||||
|
||||
function AIPage() {
|
||||
return (
|
||||
<SitePage page="ai">
|
||||
<AIHero />
|
||||
<AISection />
|
||||
<AIAcrossVerticals />
|
||||
<NextPage page="ai" />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
27
src/routes/contact.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Contact, SitePage } from "@/components/site/sections";
|
||||
import { ContactHero, NextPage } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/contact")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Contact | KapFoundry" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Get in touch with KapFoundry to build, partner or invest.",
|
||||
},
|
||||
{ property: "og:title", content: "KapFoundry Contact" },
|
||||
],
|
||||
}),
|
||||
component: ContactPage,
|
||||
});
|
||||
|
||||
function ContactPage() {
|
||||
return (
|
||||
<SitePage page="contact">
|
||||
<ContactHero />
|
||||
<Contact />
|
||||
<NextPage page="contact" />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
29
src/routes/framework.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Framework, SitePage } from "@/components/site/sections";
|
||||
import { FrameworkHero, FrameworkPrinciples, NextPage } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/framework")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Framework | KapFoundry" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"KapFoundry's repeatable framework for taking a venture from problem discovery to scale.",
|
||||
},
|
||||
{ property: "og:title", content: "KapFoundry Framework" },
|
||||
],
|
||||
}),
|
||||
component: FrameworkPage,
|
||||
});
|
||||
|
||||
function FrameworkPage() {
|
||||
return (
|
||||
<SitePage page="framework">
|
||||
<FrameworkHero />
|
||||
<Framework />
|
||||
<FrameworkPrinciples />
|
||||
<NextPage page="framework" />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AISection,
|
||||
Contact,
|
||||
Different,
|
||||
Framework,
|
||||
Hero,
|
||||
Investors,
|
||||
@@ -21,6 +22,7 @@ function Home() {
|
||||
<SitePage page="home">
|
||||
<Hero />
|
||||
<Ticker />
|
||||
<Different />
|
||||
<WhoWeAre />
|
||||
<StudioAdvantage />
|
||||
<Framework />
|
||||
|
||||
29
src/routes/investors.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Investors, SitePage } from "@/components/site/sections";
|
||||
import { InvestorModel, InvestorsHero, NextPage } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/investors")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Investors | KapFoundry" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Invest in a diversified portfolio of AI-first ventures across multiple industries with KapFoundry.",
|
||||
},
|
||||
{ property: "og:title", content: "KapFoundry Investors" },
|
||||
],
|
||||
}),
|
||||
component: InvestorsPage,
|
||||
});
|
||||
|
||||
function InvestorsPage() {
|
||||
return (
|
||||
<SitePage page="investors">
|
||||
<InvestorsHero />
|
||||
<Investors />
|
||||
<InvestorModel />
|
||||
<NextPage page="investors" />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
30
src/routes/portfolio.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Portfolio, SitePage } from "@/components/site/sections";
|
||||
import { NextPage, PortfolioHero, PortfolioIndex, Ventures } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/portfolio")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Portfolio | KapFoundry" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"The industries KapFoundry builds in: logistics, retail, fintech, agritech, foodtech, carbon and healthtech.",
|
||||
},
|
||||
{ property: "og:title", content: "KapFoundry Portfolio" },
|
||||
],
|
||||
}),
|
||||
component: PortfolioPage,
|
||||
});
|
||||
|
||||
function PortfolioPage() {
|
||||
return (
|
||||
<SitePage page="portfolio">
|
||||
<PortfolioHero />
|
||||
<PortfolioIndex />
|
||||
<Portfolio />
|
||||
<Ventures />
|
||||
<NextPage page="portfolio" />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ export const Route = createFileRoute("/sitemap.xml")({
|
||||
GET: async () => {
|
||||
const entries = [
|
||||
{ path: "/", changefreq: "weekly", priority: "1.0" },
|
||||
{ path: "/about", changefreq: "monthly", priority: "0.8" },
|
||||
...["/studio", "/framework", "/portfolio", "/ai", "/investors", "/about", "/contact"].map(
|
||||
(path) => ({ path, changefreq: "monthly", priority: "0.8" }),
|
||||
),
|
||||
];
|
||||
const urls = entries.map(
|
||||
(e) =>
|
||||
|
||||
30
src/routes/studio.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SitePage, StudioAdvantage, WhoWeAre } from "@/components/site/sections";
|
||||
import { NextPage, StudioHero, StudioVsVC } from "@/components/site/pages";
|
||||
|
||||
export const Route = createFileRoute("/studio")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Studio | KapFoundry" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"How KapFoundry works: a venture studio and technology holding company that builds AI-first companies on one shared engine.",
|
||||
},
|
||||
{ property: "og:title", content: "KapFoundry Studio" },
|
||||
],
|
||||
}),
|
||||
component: Studio,
|
||||
});
|
||||
|
||||
function Studio() {
|
||||
return (
|
||||
<SitePage page="studio">
|
||||
<StudioHero />
|
||||
<WhoWeAre />
|
||||
<StudioAdvantage />
|
||||
<StudioVsVC />
|
||||
<NextPage page="studio" />
|
||||
</SitePage>
|
||||
);
|
||||
}
|
||||
528
src/styles.css
@@ -291,7 +291,7 @@
|
||||
}
|
||||
.rise-in {
|
||||
animation: rise-in 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: var(--rise-delay, 0ms);
|
||||
animation-delay: calc(var(--rise-delay, 0ms) + var(--intro-delay, 0ms));
|
||||
}
|
||||
.hero-zoom {
|
||||
animation: hero-zoom 2.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
@@ -537,6 +537,532 @@ html.menu-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Headline split: each word rises through its own mask, one after another (--w is its index).
|
||||
------------------------------------------------------------------------------------------ */
|
||||
@keyframes word-rise {
|
||||
from {
|
||||
transform: translate3d(0, 110%, 0) rotate(4deg);
|
||||
}
|
||||
}
|
||||
.word-mask {
|
||||
display: inline-block;
|
||||
overflow: clip;
|
||||
overflow-clip-margin: 0.12em;
|
||||
vertical-align: top;
|
||||
padding-bottom: 0.08em;
|
||||
margin-bottom: -0.08em;
|
||||
}
|
||||
.word {
|
||||
display: inline-block;
|
||||
transform-origin: 0 100%;
|
||||
animation: word-rise 1.15s var(--ease-out-expo) both;
|
||||
animation-delay: calc(120ms + var(--w, 0) * 65ms + var(--intro-delay, 0ms));
|
||||
}
|
||||
|
||||
/* Smooth scrolling (Lenis) takes over from CSS smooth scrolling while it runs. */
|
||||
html.lenis,
|
||||
html.lenis body {
|
||||
height: auto;
|
||||
}
|
||||
.lenis.lenis-smooth {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.lenis.lenis-stopped {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Page transitions between routes (cross-document view transitions). The leaving page sinks
|
||||
and dims; the arriving page wipes up over it. Browsers without support navigate normally.
|
||||
------------------------------------------------------------------------------------------ */
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
@keyframes vt-leave {
|
||||
to {
|
||||
transform: translate3d(0, -12vh, 0) scale(0.94);
|
||||
opacity: 0.35;
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
@keyframes vt-enter {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0 round 3rem 3rem 0 0);
|
||||
transform: translate3d(0, 10vh, 0);
|
||||
}
|
||||
}
|
||||
::view-transition-old(root) {
|
||||
animation: vt-leave 0.9s cubic-bezier(0.7, 0, 0.2, 1) both;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: vt-enter 1s cubic-bezier(0.7, 0, 0.2, 1) both;
|
||||
}
|
||||
::view-transition-group(site-header) {
|
||||
animation-duration: 0.5s;
|
||||
}
|
||||
.vt-header {
|
||||
view-transition-name: site-header;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
First-visit intro: a counter runs to 100 while the wordmark rises, then the curtain lifts.
|
||||
Only shown when the head script adds html.intro (once per session, never with reduced
|
||||
motion). Everything behind it waits via --intro-delay.
|
||||
------------------------------------------------------------------------------------------ */
|
||||
@property --pct {
|
||||
syntax: "<integer>";
|
||||
initial-value: 0;
|
||||
inherits: false;
|
||||
}
|
||||
@keyframes intro-count {
|
||||
to {
|
||||
--pct: 100;
|
||||
}
|
||||
}
|
||||
@keyframes intro-lift {
|
||||
to {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@keyframes intro-bar {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
.intro-overlay {
|
||||
display: none;
|
||||
}
|
||||
html.intro {
|
||||
--intro-delay: 1.75s;
|
||||
}
|
||||
html.intro .intro-overlay {
|
||||
display: flex;
|
||||
animation: intro-lift 0.95s cubic-bezier(0.76, 0, 0.24, 1) 1.6s forwards;
|
||||
}
|
||||
.intro-count {
|
||||
counter-reset: pct var(--pct);
|
||||
animation: intro-count 1.4s cubic-bezier(0.65, 0, 0.35, 1) 0.1s forwards;
|
||||
}
|
||||
.intro-count::after {
|
||||
content: counter(pct);
|
||||
}
|
||||
.intro-bar {
|
||||
transform-origin: left;
|
||||
animation: intro-bar 1.4s cubic-bezier(0.65, 0, 0.35, 1) 0.1s both;
|
||||
}
|
||||
.intro-overlay .word {
|
||||
animation-delay: calc(80ms + var(--w, 0) * 45ms);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Cursor: a gold ring that trails the pointer, swells over links and shows a label over
|
||||
elements with data-cursor. Fine pointers only; the native cursor stays visible.
|
||||
------------------------------------------------------------------------------------------ */
|
||||
.cursor {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 70;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.cursor[data-on="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
.cursor-ring {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
margin: -1.125rem 0 0 -1.125rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--gold) 70%, transparent);
|
||||
transition:
|
||||
width 0.5s var(--ease-out-expo),
|
||||
height 0.5s var(--ease-out-expo),
|
||||
margin 0.5s var(--ease-out-expo),
|
||||
background-color 0.4s ease,
|
||||
border-color 0.4s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
.cursor[data-state="link"] .cursor-ring {
|
||||
width: 3.75rem;
|
||||
height: 3.75rem;
|
||||
margin: -1.875rem 0 0 -1.875rem;
|
||||
background: color-mix(in oklab, var(--gold) 16%, transparent);
|
||||
border-color: transparent;
|
||||
}
|
||||
.cursor[data-state="label"] .cursor-ring {
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
margin: -2.75rem 0 0 -2.75rem;
|
||||
background: var(--gold);
|
||||
border-color: transparent;
|
||||
}
|
||||
.cursor[data-state="text"] .cursor-ring {
|
||||
opacity: 0;
|
||||
}
|
||||
.cursor-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: oklch(0.12 0.005 60);
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.5s var(--ease-out-expo);
|
||||
}
|
||||
.cursor[data-state="label"] .cursor-label {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Look-closer lens: --lx/--ly (set from JS) place a circle that hides the first sentence and
|
||||
shows the second inside it. The ring marks the edge of the lens.
|
||||
------------------------------------------------------------------------------------------ */
|
||||
.lens-stage {
|
||||
--lx: 30%;
|
||||
--ly: 50%;
|
||||
--lr: 5.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.lens-stage {
|
||||
--lr: 9rem;
|
||||
}
|
||||
}
|
||||
.lens-base {
|
||||
mask-image: radial-gradient(circle var(--lr) at var(--lx) var(--ly), transparent 96%, black 100%);
|
||||
}
|
||||
.lens-top {
|
||||
mask-image: radial-gradient(circle var(--lr) at var(--lx) var(--ly), black 96%, transparent 100%);
|
||||
}
|
||||
.lens-ring {
|
||||
position: absolute;
|
||||
left: var(--lx);
|
||||
top: var(--ly);
|
||||
width: calc(var(--lr) * 2);
|
||||
height: calc(var(--lr) * 2);
|
||||
translate: -50% -50%;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 55%, transparent);
|
||||
background: radial-gradient(
|
||||
closest-side,
|
||||
color-mix(in oklab, var(--primary) 9%, transparent),
|
||||
transparent
|
||||
);
|
||||
box-shadow: 0 0 60px -10px color-mix(in oklab, var(--primary) 45%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lens-base,
|
||||
.lens-top {
|
||||
mask-image: none;
|
||||
}
|
||||
.lens-top {
|
||||
grid-area: auto !important;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.lens-ring {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Film grain over the whole site: an SVG noise tile that jitters a few times a second. */
|
||||
@keyframes grain {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
20% {
|
||||
transform: translate3d(-4%, 3%, 0);
|
||||
}
|
||||
40% {
|
||||
transform: translate3d(3%, -5%, 0);
|
||||
}
|
||||
60% {
|
||||
transform: translate3d(-6%, -2%, 0);
|
||||
}
|
||||
80% {
|
||||
transform: translate3d(5%, 4%, 0);
|
||||
}
|
||||
}
|
||||
.grain {
|
||||
position: fixed;
|
||||
inset: -25%;
|
||||
z-index: 65;
|
||||
pointer-events: none;
|
||||
opacity: 0.075;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1.4 -0.2'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||
animation: grain 0.9s steps(5) infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grain {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Gold duotone photos: every photo reads in the brand's gold until its card is hovered or
|
||||
focused (or is the open filmstrip panel), when it blooms into full colour.
|
||||
------------------------------------------------------------------------------------------ */
|
||||
.duo-img {
|
||||
filter: grayscale(1) contrast(1.15) brightness(0.72);
|
||||
transition:
|
||||
filter 1.1s var(--ease-out-expo),
|
||||
scale 1.6s var(--ease-out-expo);
|
||||
}
|
||||
.duo-tint {
|
||||
background: linear-gradient(
|
||||
150deg,
|
||||
oklch(0.8 0.13 82 / 0.9),
|
||||
oklch(0.55 0.11 60 / 0.9) 55%,
|
||||
oklch(0.3 0.05 50 / 0.9)
|
||||
);
|
||||
mix-blend-mode: color;
|
||||
transition: opacity 1.1s var(--ease-out-expo);
|
||||
}
|
||||
.group:hover .duo-img,
|
||||
.group:focus-visible .duo-img,
|
||||
.group:focus-within .duo-img,
|
||||
.is-open .duo-img {
|
||||
filter: none;
|
||||
scale: 1.06;
|
||||
}
|
||||
.group:hover .duo-tint,
|
||||
.group:focus-visible .duo-tint,
|
||||
.group:focus-within .duo-tint,
|
||||
.is-open .duo-tint {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Filmstrip: panels share the row; the open one takes most of it. */
|
||||
.film-panel {
|
||||
flex: 1 1 0%;
|
||||
transition: flex-grow 0.9s var(--ease-out-expo);
|
||||
}
|
||||
.film-panel.is-open {
|
||||
flex-grow: 5.5;
|
||||
}
|
||||
.film-label {
|
||||
writing-mode: vertical-rl;
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
/* Magnetic buttons: JS sets `translate`; this eases them back when the pointer leaves. */
|
||||
[data-magnetic] {
|
||||
transition: translate 0.6s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------
|
||||
Inner-page hero visuals
|
||||
------------------------------------------------------------------------------------------ */
|
||||
@keyframes spin {
|
||||
to {
|
||||
rotate: 360deg;
|
||||
}
|
||||
}
|
||||
@keyframes spin-back {
|
||||
to {
|
||||
rotate: -360deg;
|
||||
}
|
||||
}
|
||||
.spin-slow {
|
||||
animation: spin 70s linear infinite;
|
||||
}
|
||||
.spin-slower {
|
||||
animation: spin-back 110s linear infinite;
|
||||
}
|
||||
/* Labels on a spinning ring counter-rotate so they always read upright. */
|
||||
.spin-slow .upright {
|
||||
animation: spin-back 70s linear infinite;
|
||||
}
|
||||
.spin-slower .upright {
|
||||
animation: spin 110s linear infinite;
|
||||
}
|
||||
|
||||
/* Photo columns drifting past each other (portfolio hero). Tracks hold two copies. */
|
||||
@keyframes column-up {
|
||||
to {
|
||||
transform: translate3d(0, -50%, 0);
|
||||
}
|
||||
}
|
||||
@keyframes column-down {
|
||||
from {
|
||||
transform: translate3d(0, -50%, 0);
|
||||
}
|
||||
}
|
||||
.column-up {
|
||||
animation: column-up 60s linear infinite;
|
||||
}
|
||||
.column-down {
|
||||
animation: column-down 70s linear infinite;
|
||||
}
|
||||
|
||||
/* Signals travelling along network edges (AI hero). */
|
||||
@keyframes signal {
|
||||
from {
|
||||
stroke-dashoffset: 240;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
.signal {
|
||||
stroke-dasharray: 22 218;
|
||||
animation: signal 3.2s linear infinite;
|
||||
animation-delay: var(--d, 0s);
|
||||
}
|
||||
@keyframes node-pulse {
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: 6;
|
||||
}
|
||||
}
|
||||
.node-pulse {
|
||||
animation: node-pulse 2.8s ease-in-out infinite;
|
||||
animation-delay: var(--d, 0s);
|
||||
}
|
||||
|
||||
/* Ecosystem field (investors hero): dots light up in a slow diagonal wave. */
|
||||
@keyframes dot-wave {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.12;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.dot-wave {
|
||||
animation: dot-wave 4.8s ease-in-out infinite;
|
||||
animation-delay: var(--d, 0s);
|
||||
}
|
||||
|
||||
/* Travelling comet on the framework loop. */
|
||||
.orbit-comet {
|
||||
animation: spin 9s cubic-bezier(0.45, 0, 0.55, 1) infinite;
|
||||
}
|
||||
|
||||
/* Scroll cue line: a highlight slides down the track. */
|
||||
@keyframes cue {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(200%);
|
||||
}
|
||||
}
|
||||
.scroll-cue {
|
||||
animation: cue 2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
||||
}
|
||||
|
||||
/* "Next page" handoff: the giant label fills with gold from the left on hover. */
|
||||
.next-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--primary) 50%,
|
||||
color-mix(in oklab, var(--foreground) 14%, transparent) 50%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 100% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
transition: background-position 1s var(--ease-out-expo);
|
||||
}
|
||||
.group:hover .next-fill,
|
||||
.group:focus-visible .next-fill {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
@keyframes next-grow {
|
||||
from {
|
||||
transform: scale(0.72) translate3d(0, 12%, 0);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
@keyframes line-draw {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
@keyframes row-focus {
|
||||
from {
|
||||
opacity: 0.22;
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.22;
|
||||
}
|
||||
}
|
||||
@keyframes rise-scroll {
|
||||
from {
|
||||
transform: translate3d(0, 18%, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.next-grow {
|
||||
animation: next-grow linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% cover 45%;
|
||||
}
|
||||
.line-draw {
|
||||
transform-origin: left;
|
||||
animation: line-draw linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 30% cover 50%;
|
||||
}
|
||||
/* A list where the row crossing the middle of the screen is lit and the rest recede. */
|
||||
.row-focus {
|
||||
animation: row-focus linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: cover 0% cover 100%;
|
||||
}
|
||||
.rise-scroll {
|
||||
animation: rise-scroll linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 80%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.word,
|
||||
.spin-slow,
|
||||
.spin-slower,
|
||||
.spin-slow .upright,
|
||||
.spin-slower .upright,
|
||||
.column-up,
|
||||
.column-down,
|
||||
.signal,
|
||||
.node-pulse,
|
||||
.dot-wave,
|
||||
.orbit-comet,
|
||||
.scroll-cue {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.menu-overlay,
|
||||
.menu-overlay.is-open,
|
||||
|
||||