Add Three.js page graphics and GSAP site-wide motion system

- Home hero: full-height, with an interactive 3D wireframe of my face
  (MediaPipe landmarks from my-photo.png) rendered as a network graph
  with packets, scan sweeps and an assemble-on-load entry animation
- Inner pages: shared PageHero with a themed scene per page
  (About helix, Skills geodesic sphere, Work infra stack, Blog signal
  waves, Contact globe with connection arcs)
- Shared Three.js stage (framing, GSAP entry, pointer, off-screen
  pausing, reduced motion) with lazily loaded per-scene chunks
- GSAP motion system: data-reveal / data-reveal-stagger / data-parallax
  attributes, SplitText headings, divider draw-in, page exit fade,
  no-JS and reduced-motion fallbacks
- Mobile: graphic stacks above the text

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-03 11:57:05 +01:00
co-authored by Claude Opus 5.5
parent 8c280be7b8
commit d93550ed29
34 changed files with 2562 additions and 143 deletions
+21
View File
@@ -8,6 +8,7 @@
"@moduletrace/bun-mariadb": "^1.1.1", "@moduletrace/bun-mariadb": "^1.1.1",
"@moduletrace/bunext": "^1.2.4", "@moduletrace/bunext": "^1.2.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gsap": "^3.15.0",
"html-to-react": "^1.7.0", "html-to-react": "^1.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
@@ -22,6 +23,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"three": "^0.187.0",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
@@ -32,6 +34,7 @@
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/showdown": "^2.0.6", "@types/showdown": "^2.0.6",
"@types/three": "^0.187.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^5", "typescript": "^5",
@@ -45,6 +48,8 @@
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@dimforge/rapier3d-compat": ["@dimforge/[email protected]", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], "@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
@@ -209,6 +214,8 @@
"@tailwindcss/postcss": ["@tailwindcss/[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="], "@tailwindcss/postcss": ["@tailwindcss/[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
"@tweenjs/tween.js": ["@tweenjs/[email protected]", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
@@ -241,8 +248,14 @@
"@types/showdown": ["@types/[email protected]", "", {}, "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="], "@types/showdown": ["@types/[email protected]", "", {}, "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="],
"@types/stats.js": ["@types/[email protected]", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/[email protected]", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.3", "meshoptimizer": "~1.1.1" } }, "sha512-v+vKNarUtk4n2QRZ4+CN5ygMM0ARUjXjIQl0R/unMUfmStFeVqGbgqarbrpoGTXRZoulSH16ePrVJVxPJEbMUA=="],
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/webxr": ["@types/[email protected]", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], "@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
@@ -367,6 +380,8 @@
"fast-wrap-ansi": ["[email protected]", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], "fast-wrap-ansi": ["[email protected]", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fflate": ["[email protected]", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"find-up": ["[email protected]", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="], "find-up": ["[email protected]", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -379,6 +394,8 @@
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"gsap": ["[email protected]", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-from-html": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], "hast-util-from-html": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
@@ -533,6 +550,8 @@
"meow": ["[email protected]", "", { "dependencies": { "camelcase-keys": "^2.0.0", "loud-rejection": "^1.0.0", "minimist": "^1.1.3", "normalize-package-data": "^2.3.4", "object-assign": "^4.0.1", "read-pkg-up": "^1.0.1", "redent": "^1.0.0", "trim-newlines": "^1.0.0" } }, "sha512-1zRGO8C/2QD8uBxZbwwKbIQHrHKANzVnlK/3Gj7xro+ks4HLmayvETy+BnCV+wm68PE6dYcfgyTDMVG2mjlQwg=="], "meow": ["[email protected]", "", { "dependencies": { "camelcase-keys": "^2.0.0", "loud-rejection": "^1.0.0", "minimist": "^1.1.3", "normalize-package-data": "^2.3.4", "object-assign": "^4.0.1", "read-pkg-up": "^1.0.1", "redent": "^1.0.0", "trim-newlines": "^1.0.0" } }, "sha512-1zRGO8C/2QD8uBxZbwwKbIQHrHKANzVnlK/3Gj7xro+ks4HLmayvETy+BnCV+wm68PE6dYcfgyTDMVG2mjlQwg=="],
"meshoptimizer": ["[email protected]", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], "micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -763,6 +782,8 @@
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"three": ["[email protected]", "", {}, "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA=="],
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trim-newlines": ["[email protected]", "", {}, "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw=="], "trim-newlines": ["[email protected]", "", {}, "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw=="],
+4 -1
View File
@@ -14,6 +14,7 @@
"@moduletrace/bun-mariadb": "^1.1.1", "@moduletrace/bun-mariadb": "^1.1.1",
"@moduletrace/bunext": "^1.2.4", "@moduletrace/bunext": "^1.2.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gsap": "^3.15.0",
"html-to-react": "^1.7.0", "html-to-react": "^1.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
@@ -27,7 +28,8 @@
"rehype-prism-plus": "^2.0.1", "rehype-prism-plus": "^2.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"tailwind-merge": "^2.5.5" "tailwind-merge": "^2.5.5",
"three": "^0.187.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
@@ -38,6 +40,7 @@
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/showdown": "^2.0.6", "@types/showdown": "^2.0.6",
"@types/three": "^0.187.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^5" "typescript": "^5"
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import H1 from "@/src/components/lib/layout/H1";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
import SceneCanvas, { SceneName } from "@/src/components/lib/three/SceneCanvas";
type Props = {
title: React.ReactNode;
subtitle?: React.ReactNode;
scene: SceneName;
/** Small label above the title. */
eyebrow?: React.ReactNode;
/** Extra content under the subtitle (CTAs, contact details...). */
children?: React.ReactNode;
className?: string;
};
/**
* Inner-page hero: animated title block on the left, the page's 3D graphic
* on the right (behind the text, dimmed, on smaller screens).
*/
export default function PageHero({
title,
subtitle,
scene,
eyebrow,
children,
className,
}: Props) {
return (
<Section
className={twMerge(
"relative overflow-hidden justify-start xl:justify-center",
"xl:min-h-[clamp(440px,64svh,760px)]",
"pt-4! xl:pt-20!",
className,
)}
>
<div
className={twMerge(
"relative w-full h-[38svh] min-h-[260px] max-h-[440px] mb-4",
"xl:absolute xl:inset-y-0 xl:right-0 xl:left-auto",
"xl:w-1/2 xl:h-auto xl:max-h-none xl:mb-0",
"xl:[mask-image:linear-gradient(to_right,transparent,black_18%)]",
)}
data-parallax="0.4"
>
<SceneCanvas scene={scene} className="w-full h-full" />
</div>
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw] gap-6">
{eyebrow && (
<Span
className="text-sm uppercase tracking-[0.2em] dark:text-emerald-400/80"
data-reveal="fade"
>
{eyebrow}
</Span>
)}
<H1 className="leading-snug m-0!" data-reveal="text">
{title}
</H1>
{subtitle && (
<Span
className="text-xl"
data-reveal="up"
data-reveal-delay="0.35"
>
{subtitle}
</Span>
)}
{children && (
<div
className="w-full"
data-reveal="up"
data-reveal-delay="0.5"
>
{children}
</div>
)}
</Stack>
</Section>
);
}
+2 -1
View File
@@ -18,6 +18,7 @@ type Props = DetailedHTMLProps<
export default function Divider({ vertical, dashed, ...props }: Props) { export default function Divider({ vertical, dashed, ...props }: Props) {
return ( return (
<div <div
data-reveal={vertical ? undefined : "line"}
{...props} {...props}
className={twMerge( className={twMerge(
"border-slate-200 dark:border-white/10", "border-slate-200 dark:border-white/10",
@@ -27,7 +28,7 @@ export default function Divider({ vertical, dashed, ...props }: Props) {
"twui-divider", "twui-divider",
vertical ? "twui-divider-vertical" : "twui-divider-horizontal", vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
dashed ? "border-dashed" : "border-solid", dashed ? "border-dashed" : "border-solid",
props.className props.className,
)} )}
/> />
); );
+11
View File
@@ -0,0 +1,11 @@
/**
* Inline <head> script: runs before first paint so motion elements can be
* pre-hidden without a flash. Skipped for reduced-motion users. If the motion
* system hasn't booted within 3s (JS error, slow network), everything is
* revealed so content is never stuck invisible.
*/
export const MotionHeadScript = `(function(){try{
if(window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;
var d=document.documentElement;d.classList.add("motion");
setTimeout(function(){if(!window.__MOTION_READY)d.classList.add("motion-fallback");},3000);
}catch(e){}})();`;
+279
View File
@@ -0,0 +1,279 @@
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
/**
* # Site-wide motion system
*
* Declarative, attribute-driven animations powered by GSAP. Markup opts in:
*
* - `data-reveal="up|down|fade|left|right|scale|blur|text|line"`
* Animate the element in when it scrolls into view (default `up`).
* `text` splits the element into masked lines/words that rise in.
* `line` draws the element in horizontally (dividers, rules).
* - `data-reveal-delay="0.2"` extra delay in seconds.
* - `data-reveal-stagger` reveal each direct child in sequence
* (optional value = variant for children).
* - `data-parallax="0.3"` scrubbed drift + fade as the element scrolls
* out of view (value = strength).
*
* Elements are pre-hidden by CSS only once `html.motion` is set by the inline
* head script, so no-JS / reduced-motion visitors always see content.
*/
type Variant =
| "up"
| "down"
| "fade"
| "left"
| "right"
| "scale"
| "blur"
| "text"
| "line";
const FROM: Record<Exclude<Variant, "text">, gsap.TweenVars> = {
up: { y: 28 },
down: { y: -20 },
fade: {},
left: { x: -36 },
right: { x: 36 },
scale: { scale: 0.94 },
blur: { filter: "blur(12px)", y: 12 },
line: { scaleX: 0, transformOrigin: "left center" },
};
const DONE_ATTR = "data-reveal-done";
const EASE = "power3.out";
declare global {
interface Window {
__MOTION_READY?: boolean;
}
}
let registered = false;
let observer: MutationObserver | undefined;
let refreshTimer: number | undefined;
let refCount = 0;
const cleanups: (() => void)[] = [];
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function parseVariant(v: string | null, fallback: Variant = "up"): Variant {
return v && (v in FROM || v === "text") ? (v as Variant) : fallback;
}
function variantOf(el: Element, fallback: Variant = "up"): Variant {
return parseVariant(el.getAttribute("data-reveal"), fallback);
}
function delayOf(el: Element) {
return parseFloat(el.getAttribute("data-reveal-delay") || "0") || 0;
}
function trigger(el: Element) {
return {
trigger: el,
start: "top 90%",
once: true,
} satisfies ScrollTrigger.Vars;
}
function revealText(el: HTMLElement, delay: number) {
const split = SplitText.create(el, {
type: "lines,words",
mask: "lines",
linesClass: "motion-line",
autoSplit: true,
onSplit(self) {
gsap.set(el, { autoAlpha: 1 });
return gsap.from(self.words, {
yPercent: 110,
duration: 1.1,
ease: "expo.out",
stagger: 0.045,
delay,
scrollTrigger: trigger(el),
});
},
});
cleanups.push(() => split.revert());
}
function revealOne(el: HTMLElement, variant: Variant, delay: number) {
if (variant === "text") return revealText(el, delay);
gsap.fromTo(
el,
{ autoAlpha: 0, ...FROM[variant] },
{
autoAlpha: 1,
x: 0,
y: 0,
scale: variant === "line" ? undefined : 1,
scaleX: variant === "line" ? 1 : undefined,
filter: variant === "blur" ? "blur(0px)" : undefined,
duration: variant === "line" ? 1.4 : 1,
ease: variant === "line" ? "expo.inOut" : EASE,
delay,
clearProps: "transform,filter",
scrollTrigger: trigger(el),
},
);
}
function revealGroup(parent: HTMLElement) {
const childVariant = parseVariant(
parent.getAttribute("data-reveal-stagger"),
);
const children = Array.from(parent.children).filter(
(c): c is HTMLElement =>
c instanceof HTMLElement && !c.hasAttribute(DONE_ATTR),
);
if (!children.length) return;
children.forEach((c) => c.setAttribute(DONE_ATTR, ""));
gsap.set(parent, { autoAlpha: 1 });
const from = childVariant === "text" ? FROM.up : FROM[childVariant];
gsap.fromTo(
children,
{ autoAlpha: 0, ...from },
{
autoAlpha: 1,
x: 0,
y: 0,
scale: 1,
filter: childVariant === "blur" ? "blur(0px)" : undefined,
duration: 0.9,
ease: EASE,
stagger: 0.08,
delay: delayOf(parent),
clearProps: "transform,filter",
scrollTrigger: trigger(parent),
},
);
}
function parallax(el: HTMLElement) {
const strength = parseFloat(el.getAttribute("data-parallax") || "0.3");
const section = el.closest("section") ?? el;
gsap.to(el, {
yPercent: -30 * strength,
autoAlpha: 0,
ease: "none",
scrollTrigger: {
trigger: section,
start: "top top",
end: "bottom top",
scrub: true,
},
});
}
/** Process every not-yet-animated motion element inside `root`. */
function scan(root: ParentNode) {
root.querySelectorAll<HTMLElement>("[data-reveal-stagger]").forEach(
revealGroup,
);
root.querySelectorAll<HTMLElement>(
`[data-reveal]:not([${DONE_ATTR}])`,
).forEach((el) => {
el.setAttribute(DONE_ATTR, "");
revealOne(el, variantOf(el), delayOf(el));
});
root.querySelectorAll<HTMLElement>(
`[data-parallax]:not([${DONE_ATTR}])`,
).forEach((el) => {
el.setAttribute(DONE_ATTR, "");
parallax(el);
});
}
/** Fade the page out before following internal links. */
function setupPageExit() {
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = (e.target as Element | null)?.closest?.("a");
if (!a || a.target === "_blank" || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin) return;
if (url.pathname === location.pathname) return; // hash / same page
if (/\.(pdf|png|jpe?g|webp|gif|svg|zip)$/i.test(url.pathname)) return;
e.preventDefault();
gsap.to("main", {
autoAlpha: 0,
y: -12,
duration: 0.28,
ease: "power2.in",
onComplete: () => location.assign(url.href),
});
};
// restore if the page comes back from the back/forward cache
const onShow = (e: PageTransitionEvent) => {
if (e.persisted) gsap.set("main", { clearProps: "all" });
};
document.addEventListener("click", onClick);
window.addEventListener("pageshow", onShow);
cleanups.push(() => {
document.removeEventListener("click", onClick);
window.removeEventListener("pageshow", onShow);
});
}
export function initMotion() {
refCount++;
if (refCount > 1) return destroyMotion;
window.__MOTION_READY = true;
if (prefersReducedMotion()) {
document.documentElement.classList.remove("motion");
return destroyMotion;
}
if (!registered) {
gsap.registerPlugin(ScrollTrigger, SplitText);
registered = true;
}
scan(document);
setupPageExit();
// Pick up content React mounts later (tabs, lists, client fetches).
observer = new MutationObserver((mutations) => {
let found = false;
for (const m of mutations) {
m.addedNodes.forEach((n) => {
if (n instanceof HTMLElement) found = true;
});
}
if (!found) return;
scan(document);
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(() => ScrollTrigger.refresh(), 120);
});
observer.observe(document.body, { childList: true, subtree: true });
// Layout shifts after fonts/images load would misplace trigger points.
const onLoad = () => ScrollTrigger.refresh();
window.addEventListener("load", onLoad);
document.fonts?.ready.then(onLoad).catch(() => {});
cleanups.push(() => window.removeEventListener("load", onLoad));
return destroyMotion;
}
function destroyMotion() {
refCount = Math.max(0, refCount - 1);
if (refCount > 0) return;
observer?.disconnect();
observer = undefined;
window.clearTimeout(refreshTimer);
while (cleanups.length) cleanups.pop()!();
ScrollTrigger.getAll().forEach((t) => t.kill());
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
/** Boot the site-wide GSAP motion system (see ./motion.ts). */
export default function useMotion() {
React.useEffect(() => {
let destroy: (() => void) | undefined;
let cancelled = false;
import("./motion").then(({ initMotion }) => {
if (!cancelled) destroy = initMotion();
});
return () => {
cancelled = true;
destroy?.();
};
}, []);
}
+78
View File
@@ -0,0 +1,78 @@
import React from "react";
import { twMerge } from "tailwind-merge";
/**
* Each scene is its own lazily-loaded chunk; three.js + the stage are a
* shared chunk. Nothing 3D is downloaded until the component mounts.
*/
const SCENES = {
face: () => import("./scenes/face"),
globe: () => import("./scenes/globe"),
sphere: () => import("./scenes/sphere"),
stack: () => import("./scenes/stack"),
helix: () => import("./scenes/helix"),
waves: () => import("./scenes/waves"),
} as const;
export type SceneName = keyof typeof SCENES;
type Props = {
scene: SceneName;
className?: string;
};
export default function SceneCanvas({ scene, className }: Props) {
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
let disposed = false;
let dispose: (() => void) | undefined;
(async () => {
try {
const [{ createStage }, mod] = await Promise.all([
import("./stage"),
SCENES[scene](),
]);
if (disposed) return;
const handle = await createStage({
canvas,
container,
factory: mod.default,
onReady: () => setReady(true),
});
if (disposed) handle.dispose();
else dispose = handle.dispose;
} catch (error) {
// No WebGL / failed load: the page simply stays text-only.
console.warn(`SceneCanvas(${scene}) failed`, error);
}
})();
return () => {
disposed = true;
dispose?.();
};
}, [scene]);
return (
<div
ref={containerRef}
aria-hidden="true"
className={twMerge(
"pointer-events-none select-none",
"transition-opacity duration-700 ease-out",
ready ? "opacity-100" : "opacity-0",
className,
)}
>
<canvas ref={canvasRef} className="block w-full h-full" />
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import * as THREE from "three";
import type { Stage } from "./stage";
/**
* Shared visual language for every scene: additive glowing points and lines
* with per-vertex alpha and a depth fade (things further back are dimmer).
* Geometries must carry an `aAlpha` attribute; use the helpers below.
*/
const depthFade = /* glsl */ `
uniform float uDepthRange;
float depthFade(vec3 p) {
vec4 w = modelMatrix * vec4(p, 1.0);
return mix(0.18, 1.0, smoothstep(-uDepthRange, uDepthRange, w.z));
}
`;
export type GlowOptions = {
color: THREE.Color;
opacity?: number;
/** Points only: base size in px at the reference distance. */
size?: number;
/** World-space half-depth used for the depth fade. */
depthRange?: number;
};
export function glowPoints(stage: Stage, o: GlowOptions) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: o.color },
uOpacity: { value: o.opacity ?? 1 },
uSize: { value: o.size ?? 5 },
uPixelRatio: { value: stage.pixelRatio },
uDepthRange: { value: o.depthRange ?? 1.2 },
},
vertexShader: /* glsl */ `
attribute float aAlpha;
attribute float aSize;
uniform float uSize;
uniform float uPixelRatio;
varying float vAlpha;
${depthFade}
void main() {
vAlpha = aAlpha * depthFade(position);
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = min(uSize * aSize * (5.0 / -mv.z), 18.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uOpacity;
varying float vAlpha;
void main() {
float r = length(gl_PointCoord - 0.5);
float a = pow(smoothstep(0.5, 0.0, r), 1.6);
if (a < 0.01) discard;
gl_FragColor = vec4(mix(uColor, vec3(1.0), a * 0.35), a * vAlpha * uOpacity);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
}
export function glowLines(o: GlowOptions) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: o.color },
uOpacity: { value: o.opacity ?? 1 },
uDepthRange: { value: o.depthRange ?? 1.2 },
},
vertexShader: /* glsl */ `
attribute float aAlpha;
varying float vAlpha;
${depthFade}
void main() {
vAlpha = aAlpha * depthFade(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uOpacity;
varying float vAlpha;
void main() {
gl_FragColor = vec4(uColor, vAlpha * uOpacity);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
}
/** Geometry with position + aAlpha (default 1) + aSize (default 1). */
export function glowGeometry(
positions: Float32Array | number[],
opts: { index?: number[]; dynamic?: boolean } = {},
) {
const pos =
positions instanceof Float32Array
? positions
: new Float32Array(positions);
const count = pos.length / 3;
const g = new THREE.BufferGeometry();
const position = new THREE.BufferAttribute(pos, 3);
const alpha = new THREE.BufferAttribute(new Float32Array(count).fill(1), 1);
const size = new THREE.BufferAttribute(new Float32Array(count).fill(1), 1);
if (opts.dynamic) {
position.setUsage(THREE.DynamicDrawUsage);
alpha.setUsage(THREE.DynamicDrawUsage);
}
g.setAttribute("position", position);
g.setAttribute("aAlpha", alpha);
g.setAttribute("aSize", size);
if (opts.index) g.setIndex(opts.index);
return g;
}
export const clamp01 = (x: number) => Math.min(Math.max(x, 0), 1);
export const smooth = (x: number) => {
const t = clamp01(x);
return t * t * (3 - 2 * t);
};
/** Staggered sub-progress: element `i` of `n` within an overall 0→1. */
export const stagger = (p: number, i: number, n: number, span = 0.5) =>
smooth((p - (i / Math.max(n - 1, 1)) * (1 - span)) / span);
/**
* Packets that travel along polylines with fading trails. Call `update(dt)`
* every frame; add `points` to the scene.
*/
export function createPackets(
stage: Stage,
paths: THREE.Vector3[][],
o: { perPath?: number; speed?: number; trail?: number; size?: number } = {},
) {
const trail = o.trail ?? 6;
const perPath = o.perPath ?? 1;
const packets = paths.flatMap((path, pi) =>
Array.from({ length: perPath }, (_, k) => ({
path: pi,
t: (k / perPath + Math.random() * 0.3) % 1,
speed: (o.speed ?? 0.25) * (0.7 + Math.random() * 0.6),
history: [] as THREE.Vector3[],
})),
);
const count = packets.length * trail;
const geo = glowGeometry(new Float32Array(count * 3), { dynamic: true });
const pos = geo.attributes.position as THREE.BufferAttribute;
const alpha = geo.attributes.aAlpha as THREE.BufferAttribute;
const size = geo.attributes.aSize as THREE.BufferAttribute;
// hidden until the first update (never shown for reduced motion)
(alpha.array as Float32Array).fill(0);
const mat = glowPoints(stage, {
color: stage.colors.accent,
size: o.size ?? 9,
});
const points = new THREE.Points(geo, mat);
points.frustumCulled = false;
// measured every update so paths may move (e.g. bobbing layers)
const lengths = new Float32Array(paths.length);
const measure = () =>
paths.forEach((p, pi) => {
let L = 0;
for (let i = 1; i < p.length; i++) L += p[i]!.distanceTo(p[i - 1]!);
lengths[pi] = L;
});
const tmp = new THREE.Vector3();
function sample(path: THREE.Vector3[], t: number, out: THREE.Vector3) {
if (path.length < 2) return out.copy(path[0] ?? out);
const f = (Number.isFinite(t) ? t : 0) * (path.length - 1);
const i = Math.min(Math.max(Math.floor(f), 0), path.length - 2);
return out.lerpVectors(path[i]!, path[i + 1]!, f - i);
}
return {
points,
material: mat,
/**
* @param visibility global 0→1 multiplier
* @param pathVisibility optional per-packet gate (path index, head t)
*/
update(
dt: number,
visibility = 1,
pathVisibility?: (path: number, t: number) => number,
) {
measure();
let idx = 0;
for (const p of packets) {
const path = paths[p.path]!;
p.t =
(p.t + (dt * p.speed) / Math.max(lengths[p.path]!, 0.01)) %
1;
if (!Number.isFinite(p.t)) p.t = 0;
sample(path, p.t, tmp);
if (!p.history.length) {
for (let k = 0; k < trail; k++) p.history.push(tmp.clone());
}
for (let k = trail - 1; k > 0; k--) {
p.history[k]!.copy(p.history[k - 1]!);
}
p.history[0]!.copy(tmp);
// fade near path ends so wrap-around isn't visible
const edge =
smooth(Math.min(p.t, 1 - p.t) * 12) *
(pathVisibility ? pathVisibility(p.path, p.t) : 1);
for (let k = 0; k < trail; k++) {
const h = p.history[k]!;
pos.setXYZ(idx, h.x, h.y, h.z);
const f = 1 - k / trail;
alpha.setX(idx, f * f * edge * visibility);
size.setX(idx, 0.4 + f * 0.6);
idx++;
}
}
pos.needsUpdate = true;
alpha.needsUpdate = true;
size.needsUpdate = true;
},
};
}
+435
View File
@@ -0,0 +1,435 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import { smooth } from "../materials";
/**
* Home hero: my face as a network graph.
*
* Mesh data generated offline from /public/images/my-photo.png with
* MediaPipe Face Landmarker: `v` = flat xyz (normalized ~[-1, 1], y up,
* +z toward camera), `e` = tessellation edge pairs, `c` = contour edge pairs
* (eyes, brows, lips, face oval).
*
* Entry: nodes fly in from a scattered cloud, edges connect once both ends
* have arrived, a bright scanline sweeps the build, then packets start
* routing across the network. Scrolling away blows it apart again.
*/
type FaceMeshData = { v: number[]; e: number[]; c: number[] };
const PACKET_COUNT = 70;
const PACKET_TRAIL = 5;
const DUST_COUNT = 260;
const DEPTH_EXAGGERATION = 1.35;
const sharedVertex = /* glsl */ `
attribute vec3 aRandom;
uniform float uTime;
uniform float uDissolve;
uniform float uEntry;
uniform float uScanY;
uniform vec3 uWaveOrigin;
uniform float uWaveRadius;
varying float vGlow;
varying float vFacing;
varying float vArrive;
// per-node arrival: staggered by a random delay, eased
float arrival(float span) {
float local = clamp((uEntry - aRandom.x * (1.0 - span)) / span, 0.0, 1.0);
return local * local * (3.0 - 2.0 * local);
}
vec3 displaced(vec3 p) {
p += (aRandom - 0.5) * 0.012 * sin(uTime * 1.3 + aRandom.x * 40.0);
vec3 dir = normalize(aRandom - 0.5 + vec3(0.0, 0.0, 0.35));
float scatter = max(smoothstep(0.0, 1.0, uDissolve), 1.0 - arrival(0.45));
p += dir * scatter * (1.4 + aRandom.y * 2.6);
return p;
}
float glowAt(vec3 p) {
float scan = exp(-pow((p.y - uScanY) * 9.0, 2.0)) * 0.9;
float ring = abs(distance(p, uWaveOrigin) - uWaveRadius);
float wave = exp(-pow(ring * 14.0, 2.0)) * smoothstep(2.4, 0.4, uWaveRadius);
return scan + wave * 1.4;
}
float facing(vec3 p) {
vec3 n = normalize((modelMatrix * vec4(p * vec3(1.0, 1.0, 1.6), 0.0)).xyz);
return clamp(n.z * 0.5 + 0.55, 0.0, 1.0);
}
`;
const lineVertex = /* glsl */ `
${sharedVertex}
void main() {
vec3 p = displaced(position);
vGlow = glowAt(position);
vFacing = facing(position);
// edges connect after nodes land
vArrive = clamp((uEntry - 0.35 - aRandom.x * 0.4) / 0.25, 0.0, 1.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
`;
const lineFragment = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uAccent;
uniform float uOpacity;
uniform float uDissolve;
varying float vGlow;
varying float vFacing;
varying float vArrive;
void main() {
vec3 col = mix(uColor, uAccent, clamp(vGlow, 0.0, 1.0) * 0.8);
float a = uOpacity * (0.25 + vFacing * 0.75) + vGlow * 0.35;
a *= 1.0 - smoothstep(0.0, 0.55, uDissolve);
a *= vArrive;
gl_FragColor = vec4(col, a);
}
`;
const nodeVertex = /* glsl */ `
${sharedVertex}
uniform float uPixelRatio;
uniform float uSize;
void main() {
vec3 p = displaced(position);
vGlow = glowAt(position);
vFacing = facing(position);
vArrive = arrival(0.45);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
// nodes flare while in flight
float flight = 1.0 + (1.0 - vArrive) * 0.8;
float size = uSize * (0.6 + aRandom.z * 0.8) * (1.0 + vGlow * 1.2) * flight;
gl_PointSize = min(size * (4.0 / -mv.z), 14.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`;
const nodeFragment = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uAccent;
uniform float uDissolve;
varying float vGlow;
varying float vFacing;
varying float vArrive;
void main() {
float r = length(gl_PointCoord - 0.5);
float core = smoothstep(0.5, 0.0, r);
float a = core * core * (0.35 + vFacing * 0.65 + vGlow);
a *= 1.0 - smoothstep(0.6, 1.0, uDissolve);
a *= smoothstep(0.0, 0.25, vArrive + 0.08);
vec3 col = mix(uColor, uAccent, clamp(vGlow + (1.0 - vArrive) * 0.7, 0.0, 1.0));
gl_FragColor = vec4(col, a);
}
`;
const packetVertex = /* glsl */ `
attribute float aAlpha;
uniform float uPixelRatio;
uniform float uSize;
varying float vAlpha;
void main() {
vAlpha = aAlpha;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = min(uSize * (0.35 + aAlpha * 0.65) * (4.0 / -mv.z), 16.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`;
const packetFragment = /* glsl */ `
uniform vec3 uAccent;
uniform float uDissolve;
uniform float uVisible;
varying float vAlpha;
void main() {
float r = length(gl_PointCoord - 0.5);
float core = smoothstep(0.5, 0.0, r);
float a = pow(core, 1.6) * vAlpha * uVisible * (1.0 - smoothstep(0.0, 0.4, uDissolve));
gl_FragColor = vec4(mix(uAccent, vec3(1.0), core * 0.5), a);
}
`;
const dustVertex = /* glsl */ `
attribute float aSeed;
uniform float uTime;
uniform float uPixelRatio;
uniform float uEntry;
varying float vAlpha;
void main() {
vec3 p = position;
p.y += mod(uTime * 0.03 * (0.5 + aSeed) + aSeed * 10.0, 4.0) - 2.0;
p.x += sin(uTime * 0.2 + aSeed * 20.0) * 0.05;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
vAlpha = (0.15 + aSeed * 0.35) * (1.0 - smoothstep(1.6, 2.0, abs(p.y))) * uEntry;
gl_PointSize = (1.0 + aSeed * 2.0) * uPixelRatio * (4.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`;
const dustFragment = /* glsl */ `
uniform vec3 uColor;
varying float vAlpha;
void main() {
float r = length(gl_PointCoord - 0.5);
gl_FragColor = vec4(uColor, smoothstep(0.5, 0.0, r) * vAlpha);
}
`;
const face: SceneFactory = async (stage) => {
const data: FaceMeshData = await fetch("/data/face-mesh.json").then((r) =>
r.json(),
);
const { scene, reducedMotion } = stage;
const vertexCount = data.v.length / 3;
const basePositions = new Float32Array(data.v);
for (let i = 2; i < basePositions.length; i += 3) {
basePositions[i]! *= DEPTH_EXAGGERATION;
}
const randoms = new Float32Array(vertexCount * 3);
for (let i = 0; i < randoms.length; i++) randoms[i] = Math.random();
const u = {
uTime: { value: 0 },
uDissolve: { value: 0 },
uEntry: { value: stage.entry },
uScanY: { value: -3 },
uWaveOrigin: { value: new THREE.Vector3() },
uWaveRadius: { value: 10 },
uColor: { value: stage.colors.line },
uAccent: { value: stage.colors.accent },
uPixelRatio: { value: stage.pixelRatio },
};
const makeGeometry = (index?: number[]) => {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(basePositions, 3));
g.setAttribute("aRandom", new THREE.BufferAttribute(randoms, 3));
if (index) g.setIndex(index);
return g;
};
const additive = {
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
} as const;
const group = new THREE.Group();
scene.add(group);
group.add(
new THREE.LineSegments(
makeGeometry(data.e),
new THREE.ShaderMaterial({
vertexShader: lineVertex,
fragmentShader: lineFragment,
uniforms: { ...u, uOpacity: { value: 0.22 } },
...additive,
}),
),
new THREE.LineSegments(
makeGeometry(data.c),
new THREE.ShaderMaterial({
vertexShader: lineVertex,
fragmentShader: lineFragment,
uniforms: { ...u, uOpacity: { value: 0.55 } },
...additive,
}),
),
new THREE.Points(
makeGeometry(),
new THREE.ShaderMaterial({
vertexShader: nodeVertex,
fragmentShader: nodeFragment,
uniforms: { ...u, uSize: { value: 5.5 } },
...additive,
}),
),
);
// ---- packets: random walks over the mesh graph ----
const adjacency: number[][] = Array.from({ length: vertexCount }, () => []);
for (let i = 0; i < data.e.length; i += 2) {
adjacency[data.e[i]!]!.push(data.e[i + 1]!);
adjacency[data.e[i + 1]!]!.push(data.e[i]!);
}
const vertexPos = (i: number, out: THREE.Vector3) =>
out.set(
basePositions[i * 3]!,
basePositions[i * 3 + 1]!,
basePositions[i * 3 + 2]!,
);
const randomVertex = () => Math.floor(Math.random() * vertexCount);
type Packet = {
from: number;
to: number;
t: number;
speed: number;
history: THREE.Vector3[];
};
const packets: Packet[] = Array.from({ length: PACKET_COUNT }, () => {
const from = randomVertex();
const start = vertexPos(from, new THREE.Vector3());
return {
from,
to: adjacency[from]![0] ?? from,
t: Math.random(),
speed: 2.2 + Math.random() * 2.5,
history: Array.from({ length: PACKET_TRAIL }, () => start.clone()),
};
});
const nextHop = (p: Packet) => {
const options = adjacency[p.to]!.filter((n) => n !== p.from);
const pick = options.length
? options[Math.floor(Math.random() * options.length)]!
: p.from;
p.from = p.to;
p.to = pick;
p.t = 0;
};
const packetPositions = new Float32Array(PACKET_COUNT * PACKET_TRAIL * 3);
const packetAlphas = new Float32Array(PACKET_COUNT * PACKET_TRAIL);
const packetGeo = new THREE.BufferGeometry();
packetGeo.setAttribute(
"position",
new THREE.BufferAttribute(packetPositions, 3),
);
packetGeo.setAttribute(
"aAlpha",
new THREE.BufferAttribute(packetAlphas, 1),
);
const packetVisible = { value: 0 };
const packetPoints = new THREE.Points(
packetGeo,
new THREE.ShaderMaterial({
vertexShader: packetVertex,
fragmentShader: packetFragment,
uniforms: {
uAccent: u.uAccent,
uDissolve: u.uDissolve,
uPixelRatio: u.uPixelRatio,
uSize: { value: 9 },
uVisible: packetVisible,
},
...additive,
}),
);
packetPoints.frustumCulled = false;
group.add(packetPoints);
// ---- ambient dust ----
const dustPositions = new Float32Array(DUST_COUNT * 3);
const dustSeeds = new Float32Array(DUST_COUNT);
for (let i = 0; i < DUST_COUNT; i++) {
dustPositions[i * 3] = (Math.random() - 0.5) * 5;
dustPositions[i * 3 + 1] = (Math.random() - 0.5) * 4;
dustPositions[i * 3 + 2] = (Math.random() - 0.5) * 3 - 0.5;
dustSeeds[i] = Math.random();
}
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute(
"position",
new THREE.BufferAttribute(dustPositions, 3),
);
dustGeo.setAttribute("aSeed", new THREE.BufferAttribute(dustSeeds, 1));
const dust = new THREE.Points(
dustGeo,
new THREE.ShaderMaterial({
vertexShader: dustVertex,
fragmentShader: dustFragment,
uniforms: {
uTime: u.uTime,
uPixelRatio: u.uPixelRatio,
uColor: u.uColor,
uEntry: u.uEntry,
},
...additive,
}),
);
dust.frustumCulled = false;
scene.add(dust);
const tmpA = new THREE.Vector3();
const tmpB = new THREE.Vector3();
const current = new THREE.Vector3();
let waveStart = -10;
let nextWave = 4;
function updatePackets(dt: number) {
let idx = 0;
for (const p of packets) {
p.t += dt * p.speed;
while (p.t >= 1) nextHop(p);
current.lerpVectors(
vertexPos(p.from, tmpA),
vertexPos(p.to, tmpB),
p.t,
);
for (let k = PACKET_TRAIL - 1; k > 0; k--) {
p.history[k]!.copy(p.history[k - 1]!);
}
p.history[0]!.copy(current);
for (let k = 0; k < PACKET_TRAIL; k++) {
const h = p.history[k]!;
packetPositions.set([h.x, h.y, h.z], idx * 3);
packetAlphas[idx] = Math.pow(1 - k / PACKET_TRAIL, 1.8);
idx++;
}
}
packetGeo.attributes.position!.needsUpdate = true;
packetGeo.attributes.aAlpha!.needsUpdate = true;
}
return {
frame: { width: 2.3, height: 2.8 },
update(t, dt) {
const entry = stage.entry;
u.uTime.value = t;
u.uEntry.value = entry;
u.uDissolve.value = stage.scroll;
if (reducedMotion) {
packetPoints.visible = false;
group.rotation.set(0.04, -0.28, 0);
return;
}
// one bright sweep top → bottom during the build, then the
// regular slow scan cycle
u.uScanY.value =
entry < 1 ? 1.4 - entry * 3.2 : 1.6 - (((t - 3) * 0.55) % 3.6);
if (entry >= 1 && t > nextWave) {
waveStart = t;
nextWave = t + 4 + Math.random() * 3;
vertexPos(randomVertex(), u.uWaveOrigin.value);
}
u.uWaveRadius.value = (t - waveStart) * 1.1;
packetVisible.value = smooth((entry - 0.75) / 0.25);
updatePackets(dt);
// camera-ish dolly + spin settling into place
const settle = 1 - entry;
const targetY =
stage.pointer.x * 0.7 +
Math.sin(t * 0.25) * 0.15 -
settle * 0.9;
const targetX = stage.pointer.y * 0.3 + Math.sin(t * 0.18) * 0.05;
const k = Math.min(dt * 3, 1);
group.rotation.y += (targetY - group.rotation.y) * k;
group.rotation.x += (targetX - group.rotation.x) * k;
group.position.z = -settle * 1.2;
group.scale.setScalar(1 + Math.sin(t * 0.9) * 0.008);
dust.rotation.y = group.rotation.y * 0.3;
},
};
};
export default face;
+260
View File
@@ -0,0 +1,260 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
} from "../materials";
/**
* Contact: a dotted wireframe globe with connection arcs reaching out from
* home base to cities around the world — "I work with teams anywhere".
*/
const R = 1;
/** Home base. */
const ORIGIN = { lat: 6.52, lon: 3.38 }; // Lagos
const DESTINATIONS = [
{ lat: 51.5, lon: -0.13 }, // London
{ lat: 40.71, lon: -74.0 }, // New York
{ lat: 37.77, lon: -122.42 }, // San Francisco
{ lat: 43.65, lon: -79.38 }, // Toronto
{ lat: 52.52, lon: 13.4 }, // Berlin
{ lat: 25.2, lon: 55.27 }, // Dubai
{ lat: 1.35, lon: 103.82 }, // Singapore
{ lat: -26.2, lon: 28.05 }, // Johannesburg
{ lat: -23.55, lon: -46.63 }, // São Paulo
{ lat: 59.33, lon: 18.07 }, // Stockholm
];
const DOTS = 1600;
const ARC_SEGMENTS = 64;
function latLon(lat: number, lon: number, r = R) {
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon + 180);
return new THREE.Vector3(
-r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta),
);
}
function arcPoints(a: THREE.Vector3, b: THREE.Vector3) {
const angle = a.angleTo(b);
const lift = 0.06 + (angle / Math.PI) * 0.28;
const pts: THREE.Vector3[] = [];
const qa = new THREE.Quaternion();
const axis = new THREE.Vector3().crossVectors(a, b).normalize();
for (let i = 0; i <= ARC_SEGMENTS; i++) {
const t = i / ARC_SEGMENTS;
qa.setFromAxisAngle(axis, angle * t);
const p = a.clone().applyQuaternion(qa);
p.multiplyScalar(1 + lift * Math.sin(Math.PI * t));
pts.push(p);
}
return pts;
}
const globe: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
const spin = new THREE.Group();
root.add(spin);
scene.add(root);
const origin = latLon(ORIGIN.lat, ORIGIN.lon);
// face home base toward the camera, tilted to show a bit more north
const baseY = Math.atan2(-origin.x, origin.z);
root.rotation.x = 0.35;
// ---- dots: fibonacci lattice ----
const dotPos = new Float32Array(DOTS * 3);
const dotAngle = new Float32Array(DOTS);
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < DOTS; i++) {
const y = 1 - (i / (DOTS - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const th = golden * i;
const p = new THREE.Vector3(Math.cos(th) * r, y, Math.sin(th) * r);
dotPos.set([p.x, p.y, p.z], i * 3);
dotAngle[i] = p.angleTo(origin);
}
const dotGeo = glowGeometry(dotPos, { dynamic: true });
const dotAlpha = dotGeo.attributes.aAlpha as THREE.BufferAttribute;
const dots = new THREE.Points(
dotGeo,
glowPoints(stage, { color: colors.line, size: 3.2, opacity: 0.8 }),
);
spin.add(dots);
// ---- graticule ----
const grat: number[] = [];
const ring = (fn: (a: number) => THREE.Vector3) => {
const N = 96;
for (let i = 0; i < N; i++) {
const a = fn((i / N) * Math.PI * 2);
const b = fn(((i + 1) / N) * Math.PI * 2);
grat.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
};
for (let lat = -60; lat <= 60; lat += 30) {
ring((a) => latLon(lat, THREE.MathUtils.radToDeg(a)));
}
for (let lon = 0; lon < 180; lon += 30) {
ring((a) => {
const p = new THREE.Vector3(Math.cos(a), Math.sin(a), 0);
return p.applyAxisAngle(
new THREE.Vector3(0, 1, 0),
THREE.MathUtils.degToRad(lon),
);
});
}
const gratMat = glowLines({ color: colors.line, opacity: 0.1 });
spin.add(new THREE.LineSegments(glowGeometry(grat), gratMat));
// ---- arcs ----
const arcs = DESTINATIONS.map((d, i) => {
const pts = arcPoints(origin, latLon(d.lat, d.lon));
const pos: number[] = [];
const ts: number[] = [];
for (let k = 0; k < pts.length - 1; k++) {
const a = pts[k]!;
const b = pts[k + 1]!;
pos.push(a.x, a.y, a.z, b.x, b.y, b.z);
ts.push(k / ARC_SEGMENTS, (k + 1) / ARC_SEGMENTS);
}
const geo = glowGeometry(pos);
geo.setAttribute(
"aT",
new THREE.BufferAttribute(new Float32Array(ts), 1),
);
const mat = glowLines({ color: colors.accent, opacity: 0.9 });
mat.uniforms.uHead = { value: 0 };
mat.uniforms.uTail = { value: 0 };
mat.vertexShader = mat.vertexShader
.replace(
"attribute float aAlpha;",
"attribute float aAlpha;\nattribute float aT;\nvarying float vT;",
)
.replace("vAlpha = aAlpha", "vT = aT;\nvAlpha = aAlpha");
mat.fragmentShader = mat.fragmentShader
.replace(
"varying float vAlpha;",
"varying float vAlpha;\nvarying float vT;\nuniform float uHead;\nuniform float uTail;",
)
.replace(
"gl_FragColor = vec4(uColor, vAlpha * uOpacity);",
`float drawn = step(uTail, vT) * step(vT, uHead);
float head = exp(-pow((vT - uHead) * 18.0, 2.0));
gl_FragColor = vec4(mix(uColor, vec3(1.0), head * 0.6), vAlpha * uOpacity * drawn * (0.35 + head));`,
);
spin.add(new THREE.LineSegments(geo, mat));
// cycle: draw (1.6s) → hold → retract → wait
return {
pts,
mat,
offset: 0.9 + i * 0.45,
period: 7 + Math.random() * 3,
};
});
// ---- landing pins + home base pulse ----
const pinPos: number[] = [];
DESTINATIONS.forEach((d) => {
const p = latLon(d.lat, d.lon, R * 1.005);
pinPos.push(p.x, p.y, p.z);
});
const pins = new THREE.Points(
glowGeometry(pinPos),
glowPoints(stage, { color: colors.accent, size: 6, opacity: 0.7 }),
);
spin.add(pins);
const home = new THREE.Points(
glowGeometry([origin.x * 1.01, origin.y * 1.01, origin.z * 1.01]),
glowPoints(stage, { color: colors.accent, size: 16 }),
);
spin.add(home);
const pulseRing: number[] = [];
for (let i = 0; i < 64; i++) {
const a = (i / 64) * Math.PI * 2;
const b = ((i + 1) / 64) * Math.PI * 2;
pulseRing.push(
Math.cos(a),
Math.sin(a),
0,
Math.cos(b),
Math.sin(b),
0,
);
}
const pulseMat = glowLines({ color: colors.accent, opacity: 0.8 });
const pulse = new THREE.LineSegments(glowGeometry(pulseRing), pulseMat);
pulse.position.copy(origin).multiplyScalar(1.01);
pulse.lookAt(origin.clone().multiplyScalar(2));
pulse.scale.setScalar(0.001);
pulse.visible = !stage.reducedMotion;
spin.add(pulse);
// ---- packets along arcs ----
const packets = createPackets(
stage,
arcs.map((a) => a.pts),
{ perPath: 1, speed: 0.7, trail: 8, size: 8 },
);
spin.add(packets.points);
// static frame for reduced motion: everything drawn
if (stage.reducedMotion) {
arcs.forEach((a) => (a.mat.uniforms.uHead!.value = 1));
}
return {
frame: { width: 2.9, height: 2.9 },
update(t, dt) {
const e = stage.entry;
// dots switch on in a wave spreading out from home base
const front = e * Math.PI * 1.15;
for (let i = 0; i < DOTS; i++) {
dotAlpha.setX(i, smooth((front - dotAngle[i]!) / 0.5));
}
dotAlpha.needsUpdate = true;
gratMat.uniforms.uOpacity!.value = 0.1 * smooth((e - 0.3) / 0.5);
root.scale.setScalar(0.82 + 0.18 * smooth(e));
spin.rotation.y =
baseY +
(1 - smooth(e)) * 1.4 +
Math.sin(t * 0.12) * 0.45 +
stage.pointer.x * 0.35;
root.rotation.x = 0.35 + stage.pointer.y * 0.15;
if (stage.reducedMotion) return;
const armed = e > 0.55;
arcs.forEach((a) => {
const local = armed ? (t - a.offset) % a.period : -1;
const head = local < 0 ? 0 : smooth(local / 1.6);
const tail =
local < 0 ? 0 : smooth((local - a.period + 2.2) / 1.6);
a.mat.uniforms.uHead!.value = head;
a.mat.uniforms.uTail!.value = tail;
});
const pt = (t % 2.4) / 2.4;
pulse.scale.setScalar(0.02 + pt * 0.16);
pulseMat.uniforms.uOpacity!.value = (1 - pt) * 0.9 * e;
packets.update(dt, smooth((e - 0.8) / 0.2), (i, pt) => {
const u = arcs[i]!.mat.uniforms;
return pt > u.uTail!.value && pt < u.uHead!.value ? 1 : 0;
});
},
};
};
export default globe;
+190
View File
@@ -0,0 +1,190 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
} from "../materials";
/**
* About: a career double helix — two strands (build & operate) bound by
* rungs, with highlighted milestone rungs. Entry: builds bottom → top.
*/
const STEPS = 90;
const HEIGHT = 3;
const RADIUS = 0.55;
const TURNS = 2.25;
const RUNG_EVERY = 3;
const MILESTONES = [6, 13, 19, 24]; // rung indices (career milestones)
const helix: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
root.rotation.z = 0.18;
scene.add(root);
const spin = new THREE.Group();
root.add(spin);
const strandA: THREE.Vector3[] = [];
const strandB: THREE.Vector3[] = [];
for (let i = 0; i <= STEPS; i++) {
const t = i / STEPS;
const a = t * TURNS * Math.PI * 2;
const y = -HEIGHT / 2 + t * HEIGHT;
strandA.push(
new THREE.Vector3(Math.cos(a) * RADIUS, y, Math.sin(a) * RADIUS),
);
strandB.push(
new THREE.Vector3(
Math.cos(a + Math.PI) * RADIUS,
y,
Math.sin(a + Math.PI) * RADIUS,
),
);
}
// strands
const strandPos: number[] = [];
const strandY: number[] = [];
[strandA, strandB].forEach((s) => {
for (let i = 0; i < s.length - 1; i++) {
strandPos.push(
s[i]!.x,
s[i]!.y,
s[i]!.z,
s[i + 1]!.x,
s[i + 1]!.y,
s[i + 1]!.z,
);
strandY.push(s[i]!.y, s[i + 1]!.y);
}
});
const strandGeo = glowGeometry(strandPos, { dynamic: true });
spin.add(
new THREE.LineSegments(
strandGeo,
glowLines({ color: colors.line, opacity: 0.7 }),
),
);
// rungs
const rungPos: number[] = [];
const rungY: number[] = [];
const rungMid: number[] = [];
const milestonePos: number[] = [];
let rung = 0;
for (let i = 0; i <= STEPS; i += RUNG_EVERY, rung++) {
const a = strandA[i]!;
const b = strandB[i]!;
rungPos.push(a.x, a.y, a.z, b.x, b.y, b.z);
rungY.push(a.y, b.y);
// 3 "base pair" dots along each rung
for (const f of [0.3, 0.5, 0.7]) {
const m = a.clone().lerp(b, f);
rungMid.push(m.x, m.y, m.z);
}
if (MILESTONES.includes(rung)) {
milestonePos.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
}
const rungGeo = glowGeometry(rungPos, { dynamic: true });
spin.add(
new THREE.LineSegments(
rungGeo,
glowLines({ color: colors.line, opacity: 0.18 }),
),
);
const midGeo = glowGeometry(rungMid, { dynamic: true });
spin.add(
new THREE.Points(
midGeo,
glowPoints(stage, { color: colors.line, size: 2.5, opacity: 0.6 }),
),
);
// strand nodes
const nodeGeo = glowGeometry(
[...strandA, ...strandB].flatMap((v) => [v.x, v.y, v.z]),
{ dynamic: true },
);
spin.add(
new THREE.Points(
nodeGeo,
glowPoints(stage, { color: colors.line, size: 4 }),
),
);
// milestones
const milestoneGeo = glowGeometry(milestonePos, { dynamic: true });
const milestoneMat = glowPoints(stage, { color: colors.accent, size: 15 });
spin.add(new THREE.Points(milestoneGeo, milestoneMat));
const milestoneLinks = glowGeometry(milestonePos, { dynamic: true });
spin.add(
new THREE.LineSegments(
milestoneLinks,
glowLines({ color: colors.accent, opacity: 0.8 }),
),
);
// packets climbing the strands
const packets = createPackets(stage, [strandA, strandB], {
perPath: 3,
speed: 0.6,
trail: 10,
size: 9,
});
spin.add(packets.points);
// per-geometry y lookup for the bottom-up build
const yOf = (g: THREE.BufferGeometry) => {
const p = g.attributes.position as THREE.BufferAttribute;
return Float32Array.from({ length: p.count }, (_, i) => p.getY(i));
};
const builds = [
strandGeo,
rungGeo,
midGeo,
nodeGeo,
milestoneGeo,
milestoneLinks,
].map((g, k) => ({
g,
y: yOf(g),
lag: k === 1 || k === 2 ? 0.25 : k >= 4 ? 0.4 : 0,
}));
return {
frame: { width: 2.4, height: 3.9 },
update(t, dt) {
const e = stage.entry;
if (e < 1 || stage.reducedMotion) {
for (const b of builds) {
const a = b.g.attributes.aAlpha as THREE.BufferAttribute;
const front = -HEIGHT / 2 + (e * 1.3 - b.lag) * HEIGHT;
for (let i = 0; i < b.y.length; i++) {
a.setX(i, smooth((front - b.y[i]!) / 0.35));
}
a.needsUpdate = true;
}
}
if (stage.reducedMotion) {
spin.rotation.y = 0.6;
return;
}
const pulse = 0.75 + Math.sin(t * 2.2) * 0.25;
milestoneMat.uniforms.uOpacity!.value = pulse;
packets.update(dt, smooth((e - 0.8) / 0.2));
spin.rotation.y = t * 0.25 + (1 - smooth(e)) * -2;
root.rotation.x = stage.pointer.y * 0.2;
root.rotation.y = stage.pointer.x * 0.4;
},
};
};
export default helix;
+206
View File
@@ -0,0 +1,206 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
} from "../materials";
/**
* Skills: a geodesic "tech stack" sphere. Every node is a tool, every edge
* an integration; a glowing core spins inside and satellites orbit on
* tilted rings. Entry: the lattice grows out from the core.
*/
function uniqueVerticesAndEdges(geo: THREE.BufferGeometry) {
const pos = geo.attributes.position as THREE.BufferAttribute;
const keyOf = (i: number) =>
`${pos.getX(i).toFixed(4)},${pos.getY(i).toFixed(4)},${pos.getZ(i).toFixed(4)}`;
const map = new Map<string, number>();
const verts: THREE.Vector3[] = [];
const remap: number[] = [];
for (let i = 0; i < pos.count; i++) {
const k = keyOf(i);
if (!map.has(k)) {
map.set(k, verts.length);
verts.push(
new THREE.Vector3(pos.getX(i), pos.getY(i), pos.getZ(i)),
);
}
remap.push(map.get(k)!);
}
const edges = new Set<string>();
for (let i = 0; i < remap.length; i += 3) {
const tri = [remap[i]!, remap[i + 1]!, remap[i + 2]!];
for (let j = 0; j < 3; j++) {
const a = tri[j]!;
const b = tri[(j + 1) % 3]!;
edges.add(a < b ? `${a}-${b}` : `${b}-${a}`);
}
}
return {
verts,
edges: [...edges].map(
(e) => e.split("-").map(Number) as [number, number],
),
};
}
const sphere: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
scene.add(root);
// ---- outer geodesic lattice ----
const { verts, edges } = uniqueVerticesAndEdges(
new THREE.IcosahedronGeometry(1, 3),
);
const vertPos = new Float32Array(verts.length * 3);
verts.forEach((v, i) => vertPos.set([v.x, v.y, v.z], i * 3));
const index = edges.flat();
const latticeGeo = glowGeometry(vertPos.slice(), { index, dynamic: true });
const latticeMat = glowLines({ color: colors.line, opacity: 0.22 });
const lattice = new THREE.LineSegments(latticeGeo, latticeMat);
const nodeGeo = glowGeometry(vertPos.slice(), { dynamic: true });
const nodeAlpha = nodeGeo.attributes.aAlpha as THREE.BufferAttribute;
const nodeSize = nodeGeo.attributes.aSize as THREE.BufferAttribute;
const nodes = new THREE.Points(
nodeGeo,
glowPoints(stage, { color: colors.line, size: 4.5 }),
);
// a few highlighted "core skills"
const hubs = new Set<number>();
while (hubs.size < 14) hubs.add(Math.floor(Math.random() * verts.length));
const hubPos: number[] = [];
hubs.forEach((i) => hubPos.push(verts[i]!.x, verts[i]!.y, verts[i]!.z));
const hubGeo = glowGeometry(hubPos);
const hubMat = glowPoints(stage, { color: colors.accent, size: 11 });
const hubPoints = new THREE.Points(hubGeo, hubMat);
const shell = new THREE.Group();
shell.add(lattice, nodes, hubPoints);
root.add(shell);
// ---- inner core ----
const coreData = uniqueVerticesAndEdges(
new THREE.IcosahedronGeometry(0.38, 0),
);
const corePos: number[] = [];
coreData.edges.forEach(([a, b]) => {
const va = coreData.verts[a]!;
const vb = coreData.verts[b]!;
corePos.push(va.x, va.y, va.z, vb.x, vb.y, vb.z);
});
const coreMat = glowLines({ color: colors.accent, opacity: 0.7 });
const core = new THREE.LineSegments(glowGeometry(corePos), coreMat);
root.add(core);
// spokes from core to hubs
const spokePos: number[] = [];
hubs.forEach((i) => {
const v = verts[i]!;
const s = v.clone().multiplyScalar(0.38);
spokePos.push(s.x, s.y, s.z, v.x, v.y, v.z);
});
const spokeMat = glowLines({ color: colors.accent, opacity: 0.12 });
shell.add(new THREE.LineSegments(glowGeometry(spokePos), spokeMat));
// ---- orbit rings + satellites ----
const ringPaths: THREE.Vector3[][] = [];
const ringMats: THREE.ShaderMaterial[] = [];
const rings = new THREE.Group();
[
{ r: 1.35, tilt: new THREE.Euler(1.2, 0, 0.3) },
{ r: 1.5, tilt: new THREE.Euler(0.4, 0.2, -0.9) },
{ r: 1.62, tilt: new THREE.Euler(-0.7, 0, 1.4) },
].forEach(({ r, tilt }) => {
const path: THREE.Vector3[] = [];
const pos: number[] = [];
const N = 160;
for (let i = 0; i <= N; i++) {
const a = (i / N) * Math.PI * 2;
path.push(
new THREE.Vector3(
Math.cos(a) * r,
0,
Math.sin(a) * r,
).applyEuler(tilt),
);
}
for (let i = 0; i < N; i++) {
const a = path[i]!;
const b = path[i + 1]!;
pos.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
const mat = glowLines({ color: colors.line, opacity: 0.12 });
ringMats.push(mat);
rings.add(new THREE.LineSegments(glowGeometry(pos), mat));
ringPaths.push(path);
});
root.add(rings);
const satellites = createPackets(stage, ringPaths, {
perPath: 2,
speed: 0.9,
trail: 10,
size: 9,
});
rings.add(satellites.points);
return {
frame: { width: 3.6, height: 3.6 },
update(t, dt) {
const e = stage.entry;
const grow = smooth(e / 0.7);
// lattice grows out from the core with a slight overshoot wobble
const pos = latticeGeo.attributes.position as THREE.BufferAttribute;
const npos = nodeGeo.attributes.position as THREE.BufferAttribute;
for (let i = 0; i < verts.length; i++) {
const v = verts[i]!;
// pulse travelling over the sphere surface
const wave = Math.sin(v.y * 4 - t * 1.8 + v.x * 2) * 0.5 + 0.5;
const r = grow * (1 + wave * 0.025);
pos.setXYZ(i, v.x * r, v.y * r, v.z * r);
npos.setXYZ(i, v.x * r, v.y * r, v.z * r);
nodeAlpha.setX(i, (0.35 + wave * 0.65) * grow);
nodeSize.setX(i, 0.7 + wave * 0.6);
}
pos.needsUpdate = true;
npos.needsUpdate = true;
nodeAlpha.needsUpdate = true;
nodeSize.needsUpdate = true;
latticeMat.uniforms.uOpacity!.value = 0.22 * grow;
hubPoints.scale.setScalar(grow);
hubMat.uniforms.uOpacity!.value = smooth((e - 0.5) / 0.4);
spokeMat.uniforms.uOpacity!.value = 0.12 * smooth((e - 0.6) / 0.4);
coreMat.uniforms.uOpacity!.value = 0.7 * smooth(e / 0.3);
core.scale.setScalar(0.4 + 0.6 * smooth(e / 0.4));
ringMats.forEach(
(m, i) =>
(m.uniforms.uOpacity!.value =
0.12 * smooth((e - 0.55 - i * 0.1) / 0.3)),
);
rings.scale.setScalar(0.85 + 0.15 * smooth((e - 0.5) / 0.5));
if (stage.reducedMotion) {
root.rotation.set(0.3, 0.6, 0);
return;
}
satellites.update(dt, smooth((e - 0.75) / 0.25));
shell.rotation.y = t * 0.08 + (1 - grow) * 1.5;
core.rotation.set(t * 0.3, -t * 0.45, 0);
rings.rotation.y = -t * 0.05;
root.rotation.x = 0.2 + stage.pointer.y * 0.3;
root.rotation.y = stage.pointer.x * 0.5;
},
};
};
export default sphere;
+183
View File
@@ -0,0 +1,183 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
stagger,
} from "../materials";
/**
* Work: an isometric infrastructure stack — edge, app, data, storage
* layers as gridded plates, services as nodes, traffic flowing between
* layers. Entry: plates drop into place bottom-up, then links light up.
*/
const LAYERS = 4;
const SPACING = 0.62;
const SIZE = 1.9;
const GRID = 8;
const SERVICES_PER_LAYER = 5;
const LINKS_PER_GAP = 6;
const stack: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
root.rotation.set(0.62, Math.PI / 4, 0);
scene.add(root);
const half = SIZE / 2;
const cell = SIZE / GRID;
const layerY = (i: number) => (i - (LAYERS - 1) / 2) * SPACING;
type Layer = {
group: THREE.Group;
gridMat: THREE.ShaderMaterial;
borderMat: THREE.ShaderMaterial;
nodeMat: THREE.ShaderMaterial;
services: THREE.Vector3[];
};
const layers: Layer[] = [];
for (let li = 0; li < LAYERS; li++) {
const group = new THREE.Group();
const grid: number[] = [];
for (let k = 1; k < GRID; k++) {
const c = -half + k * cell;
grid.push(c, 0, -half, c, 0, half, -half, 0, c, half, 0, c);
}
const gridMat = glowLines({ color: colors.line, opacity: 0.1 });
group.add(new THREE.LineSegments(glowGeometry(grid), gridMat));
const b = [
[-half, -half],
[half, -half],
[half, half],
[-half, half],
];
const border: number[] = [];
for (let k = 0; k < 4; k++) {
const [x1, z1] = b[k]!;
const [x2, z2] = b[(k + 1) % 4]!;
border.push(x1!, 0, z1!, x2!, 0, z2!);
}
// corner ticks give plates a "hardware" feel
b.forEach(([x, z]) => {
border.push(x!, 0, z!, x!, -0.06, z!);
});
const borderMat = glowLines({ color: colors.line, opacity: 0.55 });
group.add(new THREE.LineSegments(glowGeometry(border), borderMat));
const services: THREE.Vector3[] = [];
const used = new Set<string>();
while (services.length < SERVICES_PER_LAYER) {
const gx = 1 + Math.floor(Math.random() * (GRID - 1));
const gz = 1 + Math.floor(Math.random() * (GRID - 1));
if (used.has(`${gx},${gz}`)) continue;
used.add(`${gx},${gz}`);
services.push(
new THREE.Vector3(-half + gx * cell, 0, -half + gz * cell),
);
}
const nodeMat = glowPoints(stage, {
color: li === LAYERS - 1 ? colors.accent : colors.line,
size: 9,
});
group.add(
new THREE.Points(
glowGeometry(services.flatMap((s) => [s.x, s.y, s.z])),
nodeMat,
),
);
root.add(group);
layers.push({ group, gridMat, borderMat, nodeMat, services });
}
// ---- inter-layer links ----
type Link = { lower: number; a: number; b: number };
const links: Link[] = [];
for (let li = 0; li < LAYERS - 1; li++) {
for (let k = 0; k < LINKS_PER_GAP; k++) {
links.push({
lower: li,
a: Math.floor(Math.random() * SERVICES_PER_LAYER),
b: Math.floor(Math.random() * SERVICES_PER_LAYER),
});
}
}
const linkGeo = glowGeometry(new Float32Array(links.length * 6), {
dynamic: true,
});
const linkMat = glowLines({ color: colors.accent, opacity: 0.28 });
root.add(new THREE.LineSegments(linkGeo, linkMat));
// packet paths are rebuilt in place each frame as layers bob
const linkPaths = links.map(() => [
new THREE.Vector3(),
new THREE.Vector3(),
]);
const packets = createPackets(stage, linkPaths, {
perPath: 1,
speed: 0.5,
trail: 6,
size: 8,
});
root.add(packets.points);
const world = (li: number, v: THREE.Vector3, out: THREE.Vector3) =>
out.copy(v).add(layers[li]!.group.position);
return {
frame: { width: 3.6, height: 4.1 },
update(t, dt) {
const e = stage.entry;
layers.forEach((L, i) => {
const local = stagger(e, i, LAYERS, 0.45);
const bob = stage.reducedMotion
? 0
: Math.sin(t * 0.8 + i * 1.3) * 0.035;
L.group.position.y = layerY(i) + bob + (1 - local) * 1.6;
L.gridMat.uniforms.uOpacity!.value = 0.1 * local;
L.borderMat.uniforms.uOpacity!.value = 0.55 * local;
L.nodeMat.uniforms.uOpacity!.value = smooth(
(local - 0.6) / 0.4,
);
});
const pos = linkGeo.attributes.position as THREE.BufferAttribute;
links.forEach((lk, i) => {
const pa = world(
lk.lower,
layers[lk.lower]!.services[lk.a]!,
linkPaths[i]![0]!,
);
const pb = world(
lk.lower + 1,
layers[lk.lower + 1]!.services[lk.b]!,
linkPaths[i]![1]!,
);
pos.setXYZ(i * 2, pa.x, pa.y, pa.z);
pos.setXYZ(i * 2 + 1, pb.x, pb.y, pb.z);
});
pos.needsUpdate = true;
const linksOn = smooth((e - 0.7) / 0.3);
linkMat.uniforms.uOpacity!.value = 0.28 * linksOn;
if (stage.reducedMotion) return;
packets.update(dt, linksOn);
root.rotation.y =
Math.PI / 4 +
Math.sin(t * 0.15) * 0.15 +
stage.pointer.x * 0.35;
root.rotation.x = 0.62 + stage.pointer.y * 0.12;
},
};
};
export default stack;
+165
View File
@@ -0,0 +1,165 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
/**
* Blog: a field of signal — a point grid rippling like a waveform, with
* faint scan rows and crests catching the accent colour. The pointer drops
* a ripple into the field. Entry: the field fades in front-to-back and the
* waves swell up from flat.
*/
const COLS = 110;
const ROWS = 56;
const WIDTH = 6;
const DEPTH = 4;
const vertex = /* glsl */ `
uniform float uTime;
uniform float uAmp;
uniform float uEntry;
uniform vec2 uRipple;
uniform float uPixelRatio;
uniform float uSize;
varying float vAlpha;
varying float vCrest;
float height(vec2 p) {
float h = sin(p.x * 1.3 + uTime * 0.7) * 0.18;
h += sin(p.y * 2.1 - uTime * 0.9 + p.x * 0.4) * 0.12;
h += sin((p.x + p.y) * 3.3 + uTime * 1.4) * 0.05;
float d = distance(p, uRipple);
h += sin(d * 7.0 - uTime * 3.5) * 0.09 * exp(-d * 1.2);
return h;
}
void main() {
vec3 p = position;
float h = height(p.xz) * uAmp;
p.y += h;
vCrest = smoothstep(0.12, 0.32, h);
// fade in from the front row backwards, fade edges/back
float depth01 = (p.z + ${DEPTH.toFixed(1)} * 0.75) / ${DEPTH.toFixed(1)};
float reveal = 1.0 - smoothstep(uEntry * 1.25 - 0.2, uEntry * 1.25, 1.0 - depth01);
float edge = 1.0 - smoothstep(${(WIDTH * 0.2).toFixed(2)}, ${(WIDTH * 0.42).toFixed(2)}, abs(p.x));
vAlpha = reveal * edge * mix(0.12, 1.0, depth01);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = min(uSize * (1.0 + vCrest) * (5.0 / -mv.z), 10.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`;
const fragment = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uAccent;
uniform float uOpacity;
uniform float uIsPoint;
varying float vAlpha;
varying float vCrest;
void main() {
float a = 1.0;
if (uIsPoint > 0.5) {
float r = length(gl_PointCoord - 0.5);
a = smoothstep(0.5, 0.0, r);
}
vec3 col = mix(uColor, uAccent, vCrest);
gl_FragColor = vec4(col, a * vAlpha * uOpacity * (0.6 + vCrest));
}
`;
const waves: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
root.rotation.x = 0.42;
root.position.y = -0.25;
scene.add(root);
const pos = new Float32Array(COLS * ROWS * 3);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const i = r * COLS + c;
pos[i * 3] = (c / (COLS - 1) - 0.5) * WIDTH;
pos[i * 3 + 1] = 0;
pos[i * 3 + 2] = (r / (ROWS - 1)) * DEPTH - DEPTH * 0.75;
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
const uniforms = {
uTime: { value: 0 },
uAmp: { value: 0 },
uEntry: { value: 0 },
uRipple: { value: new THREE.Vector2(0, 0) },
uPixelRatio: { value: stage.pixelRatio },
uSize: { value: 3 },
uColor: { value: colors.line },
uAccent: { value: colors.accent },
};
const common = {
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
};
root.add(
new THREE.Points(
geo,
new THREE.ShaderMaterial({
...common,
uniforms: {
...uniforms,
uOpacity: { value: 0.9 },
uIsPoint: { value: 1 },
},
}),
),
);
// every 4th row as a faint scan line
const index: number[] = [];
for (let r = 0; r < ROWS; r += 4) {
for (let c = 0; c < COLS - 1; c++) {
index.push(r * COLS + c, r * COLS + c + 1);
}
}
const lineGeo = geo.clone();
lineGeo.setIndex(index);
root.add(
new THREE.LineSegments(
lineGeo,
new THREE.ShaderMaterial({
...common,
uniforms: {
...uniforms,
uOpacity: { value: 0.18 },
uIsPoint: { value: 0 },
},
}),
),
);
const ripple = new THREE.Vector2();
return {
frame: { width: 4.6, height: 2.4 },
update(t) {
const e = stage.entry;
uniforms.uTime.value = stage.reducedMotion ? 2 : t;
uniforms.uEntry.value = e;
uniforms.uAmp.value = 0.15 + 0.85 * e * e;
ripple.set(
stage.pointer.x * WIDTH * 0.4,
(stage.pointer.y + 0.2) * DEPTH * 0.5,
);
uniforms.uRipple.value.lerp(ripple, 0.05);
if (!stage.reducedMotion) {
root.rotation.y = stage.pointer.x * 0.12;
root.rotation.x = 0.42 + stage.pointer.y * 0.06;
}
},
};
};
export default waves;
+204
View File
@@ -0,0 +1,204 @@
import * as THREE from "three";
import { gsap } from "gsap";
/**
* # Three.js stage
*
* Shared renderer / camera / loop for every page graphic. A scene plugs in
* by returning a `SceneInstance`; the stage handles framing, resize, pointer
* smoothing, the GSAP-driven entry animation, pausing when off-screen and
* reduced motion.
*/
export type Stage = {
THREE: typeof THREE;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
/** Smoothed pointer, -1 → 1 on both axes (y down). */
pointer: THREE.Vector2;
reducedMotion: boolean;
pixelRatio: number;
/** 0 → 1 entry progress, tweened by GSAP once the scene first shows. */
entry: number;
/** 0 → 1 as the stage's section scrolls out of the viewport. */
scroll: number;
colors: { line: THREE.Color; accent: THREE.Color };
};
export type SceneInstance = {
/** World-space box the camera must keep in frame. */
frame: { width: number; height: number };
update(t: number, dt: number): void;
dispose?(): void;
};
export type SceneFactory = (
stage: Stage,
) => SceneInstance | Promise<SceneInstance>;
export type StageOptions = {
canvas: HTMLCanvasElement;
container: HTMLElement;
factory: SceneFactory;
entryDuration?: number;
entryDelay?: number;
onReady?: () => void;
};
const FOV = 32;
export async function createStage(opts: StageOptions) {
const { canvas, container, factory } = opts;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(pixelRatio);
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100);
const stage: Stage = {
THREE,
renderer,
scene,
camera,
pointer: new THREE.Vector2(),
reducedMotion,
pixelRatio,
entry: reducedMotion ? 1 : 0,
scroll: 0,
colors: {
line: new THREE.Color("#ededed"),
accent: new THREE.Color("#34d399"),
},
};
const instance = await factory(stage);
// ---- pointer ----
const rawPointer = new THREE.Vector2();
const onPointerMove = (e: PointerEvent) => {
rawPointer.set(
(e.clientX / window.innerWidth) * 2 - 1,
(e.clientY / window.innerHeight) * 2 - 1,
);
};
if (!reducedMotion) {
window.addEventListener("pointermove", onPointerMove, {
passive: true,
});
}
// ---- scroll progress of the hosting section ----
const section = container.closest("section") ?? container;
const readScroll = () => {
const rect = section.getBoundingClientRect();
const p = -rect.top / Math.max(rect.height * 0.85, 1);
stage.scroll = Math.min(Math.max(p, 0), 1);
};
// ---- framing ----
function resize() {
const w = container.clientWidth;
const h = container.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
const tan = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
const zForHeight = instance.frame.height / 2 / tan;
const zForWidth = instance.frame.width / 2 / (tan * camera.aspect);
camera.position.set(0, 0, Math.max(zForHeight, zForWidth));
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
if (reducedMotion) renderFrame(0, 0);
}
function renderFrame(t: number, dt: number) {
readScroll();
instance.update(t, dt);
renderer.render(scene, camera);
}
// ---- loop ----
let raf = 0;
let running = false;
let last = 0;
let elapsed = 0;
let entryTween: gsap.core.Tween | undefined;
function loop(now: number) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += dt;
const k = Math.min(dt * 3, 1);
stage.pointer.x += (rawPointer.x - stage.pointer.x) * k;
stage.pointer.y += (rawPointer.y - stage.pointer.y) * k;
renderFrame(elapsed, dt);
raf = requestAnimationFrame(loop);
}
function start() {
if (running || reducedMotion) return;
running = true;
last = performance.now();
raf = requestAnimationFrame(loop);
entryTween ??= gsap.to(stage, {
entry: 1,
duration: opts.entryDuration ?? 2.8,
delay: opts.entryDelay ?? 0.15,
ease: "power2.inOut",
});
}
function stop() {
running = false;
cancelAnimationFrame(raf);
}
const visibility = new IntersectionObserver(([entry]) =>
entry?.isIntersecting ? start() : stop(),
);
visibility.observe(container);
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
const onScrollStatic = () => renderFrame(0, 0);
if (reducedMotion) {
window.addEventListener("scroll", onScrollStatic, { passive: true });
}
resize();
renderFrame(0, 0);
opts.onReady?.();
return {
stage,
dispose() {
stop();
entryTween?.kill();
visibility.disconnect();
resizeObserver.disconnect();
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("scroll", onScrollStatic);
instance.dispose?.();
scene.traverse((obj) => {
const o = obj as THREE.Mesh;
o.geometry?.dispose();
const m = o.material as THREE.Material | THREE.Material[];
(Array.isArray(m) ? m : [m]).forEach((x) => x?.dispose?.());
});
renderer.dispose();
},
};
}
@@ -9,7 +9,10 @@ import { UserRoundSearch } from "lucide-react";
export default function AboutSection() { export default function AboutSection() {
return ( return (
<Section> <Section>
<Stack className="w-full max-w-full xl:max-w-[50vw]"> <Stack
className="w-full max-w-full xl:max-w-[50vw]"
data-reveal-stagger
>
<H2 className="leading-snug"> <H2 className="leading-snug">
I Build & Operate Production Systems I Build & Operate Production Systems
</H2> </H2>
@@ -22,7 +22,7 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
const categories = Object.keys(skills) as (keyof typeof skills)[]; const categories = Object.keys(skills) as (keyof typeof skills)[];
const TitleSection = ( const TitleSection = (
<Row className="w-full justify-between"> <Row className="w-full justify-between" data-reveal="up">
<Stack> <Stack>
{noTitle ? null : ( {noTitle ? null : (
<React.Fragment> <React.Fragment>
@@ -65,7 +65,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
</Span> </Span>
</Stack> </Stack>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{skills[ctgr].portfolio.map( {skills[ctgr].portfolio.map(
(portfolio, index) => { (portfolio, index) => {
return ( return (
@@ -119,7 +122,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
})} })}
</Stack> </Stack>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{skills[category].portfolio.map((portfolio, index) => { {skills[category].portfolio.map((portfolio, index) => {
return ( return (
<MySkillsCard <MySkillsCard
@@ -25,7 +25,7 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
); );
const TitleSection = ( const TitleSection = (
<Row className="w-full justify-between"> <Row className="w-full justify-between" data-reveal="up">
<Stack> <Stack>
{!noTitle && ( {!noTitle && (
<React.Fragment> <React.Fragment>
@@ -61,7 +61,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
<Stack className="gap-6"> <Stack className="gap-6">
<H3 className="m-0 text-lg">{ctgr}</H3> <H3 className="m-0 text-lg">{ctgr}</H3>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{work[ctgr].portfolio.map( {work[ctgr].portfolio.map(
(portfolio, index) => { (portfolio, index) => {
return ( return (
@@ -113,7 +116,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
})} })}
</Stack> </Stack>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{work[category].portfolio.map((portfolio, index) => { {work[category].portfolio.map((portfolio, index) => {
return ( return (
<MyWorkPortfolioCard <MyWorkPortfolioCard
@@ -9,7 +9,10 @@ import { Contact, Mail } from "lucide-react";
export default function FooterCTASection() { export default function FooterCTASection() {
return ( return (
<Section> <Section>
<Stack className="w-full max-w-full xl:max-w-[50vw] gap-8"> <Stack
className="w-full max-w-full xl:max-w-[50vw] gap-8"
data-reveal-stagger
>
<H2 className="leading-snug">Need Help With Infrastructure?</H2> <H2 className="leading-snug">Need Help With Infrastructure?</H2>
<Span> <Span>
Whether it's CI/CD, cost optimization, or production Whether it's CI/CD, cost optimization, or production
+35 -7
View File
@@ -7,20 +7,44 @@ import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack"; import Stack from "@/src/components/lib/layout/Stack";
import { Contact, ScrollText, Terminal } from "lucide-react"; import { Contact, ScrollText, Terminal } from "lucide-react";
import ResumeButton from "../../general/resume-button"; import ResumeButton from "../../general/resume-button";
import SceneCanvas from "../../lib/three/SceneCanvas";
export default function Main() { export default function Main() {
return ( return (
<Section> <Section
<Stack className="w-full max-w-full xl:max-w-[50vw]"> className={[
<Span>Benjamin Toby</Span> "relative overflow-hidden justify-start xl:justify-center",
<H1 className="leading-snug m-0!"> "min-h-[calc(100svh-var(--header-height))]",
"pt-4! xl:pt-20!",
].join(" ")}
>
<div
className={[
"relative w-full h-[40svh] min-h-[280px] max-h-[520px] mb-4",
"xl:absolute xl:inset-y-0 xl:right-0 xl:left-auto",
"xl:w-1/2 xl:h-auto xl:max-h-none xl:mb-0",
].join(" ")}
>
<SceneCanvas scene="face" className="w-full h-full" />
</div>
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw]">
<Span data-reveal="fade">Benjamin Toby</Span>
<H1 className="leading-snug m-0!" data-reveal="text">
CTO, DevOps & Platform Engineer. CTO, DevOps & Platform Engineer.
</H1> </H1>
<Span className="text-xl"> <Span
className="text-xl"
data-reveal="up"
data-reveal-delay="0.4"
>
I help teams ship faster, cut infrastructure costs, and I help teams ship faster, cut infrastructure costs, and
achieve reliable production systems. achieve reliable production systems.
</Span> </Span>
<Row className="my-4 gap-6"> <Row
className="my-4 gap-6"
data-reveal-stagger="up"
data-reveal-delay="0.6"
>
<Row> <Row>
<LucideIcon name="CheckCircle2" size={20} /> <LucideIcon name="CheckCircle2" size={20} />
<Span>99.99% uptime targets</Span> <Span>99.99% uptime targets</Span>
@@ -34,7 +58,11 @@ export default function Main() {
<Span>Over 80% Operations cost reduction</Span> <Span>Over 80% Operations cost reduction</Span>
</Row> </Row>
</Row> </Row>
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4"> <Row
className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4"
data-reveal-stagger="up"
data-reveal-delay="0.85"
>
<Button <Button
title="Contact Me" title="Contact Me"
beforeIcon={ beforeIcon={
+63 -59
View File
@@ -1,79 +1,83 @@
import Button from "@/src/components/lib/layout/Button"; import Button from "@/src/components/lib/layout/Button";
import Divider from "@/src/components/lib/layout/Divider"; import Divider from "@/src/components/lib/layout/Divider";
import H1 from "@/src/components/lib/layout/H1";
import H2 from "@/src/components/lib/layout/H2"; import H2 from "@/src/components/lib/layout/H2";
import P from "@/src/components/lib/layout/P"; import P from "@/src/components/lib/layout/P";
import Row from "@/src/components/lib/layout/Row"; import Row from "@/src/components/lib/layout/Row";
import Section from "@/src/components/lib/layout/Section"; import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span"; import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack"; import Stack from "@/src/components/lib/layout/Stack";
import { ScrollText, Terminal } from "lucide-react"; import { Terminal } from "lucide-react";
import React from "react";
import PageHero from "../../general/page-hero";
import ResumeButton from "../../general/resume-button"; import ResumeButton from "../../general/resume-button";
export default function Main() { export default function Main() {
return ( return (
<Section> <React.Fragment>
<Stack className="w-full max-w-full xl:max-w-[50vw] gap-8"> <PageHero
{/* <Span className="leading-snug -mb-6 text-sm"> scene="helix"
I Build & Operate Production Systems eyebrow="Background"
</Span> */} title="About Me"
<H1 className="leading-snug">About Me</H1> subtitle="I'm Benjamin Toby, a senior DevOps/Platform engineer and CTO at Summit Lending. I build and operate production systems that support real businesses."
<Span> />
I'm Benjamin Toby, a senior DevOps/Platform engineer and CTO <Divider />
at Summit Lending. I build and operate production systems <Section>
that support real businesses. <Stack
</Span> className="w-full max-w-full xl:max-w-[50vw] gap-8"
data-reveal-stagger
>
<H2>What I Do</H2>
<Span>I focus on infrastructure and reliability:</Span>
<ul className="space-y-4">
<li>CI/CD automation and deployment pipelines</li>
<li>
Multi-cloud architecture (AWS, GCP, Azure, Hetzner)
</li>
<li>NGINX hardening, TLS, caching, rate limiting</li>
<li>Full-stack delivery with Next.js, Bun, and SQL</li>
</ul>
<Divider /> <ResumeButton />
<H2>What I Do</H2> <Divider />
<Span>I focus on infrastructure and reliability:</Span>
<ul className="space-y-4">
<li>CI/CD automation and deployment pipelines</li>
<li>Multi-cloud architecture (AWS, GCP, Azure, Hetzner)</li>
<li>NGINX hardening, TLS, caching, rate limiting</li>
<li>Full-stack delivery with Next.js, Bun, and SQL</li>
</ul>
<ResumeButton /> <H2>Background</H2>
<P>
I started as a full-stack developer and moved into
DevOps and platform engineering out of necessity,
building systems that could scale without breaking. That
led to Summit Lending, where I manage all engineering,
from infrastructure to product.
</P>
<P>
Along the way I built TurboCI, a deployment
orchestration tool, and Datasquirel, a cloud SQL
platform. I write about what I learn on my{" "}
<a href="/blog">blog</a>.
</P>
<Divider /> <Divider />
<H2>Background</H2> <Span>
<P> I focus on measurable outcomes: faster deployments,
I started as a full-stack developer and moved into DevOps lower costs, fewer incidents.
and platform engineering out of necessity, building systems </Span>
that could scale without breaking. That led to Summit
Lending, where I manage all engineering, from infrastructure
to product.
</P>
<P>
Along the way I built TurboCI, a deployment orchestration
tool, and Datasquirel, a cloud SQL platform. I write about
what I learn on my <a href="/blog">blog</a>.
</P>
<Divider /> <Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4">
<Button
<Span> title="View My Work"
I focus on measurable outcomes: faster deployments, lower beforeIcon={
costs, fewer incidents. <Terminal size={17} className="font-normal" />
</Span> }
href="/work"
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4"> className="grow w-full bg-transparent!"
<Button variant="outlined"
title="View My Work" >
beforeIcon={ View My Work
<Terminal size={17} className="font-normal" /> </Button>
} </Row>
href="/work" </Stack>
className="grow w-full bg-transparent!" </Section>
variant="outlined" </React.Fragment>
>
View My Work
</Button>
</Row>
</Stack>
</Section>
); );
} }
@@ -28,7 +28,7 @@ export default function BlogPostsList() {
return ( return (
<Section className="!mt-0 !pt-0"> <Section className="!mt-0 !pt-0">
<Stack className="w-full"> <Stack className="w-full" data-reveal-stagger>
{blogPosts.map((post, index) => { {blogPosts.map((post, index) => {
return <BlogPostsListCard post={post} key={index} />; return <BlogPostsListCard post={post} key={index} />;
})} })}
+7 -17
View File
@@ -1,22 +1,12 @@
import H1 from "@/src/components/lib/layout/H1"; import PageHero from "@/src/components/general/page-hero";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import React from "react";
export default function Hero() { export default function Hero() {
const { pageProps } = React.useContext(AppContext);
return ( return (
<Section> <PageHero
<Stack className="w-full gap-0"> scene="waves"
<H1 className="leading-snug">Tech Musings</H1> eyebrow="Blog"
<Span> title="Tech Musings"
A few takes and tips from my experience and knowledge in the subtitle="A few takes and tips from my experience and knowledge in the tech industry"
tech industry />
</Span>
</Stack>
</Section>
); );
} }
+3 -1
View File
@@ -19,7 +19,9 @@ export default function Main() {
<Stack className="w-full max-w-full xl:max-w-[800px] overflow-auto"> <Stack className="w-full max-w-full xl:max-w-[800px] overflow-auto">
{/* <Divider dashed className="border-[2px] my-6" /> */} {/* <Divider dashed className="border-[2px] my-6" /> */}
<Stack className="gap-1"> <Stack className="gap-1">
<H1 className="leading-snug mb-1">{blogPost?.title}</H1> <H1 className="leading-snug mb-1" data-reveal="text">
{blogPost?.title}
</H1>
<Span>{blogPost?.excerpt}</Span> <Span>{blogPost?.excerpt}</Span>
<Stack className="gap-2 mt-2"> <Stack className="gap-2 mt-2">
<Breadcrumbs <Breadcrumbs
+14 -19
View File
@@ -1,28 +1,23 @@
import H1 from "@/src/components/lib/layout/H1";
import Link from "@/src/components/lib/layout/Link"; import Link from "@/src/components/lib/layout/Link";
import Row from "@/src/components/lib/layout/Row"; import Row from "@/src/components/lib/layout/Row";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span"; import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import PageHero from "../../general/page-hero";
export default function Main() { export default function Main() {
return ( return (
<Section> <PageHero
<Stack className="w-full max-w-full xl:max-w-[50vw]"> scene="globe"
<H1 className="leading-snug">Contact Me</H1> eyebrow="Get in touch"
<Span> title="Contact Me"
Need help with CI/CD, cost optimization, or production subtitle="Need help with CI/CD, cost optimization, or production reliability? Book a call or send a message."
reliability? Book a call or send a message. >
</Span> <Link href={`mailto:[email protected]`} className="border-none">
<Row className="items-center">
<Link href={`mailto:[email protected]`} className="border-none"> <Mail size={20} className="mt-1" />
<Row className="items-center"> <Span className="text-2xl">ben@tben.me</Span>
<Mail size={20} className="mt-1" /> </Row>
<Span className="text-2xl">ben@tben.me</Span> </Link>
</Row> </PageHero>
</Link>
</Stack>
</Section>
); );
} }
+7 -10
View File
@@ -1,15 +1,12 @@
import H1 from "@/src/components/lib/layout/H1"; import PageHero from "../../general/page-hero";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
export default function Main() { export default function Main() {
return ( return (
<Section> <PageHero
<Stack className="w-full max-w-full xl:max-w-[50vw]"> scene="sphere"
<H1 className="leading-snug">My Skills</H1> eyebrow="Toolbox"
<Span>A summary of the tools I've mastered over the years</Span> title="My Skills"
</Stack> subtitle="A summary of the tools I've mastered over the years"
</Section> />
); );
} }
+7 -10
View File
@@ -1,15 +1,12 @@
import H1 from "@/src/components/lib/layout/H1"; import PageHero from "../../general/page-hero";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
export default function Main() { export default function Main() {
return ( return (
<Section> <PageHero
<Stack className="w-full max-w-full xl:max-w-[50vw]"> scene="stack"
<H1 className="leading-snug">My Work</H1> eyebrow="Portfolio"
<Span>Some of My Work</Span> title="My Work"
</Stack> subtitle="Systems I've designed, shipped and kept running in production."
</Section> />
); );
} }
+5 -1
View File
@@ -21,10 +21,14 @@ export default function Aside({}: Props) {
"px-6 py-4 h-[var(--header-height)] border-0", "px-6 py-4 h-[var(--header-height)] border-0",
"border-b border-white/10", "border-b border-white/10",
)} )}
data-reveal="fade"
> >
<Logo size={28} /> <Logo size={28} />
</div> </div>
<Stack className="items-center w-full py-6 mt-auto"> <Stack
className="items-center w-full py-6 mt-auto"
data-reveal-stagger="left"
>
{SocialLinks.map((link, index) => { {SocialLinks.map((link, index) => {
return <SocialLink key={index} link={link} />; return <SocialLink key={index} link={link} />;
})} })}
+5 -2
View File
@@ -21,10 +21,13 @@ export default function Header({ menuOpen, setMenuOpen }: Props) {
"bg-background-dark z-10", "bg-background-dark z-10",
)} )}
> >
<Row className="gap-6 ml-auto hidden md:flex"> <Row className="gap-6 ml-auto hidden md:flex" data-reveal="down">
<LinkList links={HeaderLinks} /> <LinkList links={HeaderLinks} />
</Row> </Row>
<Row className="flex md:hidden w-full justify-between"> <Row
className="flex md:hidden w-full justify-between"
data-reveal="fade"
>
<Logo size={25} /> <Logo size={25} />
<Button <Button
title="Mobile Menu" title="Mobile Menu"
+2
View File
@@ -4,11 +4,13 @@ import Header from "./Header";
import Footer from "./Footer"; import Footer from "./Footer";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import MobileMenu from "./(sections)/MobileMenu"; import MobileMenu from "./(sections)/MobileMenu";
import useMotion from "@/src/components/lib/motion/use-motion";
type Props = PropsWithChildren; type Props = PropsWithChildren;
export default function Layout({ children }: Props) { export default function Layout({ children }: Props) {
const [menuOpen, setMenuOpen] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false);
useMotion();
return ( return (
<div className="flex flex-row items-stretch w-full min-h-screen"> <div className="flex flex-row items-stretch w-full min-h-screen">
+10 -6
View File
@@ -1,13 +1,17 @@
import { SchemaJSON } from "@/src/layouts/main/(data)/schema"; import { SchemaJSON } from "@/src/layouts/main/(data)/schema";
import { MotionHeadScript } from "@/src/components/lib/motion/head-script";
import { BunextPageHeadFCProps } from "@moduletrace/bunext/types"; import { BunextPageHeadFCProps } from "@moduletrace/bunext/types";
export const RootHead = ({}: BunextPageHeadFCProps) => { export const RootHead = ({}: BunextPageHeadFCProps) => {
return ( return (
<script <>
type="application/ld+json" <script dangerouslySetInnerHTML={{ __html: MotionHeadScript }} />
dangerouslySetInnerHTML={{ <script
__html: JSON.stringify(SchemaJSON), type="application/ld+json"
}} dangerouslySetInnerHTML={{
></script> __html: JSON.stringify(SchemaJSON),
}}
></script>
</>
); );
}; };
+11
View File
@@ -103,3 +103,14 @@
.markdown-rendered hr { .markdown-rendered hr {
@apply opacity-20 my-6; @apply opacity-20 my-6;
} }
/* ---- Motion system (see src/components/lib/motion/motion.ts) ---- */
html.motion:not(.motion-fallback) [data-reveal],
html.motion:not(.motion-fallback) [data-reveal-stagger] > * {
visibility: hidden;
}
.motion-line {
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}