diff --git a/bun.lock b/bun.lock
index 95e6f62..264385b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,6 +8,7 @@
"@moduletrace/bun-mariadb": "^1.1.1",
"@moduletrace/bunext": "^1.2.4",
"gray-matter": "^4.0.3",
+ "gsap": "^3.15.0",
"html-to-react": "^1.7.0",
"lodash": "^4.17.21",
"lucide-react": "^0.462.0",
@@ -22,6 +23,7 @@
"remark-gfm": "^4.0.1",
"showdown": "^2.1.0",
"tailwind-merge": "^2.5.5",
+ "three": "^0.187.0",
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
@@ -32,6 +34,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/showdown": "^2.0.6",
+ "@types/three": "^0.187.0",
"postcss": "^8",
"tailwindcss": "^4.1.11",
"typescript": "^5",
@@ -45,6 +48,8 @@
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
@@ -209,6 +214,8 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "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/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
+
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
@@ -241,8 +248,14 @@
"@types/showdown": ["@types/showdown@2.0.6", "", {}, "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="],
+ "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
+
+ "@types/three": ["@types/three@0.187.0", "", { "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/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+ "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
+
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
@@ -367,6 +380,8 @@
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
+ "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
+
"find-up": ["find-up@1.1.2", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -379,6 +394,8 @@
"gray-matter": ["gray-matter@4.0.3", "", { "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": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
+
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "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": ["meow@3.6.0", "", { "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": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
+
"micromark": ["micromark@4.0.2", "", { "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": ["micromark-core-commonmark@2.0.3", "", { "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": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+ "three": ["three@0.187.0", "", {}, "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA=="],
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trim-newlines": ["trim-newlines@1.0.0", "", {}, "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw=="],
diff --git a/package.json b/package.json
index 9e96747..fad8846 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@moduletrace/bun-mariadb": "^1.1.1",
"@moduletrace/bunext": "^1.2.4",
"gray-matter": "^4.0.3",
+ "gsap": "^3.15.0",
"html-to-react": "^1.7.0",
"lodash": "^4.17.21",
"lucide-react": "^0.462.0",
@@ -27,7 +28,8 @@
"rehype-prism-plus": "^2.0.1",
"remark-gfm": "^4.0.1",
"showdown": "^2.1.0",
- "tailwind-merge": "^2.5.5"
+ "tailwind-merge": "^2.5.5",
+ "three": "^0.187.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
@@ -38,6 +40,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/showdown": "^2.0.6",
+ "@types/three": "^0.187.0",
"postcss": "^8",
"tailwindcss": "^4.1.11",
"typescript": "^5"
diff --git a/public/data/face-mesh.json b/public/data/face-mesh.json
new file mode 100644
index 0000000..a30b61e
--- /dev/null
+++ b/public/data/face-mesh.json
@@ -0,0 +1 @@
+{"v":[0.011,-0.3409,0.1789,0.0162,-0.0977,0.4255,0.0097,-0.168,0.2136,-0.0531,0.1551,0.3497,0.0181,-0.0206,0.4597,0.0171,0.081,0.4392,0.0136,0.3259,0.2566,-0.5244,0.3768,-0.0328,0.0147,0.5321,0.2247,0.0184,0.6406,0.2546,0.0247,1.0082,0.2292,0.0095,-0.3746,0.1641,0.0078,-0.4097,0.1381,0.0042,-0.4352,0.0993,0.0093,-0.436,0.0912,0.0081,-0.4817,0.0995,0.0068,-0.5377,0.1158,0.0028,-0.5937,0.1048,-0.0008,-0.6689,0.0309,0.0139,-0.1378,0.3813,-0.0699,-0.1307,0.2743,-0.8063,0.6913,-0.3449,-0.3211,0.3073,0.0326,-0.3967,0.3047,0.0287,-0.4695,0.3107,0.01,-0.5602,0.3518,-0.0497,-0.2542,0.3195,0.0232,-0.4349,0.5303,0.0851,-0.3462,0.5162,0.0816,-0.5182,0.5199,0.059,-0.5716,0.4902,0.0253,-0.6261,0.3106,-0.0946,-0.3064,-0.7697,-0.0926,-0.5517,0.3946,-0.0564,-0.8391,0.3584,-0.4289,-0.7105,0.3675,-0.1638,-0.4069,0.0006,0.0675,-0.1112,-0.3166,0.1756,-0.1073,-0.4015,0.1281,-0.2305,-0.3286,0.1247,-0.3103,-0.3403,0.0513,-0.2041,-0.3911,0.0915,-0.2787,-0.3831,0.0256,-0.4129,-0.4515,-0.0991,-0.0535,-0.0942,0.4209,-0.0604,-0.0179,0.4526,-0.6802,0.5763,0.0186,-0.2313,0.1778,0.0954,-0.2671,-0.0515,0.2361,-0.2728,-0.0101,0.2157,-0.6368,0.0253,-0.0313,-0.0573,0.0764,0.4171,-0.5037,0.6566,0.1554,-0.6094,0.6322,0.095,-0.7311,0.8237,-0.1758,-0.1563,0.5667,0.2091,-0.2697,0.4784,0.054,-0.4776,-0.3587,-0.1035,-0.7886,-0.3643,-0.7519,-0.2102,-0.0972,0.1979,-0.1375,-0.1261,0.1973,-0.418,-0.3576,-0.106,-0.3973,-0.3629,-0.0873,-0.6503,0.6878,0.0669,-0.2762,-0.0811,0.1879,-0.3554,0.6408,0.1962,-0.3672,0.7086,0.2232,-0.4209,0.9859,0.1377,-0.6896,0.7514,-0.0374,-0.3868,0.8436,0.185,-0.7234,0.6143,-0.0401,-0.7649,0.6444,-0.1836,-0.1117,-0.3604,0.1592,-0.217,-0.3609,0.1095,-0.2947,-0.3601,0.0435,-0.1906,-0.113,0.1787,-0.4075,-0.3601,-0.0963,-0.3576,-0.3993,-0.0515,-0.3848,-0.3673,-0.0852,-0.1584,-0.0783,0.3123,-0.2654,-0.3991,0.0101,-0.1909,-0.4115,0.0558,-0.1003,-0.4259,0.0906,-0.1271,-0.6582,0.0253,-0.1224,-0.5795,0.0983,-0.1152,-0.523,0.108,-0.1054,-0.4686,0.09,-0.0949,-0.4255,0.0819,-0.2621,-0.399,0.0014,-0.2802,-0.4169,0.0107,-0.3004,-0.443,0.019,-0.3148,-0.4714,0.0012,-0.371,-0.2566,0.0397,-0.8516,0.0225,-0.8032,0.0115,-0.1563,0.2732,-0.3185,-0.3853,-0.0516,-0.3383,-0.3931,-0.0478,-0.1112,-0.1564,0.1957,-0.2503,-0.1246,0.1216,-0.1238,-0.1428,0.1989,-0.3191,0.1402,0.0644,-0.4494,0.1003,0.0389,-0.2903,-0.042,0.1667,-0.609,0.924,-0.0071,-0.5696,0.8207,0.0826,-0.53,0.7227,0.1537,-0.3376,-0.5308,-0.058,-0.18,0.6742,0.2534,-0.1941,0.8368,0.2374,-0.2145,1.0101,0.2174,-0.5264,0.3249,-0.021,-0.7044,0.2641,-0.1436,-0.2102,0.3365,0.0152,-0.6424,0.4613,-0.0421,-0.1713,0.2211,0.1285,-0.2075,-0.0307,0.3156,-0.7865,0.2131,-0.2379,-0.6581,0.2118,-0.0821,-0.5668,0.1815,-0.0173,-0.423,0.1841,0.0155,-0.3139,0.2077,0.034,-0.23,0.2364,0.0574,-0.0614,0.3101,0.2256,-0.7881,0.0588,-0.2388,-0.6989,0.477,-0.0733,-0.0282,-0.1335,0.3784,-0.2336,0.1038,0.1222,-0.8606,0.369,-0.6851,-0.1657,0.2736,0.0749,-0.2991,-0.0447,0.0842,-0.5942,0.3968,-0.0725,-0.2114,0.0198,0.2905,-0.8321,-0.1658,-0.7982,-0.2094,0.3604,0.001,-0.1333,0.056,0.3696,-0.6354,-0.5527,-0.3721,-0.6239,-0.6453,-0.5206,-0.8431,0.0355,-0.5186,-0.7293,-0.4441,-0.4499,-0.8037,0.5121,-0.3261,-0.3173,-0.8568,-0.1465,-0.0214,-0.1531,0.2688,-0.3104,0.0532,0.0884,-0.7699,0.3577,-0.2416,-0.4581,0.3531,0.0091,-0.3967,0.3464,0.0256,-0.3719,-0.4125,-0.0661,-0.7861,-0.0976,-0.2897,-0.1728,-0.9733,-0.1776,-0.4085,-0.8448,-0.3273,-0.5075,-0.761,-0.4151,0.0215,0.826,0.249,-0.0103,-0.9899,-0.1591,-0.3337,0.3489,0.0276,-0.2722,0.3545,0.016,-0.2297,0.3561,0.0011,-0.7498,0.496,-0.147,-0.2835,0.4087,0.037,-0.3507,0.4293,0.0548,-0.412,0.4356,0.0534,-0.4721,0.4294,0.0338,-0.5104,0.4168,0.0079,-0.844,0.5458,-0.5296,-0.4972,0.3647,-0.0131,0.0085,-0.2248,0.1848,-0.2838,-0.2273,0.1001,-0.2068,-0.0855,0.2311,-0.1173,-0.2197,0.1797,0.0128,0.4255,0.212,-0.528,-0.6675,-0.3109,-0.4275,-0.7614,-0.2348,-0.1756,-0.9172,-0.0725,-0.7144,-0.5234,-0.6461,-0.2326,0.38,0.0161,-0.1166,0.194,0.2362,-0.0075,-0.9391,-0.0585,-0.3012,-0.9194,-0.2358,-0.8274,-0.1357,-0.5338,-0.1854,-0.4134,0.0468,-0.2027,-0.4437,0.0566,-0.219,-0.4862,0.0673,-0.231,-0.5363,0.053,-0.2474,-0.6058,-0.0101,-0.3399,-0.372,-0.0352,-0.3569,-0.3608,-0.0348,-0.3706,-0.3493,-0.0309,-0.4415,-0.2988,-0.0319,-0.7042,-0.1391,-0.138,-0.1144,0.2702,0.1721,-0.1551,0.4242,0.0601,-0.2014,0.4115,0.0323,-0.3225,-0.3848,-0.0442,-0.7134,-0.3273,-0.2859,-0.0843,0.423,0.1615,-0.2781,-0.682,-0.0537,0.016,0.1638,0.3744,-0.0576,0.2284,0.2958,0.0134,0.24,0.3129,-0.1834,0.0827,0.2252,-0.0063,-0.8576,-0.0034,-0.003,-0.7573,0.0145,-0.1456,-0.7399,-0.0018,-0.4599,-0.4934,-0.1403,-0.362,-0.0977,0.0553,-0.3768,-0.5999,-0.1047,-0.5387,-0.0806,0.0243,-0.4376,-0.1663,0.0215,-0.6104,-0.1965,-0.0599,-0.1675,-0.837,-0.021,-0.2415,0.0465,0.1643,-0.5129,-0.5677,-0.215,-0.4095,-0.6759,-0.1672,-0.5318,-0.3833,-0.1312,-0.7732,-0.2323,-0.3388,-0.6178,-0.4087,-0.2028,-0.7957,-0.298,-0.5278,-0.5096,-0.2472,-0.0348,-0.173,0.1495,0.1747,-0.1818,-0.0653,0.3303,-0.2426,-0.0778,0.2413,-0.136,-0.0207,0.3875,-0.2186,0.503,0.0928,-0.3495,0.5611,0.1126,-0.462,0.5771,0.1095,-0.5552,0.5631,0.0768,-0.6178,0.5262,0.0264,-0.6446,0.3844,-0.1057,-0.8484,0.1976,-0.477,-0.5862,0.2782,-0.0594,-0.5119,0.254,-0.0182,-0.4119,0.249,0.0103,-0.315,0.2601,0.0251,-0.2369,0.2806,0.0291,-0.1838,0.3016,0.0367,-0.8582,0.199,-0.7686,-0.2443,-0.0961,0.1993,-0.1178,0.1254,0.292,-0.1148,-0.0769,0.387,-0.0723,-0.1172,0.3485,-0.1194,-0.0866,0.3478,-0.2237,-0.1195,0.1679,-0.0566,-0.1268,0.3676,-0.0497,-0.1429,0.2709,-0.1828,0.3587,0.0117,-0.1427,0.343,0.054,-0.1183,0.3303,0.1041,-0.5336,0.4052,-0.0201,-0.6015,0.4513,-0.0228,0.0815,0.1516,0.3434,0.5294,0.3406,-0.0771,0.0914,-0.1347,0.269,0.7846,0.6405,-0.4098,0.3271,0.2847,0.0044,0.4003,0.2772,-0.0054,0.4721,0.28,-0.0293,0.56,0.3145,-0.096,0.259,0.302,0.0002,0.4456,0.4975,0.0496,0.3578,0.4882,0.0527,0.5261,0.4826,0.0176,0.577,0.4502,-0.0194,0.6217,0.2723,-0.1445,0.2788,-0.778,-0.1123,0.5545,0.3571,-0.1026,0.8027,0.3093,-0.4979,0.702,0.3215,-0.2207,0.4067,-0.0202,0.0393,0.1293,-0.3173,0.1676,0.1156,-0.4021,0.121,0.2379,-0.329,0.1074,0.3119,-0.3408,0.0272,0.2058,-0.3935,0.0769,0.2772,-0.3831,0.0052,0.3957,-0.4644,-0.1292,0.0828,-0.0969,0.4169,0.0944,-0.023,0.4481,0.6869,0.5291,-0.0349,0.238,0.1649,0.0768,0.2821,-0.0627,0.2173,0.2843,-0.0218,0.1966,0.6251,-0.0069,-0.0776,0.0881,0.074,0.4106,0.5234,0.618,0.118,0.6233,0.5871,0.0492,0.7305,0.7749,-0.2341,0.1828,0.5541,0.196,0.2815,0.4574,0.0301,0.4638,-0.3713,-0.139,0.7168,-0.3963,-0.8122,0.2221,-0.1053,0.1861,0.1529,-0.1306,0.1912,0.41,-0.3602,-0.1384,0.3909,-0.366,-0.1164,0.6631,0.6389,0.0182,0.2876,-0.0912,0.1698,0.3808,0.6126,0.1707,0.3961,0.679,0.1969,0.4574,0.9549,0.1043,0.6948,0.7051,-0.0908,0.4213,0.8148,0.1543,0.7253,0.5635,-0.0945,0.7547,0.5954,-0.245,0.1257,-0.3622,0.1511,0.2226,-0.3619,0.0934,0.2956,-0.3618,0.0218,0.2042,-0.1189,0.1678,0.4029,-0.363,-0.1267,0.3539,-0.4059,-0.0783,0.3804,-0.3708,-0.1137,0.1796,-0.0852,0.2995,0.2607,-0.3995,-0.0094,0.1909,-0.4136,0.0418,0.1048,-0.428,0.083,0.1168,-0.6607,0.0179,0.123,-0.5834,0.0897,0.1216,-0.5276,0.0988,0.1164,-0.4704,0.0811,0.1063,-0.4303,0.0732,0.2621,-0.4035,-0.0196,0.2795,-0.422,-0.0102,0.2974,-0.4506,-0.003,0.3106,-0.4792,-0.0225,0.3694,-0.269,0.012,0.7917,-0.0239,-0.8693,0.3161,-0.3893,-0.0776,0.3338,-0.3975,-0.0735,0.1276,-0.1603,0.189,0.2583,-0.1335,0.1071,0.1393,-0.1481,0.1928,0.3224,0.1247,0.0394,0.4486,0.0763,0.0061,0.2991,-0.0527,0.1469,0.6289,0.8815,-0.0549,0.5916,0.781,0.0395,0.5516,0.6808,0.1139,0.3203,-0.5421,-0.0837,0.2116,0.6588,0.2396,0.2355,0.8216,0.2221,0.261,0.9943,0.1999,0.5284,0.2921,-0.0645,0.6941,0.2208,-0.1999,0.2177,0.3205,-0.0051,0.6436,0.4175,-0.0937,0.1807,0.2112,0.1136,0.228,-0.0411,0.3006,0.7642,0.1694,-0.2978,0.6507,0.1742,-0.1345,0.5631,0.1499,-0.0621,0.4253,0.1604,-0.0186,0.3184,0.1887,0.0078,0.235,0.2229,0.0381,0.084,0.3063,0.2194,0.7621,0.0171,-0.3008,0.6958,0.4301,-0.1294,0.0535,-0.1343,0.375,0.2428,0.0921,0.1037,0.8097,0.32,-0.7538,0.1758,0.2635,0.0591,0.3039,-0.0558,0.0638,0.5941,0.356,-0.1213,0.2302,0.0096,0.2753,0.7683,-0.2071,-0.8613,0.2152,0.3446,-0.0203,0.1587,0.0484,0.3585,0.5819,-0.5736,-0.4156,0.5561,-0.663,-0.5646,0.8024,-0.0081,-0.5867,0.6699,-0.4702,-0.5014,0.7803,0.4628,-0.3915,0.2859,-0.8638,-0.1672,0.044,-0.1531,0.2668,0.3152,0.0387,0.0659,0.753,0.3088,-0.3008,0.4626,0.3234,-0.0299,0.4035,0.3197,-0.0085,0.3678,-0.4162,-0.0941,0.7536,-0.1355,-0.3487,0.1441,-0.9765,-0.1883,0.3598,-0.8563,-0.3564,0.4494,-0.7739,-0.4511,0.3384,0.3251,-0.0014,0.2778,0.3337,-0.0088,0.2369,0.3396,-0.0213,0.7429,0.4458,-0.2064,0.2878,0.3865,0.0119,0.3547,0.402,0.0253,0.4177,0.4046,0.0192,0.4753,0.3959,-0.0057,0.5147,0.3805,-0.0353,0.8074,0.4971,-0.5974,0.5019,0.3309,-0.0557,0.2891,-0.2366,0.0795,0.2207,-0.0941,0.2181,0.132,-0.2235,0.1714,0.4786,-0.6804,-0.3472,0.3865,-0.7731,-0.2635,0.1509,-0.9219,-0.0829,0.6411,-0.548,-0.6973,0.2387,0.3634,-0.0067,0.1356,0.1865,0.226,0.2628,-0.9271,-0.2566,0.7799,-0.1771,-0.5978,0.1927,-0.4171,0.0314,0.2067,-0.4504,0.0401,0.2212,-0.495,0.0501,0.2283,-0.5432,0.0346,0.2331,-0.6143,-0.0282,0.3362,-0.3752,-0.0598,0.352,-0.3618,-0.0619,0.3665,-0.3517,-0.06,0.4347,-0.315,-0.0663,0.6798,-0.1726,-0.1875,0.1299,0.2635,0.1617,0.1694,0.4119,0.046,0.211,0.396,0.0117,0.3154,-0.3861,-0.0692,0.6706,-0.3571,-0.3356,0.1026,0.4158,0.1519,0.2556,-0.6897,-0.0719,0.0832,0.2242,0.2901,0.1991,0.0744,0.2101,0.1303,-0.7459,-0.0091,0.4349,-0.5074,-0.174,0.3627,-0.1126,0.0307,0.3512,-0.6087,-0.131,0.5312,-0.1082,-0.0116,0.435,-0.1855,-0.0096,0.5916,-0.2239,-0.1019,0.1482,-0.842,-0.0309,0.2515,0.0356,0.147,0.4738,-0.5824,-0.2508,0.3779,-0.6858,-0.1941,0.5079,-0.3987,-0.1704,0.7307,-0.2661,-0.3948,0.5823,-0.4314,-0.2455,0.7403,-0.3323,-0.5886,0.4968,-0.2688,-0.0707,0.1869,0.1406,0.1599,0.2013,-0.0728,0.3165,0.2557,-0.0886,0.2256,0.164,-0.0276,0.3787,0.2372,0.4856,0.0737,0.3681,0.5348,0.0856,0.4779,0.5415,0.0737,0.5666,0.5227,0.0337,0.6237,0.4836,-0.0221,0.6407,0.3408,-0.1573,0.8076,0.1503,-0.5474,0.584,0.2416,-0.1069,0.5105,0.2236,-0.0592,0.4138,0.2231,-0.0237,0.3184,0.2398,-0.0022,0.2426,0.2646,0.008,0.1892,0.2886,0.0192,0.8018,0.1512,-0.8377,0.2562,-0.1047,0.1844,0.1407,0.1199,0.2809,0.1409,-0.0818,0.3788,0.0954,-0.1203,0.3432,0.1414,-0.0931,0.3391,0.2353,-0.1275,0.1549,0.0798,-0.1286,0.3638,0.0716,-0.1472,0.2676,0.1898,0.3453,-0.0069,0.1526,0.3321,0.0392,0.1325,0.321,0.0936,0.5375,0.3678,-0.0649,0.6059,0.4095,-0.0698],"e":[0,11,0,37,0,164,0,267,1,4,1,19,1,44,1,274,2,94,2,97,2,141,2,164,2,167,2,326,2,370,2,393,3,51,3,195,3,196,3,197,3,236,4,5,4,44,4,45,4,51,4,274,4,275,4,281,5,51,5,195,5,281,6,122,6,168,6,196,6,197,6,351,6,419,7,25,7,33,7,110,7,163,8,9,8,55,8,168,8,193,8,285,8,417,9,55,9,107,9,108,9,151,9,285,9,336,9,337,10,109,10,151,10,338,11,12,11,37,11,72,11,267,11,302,12,13,12,38,12,72,12,268,12,302,13,38,13,82,13,268,13,312,14,15,14,86,14,87,14,316,14,317,15,16,15,85,15,86,15,315,15,316,16,17,16,85,16,315,17,18,17,83,17,84,17,85,17,313,17,314,17,315,18,83,18,200,18,201,18,313,18,421,19,44,19,94,19,125,19,141,19,274,19,354,19,370,20,60,20,79,20,99,20,166,20,238,20,242,21,54,21,68,21,71,21,162,22,23,22,26,22,145,22,153,22,154,22,230,22,231,23,24,23,144,23,145,23,229,23,230,24,110,24,144,24,228,24,229,25,31,25,33,25,110,25,130,25,226,25,228,26,112,26,154,26,155,26,231,26,232,27,28,27,29,27,159,27,160,27,222,27,223,28,56,28,157,28,158,28,159,28,221,28,222,29,30,29,160,29,223,29,224,30,160,30,161,30,224,30,225,30,247,31,111,31,117,31,226,31,228,32,140,32,171,32,194,32,201,32,208,32,211,33,130,33,246,33,247,34,127,34,139,34,143,34,156,34,227,34,234,35,111,35,113,35,124,35,143,35,226,36,100,36,101,36,142,36,203,36,205,36,206,37,39,37,72,37,164,37,167,38,41,38,72,38,81,38,82,39,40,39,72,39,73,39,92,39,165,39,167,40,73,40,74,40,92,40,185,40,186,41,42,41,72,41,73,41,74,41,81,42,74,42,80,42,81,42,183,42,184,43,57,43,61,43,91,43,106,43,146,43,202,43,204,44,45,44,125,44,220,44,237,45,51,45,134,45,220,46,53,46,63,46,70,46,113,46,124,46,156,46,225,47,100,47,114,47,121,47,126,47,128,47,217,48,49,48,64,48,115,48,131,48,219,48,235,49,64,49,102,49,129,49,131,49,209,50,101,50,117,50,118,50,123,50,187,50,205,51,134,51,195,51,236,52,53,52,63,52,65,52,66,52,105,52,222,52,223,53,63,53,223,53,224,53,225,54,68,54,103,54,104,55,65,55,107,55,189,55,193,55,221,56,157,56,173,56,190,56,221,57,61,57,185,57,186,57,202,57,212,58,132,58,172,58,177,58,215,59,75,59,166,59,219,59,235,60,75,60,99,60,166,60,240,61,76,61,146,61,184,61,185,62,76,62,77,62,78,62,96,62,183,62,191,63,68,63,70,63,71,63,104,63,105,64,98,64,102,64,129,64,235,64,240,65,66,65,107,65,221,65,222,66,69,66,105,66,107,67,69,67,103,67,104,67,108,67,109,68,71,68,104,69,104,69,105,69,107,69,108,70,71,70,139,70,156,71,139,71,162,72,73,73,74,74,184,74,185,75,166,75,235,75,240,76,77,76,146,76,183,76,184,77,90,77,91,77,96,77,146,78,95,78,96,78,191,79,166,79,218,79,237,79,238,79,239,80,81,80,183,80,191,81,82,83,84,83,181,83,182,83,201,84,85,84,180,84,181,85,86,85,179,85,180,86,87,86,178,86,179,87,178,88,89,88,95,88,96,88,178,88,179,89,90,89,96,89,179,89,180,90,91,90,96,90,180,90,181,91,106,91,146,91,181,91,182,92,165,92,186,92,206,92,216,93,132,93,137,93,227,93,234,94,141,94,370,95,96,97,98,97,99,97,141,97,165,97,167,97,242,98,99,98,129,98,165,98,203,98,240,99,240,99,242,100,101,100,120,100,121,100,126,100,142,101,118,101,119,101,120,101,205,102,129,103,104,104,105,106,182,106,194,106,204,107,108,108,109,108,151,109,151,110,144,110,163,110,228,111,116,111,117,111,123,111,143,111,226,112,133,112,155,112,232,112,233,112,243,112,244,113,124,113,225,113,226,113,247,114,128,114,174,114,188,114,217,115,131,115,218,115,219,115,220,116,123,116,137,116,143,116,227,117,118,117,123,117,228,117,229,118,119,118,229,118,230,119,120,119,230,120,121,120,230,120,231,120,232,121,128,121,232,122,168,122,188,122,193,122,196,122,245,123,137,123,147,123,177,123,187,124,143,124,156,125,141,125,237,125,241,126,129,126,142,126,209,126,217,127,139,127,162,127,234,128,188,128,232,128,233,128,245,129,142,129,203,129,209,130,226,130,247,131,134,131,198,131,209,131,220,132,137,132,177,133,155,133,173,133,190,133,243,134,198,134,220,134,236,135,136,135,138,135,150,135,169,135,192,135,214,136,138,136,150,136,172,137,177,137,227,138,172,138,192,138,213,138,215,139,156,139,162,140,148,140,170,140,171,140,176,140,211,141,241,141,242,142,203,143,156,143,227,144,145,144,163,145,153,147,177,147,187,147,213,147,215,148,152,148,171,148,175,148,176,149,150,149,170,149,176,150,169,150,170,151,337,151,338,152,175,152,377,153,154,154,155,157,158,157,173,158,159,159,160,160,161,161,246,161,247,164,167,164,267,164,393,165,167,165,203,165,206,166,218,166,219,168,193,168,351,168,417,169,170,169,210,169,211,169,214,170,176,170,211,171,175,171,199,171,208,172,215,173,190,174,188,174,196,174,217,174,236,175,199,175,377,175,396,177,215,178,179,179,180,180,181,181,182,182,194,182,201,183,184,183,191,184,185,185,186,186,212,186,216,187,192,187,205,187,207,187,213,187,214,188,196,188,245,189,190,189,193,189,221,189,243,189,244,190,221,190,243,192,213,192,214,193,244,193,245,194,201,194,204,194,211,195,197,195,248,195,281,196,197,196,236,197,248,197,419,198,209,198,217,198,236,199,200,199,208,199,396,199,428,200,201,200,208,200,421,200,428,201,208,202,204,202,210,202,212,202,214,203,206,204,210,204,211,205,206,205,207,205,216,206,216,207,212,207,214,207,216,209,217,210,211,210,214,212,214,212,216,213,215,217,236,218,219,218,220,218,237,219,235,220,237,221,222,222,223,223,224,224,225,225,247,226,247,227,234,228,229,229,230,230,231,231,232,232,233,233,244,233,245,235,240,237,239,237,241,238,239,238,241,238,242,239,241,241,242,243,244,244,245,246,247,248,281,248,419,248,456,249,255,249,263,249,339,249,390,250,290,250,309,250,328,250,392,250,458,250,459,250,462,251,284,251,298,251,301,251,389,252,253,252,256,252,374,252,380,252,381,252,450,252,451,253,254,253,373,253,374,253,449,253,450,254,339,254,373,254,448,254,449,255,261,255,263,255,339,255,359,255,446,255,448,256,341,256,381,256,382,256,451,256,452,257,258,257,259,257,386,257,387,257,442,257,443,258,286,258,384,258,385,258,386,258,441,258,442,259,260,259,387,259,443,259,444,260,387,260,388,260,444,260,445,260,466,260,467,261,340,261,346,261,446,261,448,262,369,262,396,262,418,262,421,262,428,262,431,263,359,263,466,263,467,264,356,264,368,264,372,264,383,264,447,264,454,265,340,265,342,265,353,265,372,265,446,266,329,266,330,266,371,266,423,266,425,266,426,267,269,267,302,267,393,268,271,268,302,268,311,268,312,269,270,269,302,269,303,269,322,269,391,269,393,270,303,270,304,270,322,270,409,270,410,271,272,271,302,271,303,271,304,271,311,272,304,272,310,272,311,272,407,272,408,273,287,273,291,273,321,273,335,273,375,273,422,273,424,274,275,274,354,274,440,274,457,275,281,275,363,275,440,276,283,276,293,276,300,276,342,276,353,276,383,276,445,277,329,277,343,277,350,277,355,277,357,277,437,278,279,278,294,278,344,278,360,278,439,278,455,279,294,279,331,279,358,279,360,279,429,280,330,280,346,280,347,280,352,280,411,280,425,281,363,281,456,282,283,282,293,282,295,282,296,282,334,282,442,282,443,283,293,283,443,283,444,283,445,284,298,284,332,284,333,285,295,285,336,285,413,285,417,285,441,286,384,286,398,286,414,286,441,287,291,287,409,287,410,287,422,287,432,288,361,288,397,288,401,288,435,289,290,289,305,289,392,289,439,289,455,290,305,290,328,290,392,290,460,291,306,291,375,291,408,291,409,292,306,292,307,292,308,292,325,292,407,292,415,293,298,293,300,293,301,293,333,293,334,294,327,294,331,294,358,294,455,294,460,295,296,295,336,295,441,295,442,296,299,296,334,296,336,297,299,297,332,297,333,297,337,297,338,298,301,298,333,299,333,299,334,299,336,299,337,300,301,300,368,300,383,301,368,301,389,302,303,303,304,304,408,304,409,305,455,305,460,306,307,306,375,306,407,306,408,307,320,307,321,307,325,307,375,308,324,308,325,308,415,309,392,309,438,309,457,309,459,310,311,310,407,310,415,311,312,313,314,313,405,313,406,313,421,314,315,314,404,314,405,315,316,315,403,315,404,316,317,316,402,316,403,317,402,318,319,318,324,318,325,318,402,318,403,319,320,319,325,319,403,319,404,320,321,320,325,320,404,320,405,321,335,321,375,321,405,321,406,322,391,322,410,322,426,322,436,323,361,323,366,323,447,323,454,324,325,326,327,326,328,326,370,326,391,326,393,326,462,327,328,327,358,327,391,327,423,327,460,328,460,328,462,329,330,329,349,329,350,329,355,329,371,330,347,330,348,330,349,330,425,331,358,332,333,333,334,335,406,335,418,335,424,336,337,337,338,339,373,339,390,339,448,340,345,340,346,340,352,340,372,340,446,341,362,341,382,341,452,341,453,341,463,341,464,342,353,342,445,342,446,342,467,343,357,343,399,343,412,343,437,344,360,344,438,344,439,344,440,345,352,345,366,345,372,345,447,346,347,346,352,346,448,346,449,347,348,347,449,347,450,348,349,348,450,349,350,349,450,349,451,349,452,350,357,350,452,351,412,351,417,351,419,351,465,352,366,352,376,352,401,352,411,353,372,353,383,354,370,354,457,354,461,355,358,355,371,355,429,355,437,356,368,356,389,356,454,357,412,357,452,357,453,357,465,358,371,358,423,358,429,359,446,359,467,360,363,360,420,360,429,360,440,361,366,361,401,362,382,362,398,362,414,362,463,363,420,363,440,363,456,364,365,364,367,364,379,364,394,364,416,364,434,365,367,365,379,365,397,366,401,366,447,367,397,367,416,367,433,367,435,368,383,368,389,369,377,369,395,369,396,369,400,369,431,370,461,370,462,371,423,372,383,372,447,373,374,373,390,374,380,376,401,376,411,376,433,376,435,377,396,377,400,378,379,378,395,378,400,379,394,379,395,380,381,381,382,384,385,384,398,385,386,386,387,387,388,388,466,391,393,391,423,391,426,392,438,392,439,394,395,394,430,394,431,394,434,395,400,395,431,396,428,397,435,398,414,399,412,399,419,399,437,399,456,401,435,402,403,403,404,404,405,405,406,406,418,406,421,407,408,407,415,408,409,409,410,410,432,410,436,411,416,411,425,411,427,411,433,411,434,412,419,412,465,413,414,413,417,413,441,413,463,413,464,414,441,414,463,416,433,416,434,417,464,417,465,418,421,418,424,418,431,419,456,420,429,420,437,420,456,421,428,422,424,422,430,422,432,422,434,423,426,424,430,424,431,425,426,425,427,425,436,426,436,427,432,427,434,427,436,429,437,430,431,430,434,432,434,432,436,433,435,437,456,438,439,438,440,438,457,439,455,440,457,441,442,442,443,443,444,444,445,445,467,446,467,447,454,448,449,449,450,450,451,451,452,452,453,453,464,453,465,455,460,457,459,457,461,458,459,458,461,458,462,459,461,461,462,463,464,464,465,466,467],"c":[0,37,0,267,7,33,7,163,10,109,10,338,13,82,13,312,14,87,14,317,17,84,17,314,21,54,21,162,33,246,37,39,39,40,40,185,46,53,52,53,52,65,54,103,55,65,58,132,58,172,61,146,61,185,63,70,63,105,66,105,66,107,67,103,67,109,78,95,78,191,80,81,80,191,81,82,84,181,87,178,88,95,88,178,91,146,91,181,93,132,93,234,127,162,127,234,133,155,133,173,136,150,136,172,144,145,144,163,145,153,148,152,148,176,149,150,149,176,152,377,153,154,154,155,157,158,157,173,158,159,159,160,160,161,161,246,249,263,249,390,251,284,251,389,263,466,267,269,269,270,270,409,276,283,282,283,282,295,284,332,285,295,288,361,288,397,291,375,291,409,293,300,293,334,296,334,296,336,297,332,297,338,308,324,308,415,310,311,310,415,311,312,314,405,317,402,318,324,318,402,321,375,321,405,323,361,323,454,356,389,356,454,362,382,362,398,365,379,365,397,373,374,373,390,374,380,377,400,378,379,378,400,380,381,381,382,384,385,384,398,385,386,386,387,387,388,388,466]}
\ No newline at end of file
diff --git a/src/components/general/page-hero.tsx b/src/components/general/page-hero.tsx
new file mode 100644
index 0000000..a47aa69
--- /dev/null
+++ b/src/components/general/page-hero.tsx
@@ -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 (
+
+
+
+
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+ {children && (
+
+ {children}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/lib/layout/Divider.tsx b/src/components/lib/layout/Divider.tsx
index b5d1ffb..488857c 100644
--- a/src/components/lib/layout/Divider.tsx
+++ b/src/components/lib/layout/Divider.tsx
@@ -18,6 +18,7 @@ type Props = DetailedHTMLProps<
export default function Divider({ vertical, dashed, ...props }: Props) {
return (
);
diff --git a/src/components/lib/motion/head-script.ts b/src/components/lib/motion/head-script.ts
new file mode 100644
index 0000000..a51d087
--- /dev/null
+++ b/src/components/lib/motion/head-script.ts
@@ -0,0 +1,11 @@
+/**
+ * Inline 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){}})();`;
diff --git a/src/components/lib/motion/motion.ts b/src/components/lib/motion/motion.ts
new file mode 100644
index 0000000..4d9e40a
--- /dev/null
+++ b/src/components/lib/motion/motion.ts
@@ -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, 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("[data-reveal-stagger]").forEach(
+ revealGroup,
+ );
+
+ root.querySelectorAll(
+ `[data-reveal]:not([${DONE_ATTR}])`,
+ ).forEach((el) => {
+ el.setAttribute(DONE_ATTR, "");
+ revealOne(el, variantOf(el), delayOf(el));
+ });
+
+ root.querySelectorAll(
+ `[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());
+}
diff --git a/src/components/lib/motion/use-motion.tsx b/src/components/lib/motion/use-motion.tsx
new file mode 100644
index 0000000..af79d45
--- /dev/null
+++ b/src/components/lib/motion/use-motion.tsx
@@ -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?.();
+ };
+ }, []);
+}
diff --git a/src/components/lib/three/SceneCanvas.tsx b/src/components/lib/three/SceneCanvas.tsx
new file mode 100644
index 0000000..87dd4ea
--- /dev/null
+++ b/src/components/lib/three/SceneCanvas.tsx
@@ -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(null);
+ const canvasRef = React.useRef(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 (
+
+
+
+ );
+}
diff --git a/src/components/lib/three/materials.ts b/src/components/lib/three/materials.ts
new file mode 100644
index 0000000..6aff496
--- /dev/null
+++ b/src/components/lib/three/materials.ts
@@ -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;
+ },
+ };
+}
diff --git a/src/components/lib/three/scenes/face.ts b/src/components/lib/three/scenes/face.ts
new file mode 100644
index 0000000..57d4dd4
--- /dev/null
+++ b/src/components/lib/three/scenes/face.ts
@@ -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;
diff --git a/src/components/lib/three/scenes/globe.ts b/src/components/lib/three/scenes/globe.ts
new file mode 100644
index 0000000..e37f619
--- /dev/null
+++ b/src/components/lib/three/scenes/globe.ts
@@ -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;
diff --git a/src/components/lib/three/scenes/helix.ts b/src/components/lib/three/scenes/helix.ts
new file mode 100644
index 0000000..6551bf5
--- /dev/null
+++ b/src/components/lib/three/scenes/helix.ts
@@ -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;
diff --git a/src/components/lib/three/scenes/sphere.ts b/src/components/lib/three/scenes/sphere.ts
new file mode 100644
index 0000000..eee2519
--- /dev/null
+++ b/src/components/lib/three/scenes/sphere.ts
@@ -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();
+ 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();
+ 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();
+ 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;
diff --git a/src/components/lib/three/scenes/stack.ts b/src/components/lib/three/scenes/stack.ts
new file mode 100644
index 0000000..ef8f3bb
--- /dev/null
+++ b/src/components/lib/three/scenes/stack.ts
@@ -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();
+ 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;
diff --git a/src/components/lib/three/scenes/waves.ts b/src/components/lib/three/scenes/waves.ts
new file mode 100644
index 0000000..48de058
--- /dev/null
+++ b/src/components/lib/three/scenes/waves.ts
@@ -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;
diff --git a/src/components/lib/three/stage.ts b/src/components/lib/three/stage.ts
new file mode 100644
index 0000000..c20e0a3
--- /dev/null
+++ b/src/components/lib/three/stage.ts
@@ -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;
+
+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();
+ },
+ };
+}
diff --git a/src/components/pages/Home/(sections)/AboutSection.tsx b/src/components/pages/Home/(sections)/AboutSection.tsx
index ad69fbe..cc24697 100644
--- a/src/components/pages/Home/(sections)/AboutSection.tsx
+++ b/src/components/pages/Home/(sections)/AboutSection.tsx
@@ -9,7 +9,10 @@ import { UserRoundSearch } from "lucide-react";
export default function AboutSection() {
return (
-
+
I Build & Operate Production Systems
diff --git a/src/components/pages/Home/(sections)/MySkillsSection.tsx b/src/components/pages/Home/(sections)/MySkillsSection.tsx
index 3e52adb..33ce456 100644
--- a/src/components/pages/Home/(sections)/MySkillsSection.tsx
+++ b/src/components/pages/Home/(sections)/MySkillsSection.tsx
@@ -22,7 +22,7 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
const categories = Object.keys(skills) as (keyof typeof skills)[];
const TitleSection = (
-
+
{noTitle ? null : (
@@ -65,7 +65,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
-
+
{skills[ctgr].portfolio.map(
(portfolio, index) => {
return (
@@ -119,7 +122,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
})}
-
+
{skills[category].portfolio.map((portfolio, index) => {
return (
+
{!noTitle && (
@@ -61,7 +61,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
{ctgr}
-
+
{work[ctgr].portfolio.map(
(portfolio, index) => {
return (
@@ -113,7 +116,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
})}
-
+
{work[category].portfolio.map((portfolio, index) => {
return (
-
+
Need Help With Infrastructure?
Whether it's CI/CD, cost optimization, or production
diff --git a/src/components/pages/Home/index.tsx b/src/components/pages/Home/index.tsx
index 7c92f8f..a0ca359 100644
--- a/src/components/pages/Home/index.tsx
+++ b/src/components/pages/Home/index.tsx
@@ -7,20 +7,44 @@ import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
import { Contact, ScrollText, Terminal } from "lucide-react";
import ResumeButton from "../../general/resume-button";
+import SceneCanvas from "../../lib/three/SceneCanvas";
export default function Main() {
return (
-
-
- Benjamin Toby
-
+
+
+
+
+
+ Benjamin Toby
+
CTO, DevOps & Platform Engineer.
-
+
I help teams ship faster, cut infrastructure costs, and
achieve reliable production systems.
-
+
99.99% uptime targets
@@ -34,7 +58,11 @@ export default function Main() {
Over 80% Operations cost reduction
-
+
-
- {/*
- I Build & Operate Production Systems
- */}
- About Me
-
- I'm Benjamin Toby, a senior DevOps/Platform engineer and CTO
- at Summit Lending. I build and operate production systems
- that support real businesses.
-
+
+
+
+
+
+ What I Do
+ I focus on infrastructure and reliability:
+
+ CI/CD automation and deployment pipelines
+
+ Multi-cloud architecture (AWS, GCP, Azure, Hetzner)
+
+ NGINX hardening, TLS, caching, rate limiting
+ Full-stack delivery with Next.js, Bun, and SQL
+
-
+
- What I Do
- I focus on infrastructure and reliability:
-
- CI/CD automation and deployment pipelines
- Multi-cloud architecture (AWS, GCP, Azure, Hetzner)
- NGINX hardening, TLS, caching, rate limiting
- Full-stack delivery with Next.js, Bun, and SQL
-
+
-
+ Background
+
+ 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.
+
+
+ Along the way I built TurboCI, a deployment
+ orchestration tool, and Datasquirel, a cloud SQL
+ platform. I write about what I learn on my{" "}
+ blog .
+
-
+
- Background
-
- 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.
-
-
- Along the way I built TurboCI, a deployment orchestration
- tool, and Datasquirel, a cloud SQL platform. I write about
- what I learn on my blog .
-
+
+ I focus on measurable outcomes: faster deployments,
+ lower costs, fewer incidents.
+
-
-
-
- I focus on measurable outcomes: faster deployments, lower
- costs, fewer incidents.
-
-
-
-
- }
- href="/work"
- className="grow w-full bg-transparent!"
- variant="outlined"
- >
- View My Work
-
-
-
-
+
+
+ }
+ href="/work"
+ className="grow w-full bg-transparent!"
+ variant="outlined"
+ >
+ View My Work
+
+
+
+
+
);
}
diff --git a/src/components/pages/blog/(sections)/BlogPostsList.tsx b/src/components/pages/blog/(sections)/BlogPostsList.tsx
index a81de42..e414598 100644
--- a/src/components/pages/blog/(sections)/BlogPostsList.tsx
+++ b/src/components/pages/blog/(sections)/BlogPostsList.tsx
@@ -28,7 +28,7 @@ export default function BlogPostsList() {
return (
-
+
{blogPosts.map((post, index) => {
return ;
})}
diff --git a/src/components/pages/blog/(sections)/Hero.tsx b/src/components/pages/blog/(sections)/Hero.tsx
index 7bf0309..73e4188 100644
--- a/src/components/pages/blog/(sections)/Hero.tsx
+++ b/src/components/pages/blog/(sections)/Hero.tsx
@@ -1,22 +1,12 @@
-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 { AppContext } from "@/src/pages/__root";
-import React from "react";
+import PageHero from "@/src/components/general/page-hero";
export default function Hero() {
- const { pageProps } = React.useContext(AppContext);
-
return (
-
-
- Tech Musings
-
- A few takes and tips from my experience and knowledge in the
- tech industry
-
-
-
+
);
}
diff --git a/src/components/pages/blog/slug/index.tsx b/src/components/pages/blog/slug/index.tsx
index 6763e7d..891a85f 100644
--- a/src/components/pages/blog/slug/index.tsx
+++ b/src/components/pages/blog/slug/index.tsx
@@ -19,7 +19,9 @@ export default function Main() {
{/* */}
- {blogPost?.title}
+
+ {blogPost?.title}
+
{blogPost?.excerpt}
-
- Contact Me
-
- Need help with CI/CD, cost optimization, or production
- reliability? Book a call or send a message.
-
-
-
-
-
- ben@tben.me
-
-
-
-
+
+
+
+
+ ben@tben.me
+
+
+
);
}
diff --git a/src/components/pages/skills/index.tsx b/src/components/pages/skills/index.tsx
index 6d2cd9f..b5f606d 100644
--- a/src/components/pages/skills/index.tsx
+++ b/src/components/pages/skills/index.tsx
@@ -1,15 +1,12 @@
-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 PageHero from "../../general/page-hero";
export default function Main() {
return (
-
-
- My Skills
- A summary of the tools I've mastered over the years
-
-
+
);
}
diff --git a/src/components/pages/work/index.tsx b/src/components/pages/work/index.tsx
index 26e132b..7914cad 100644
--- a/src/components/pages/work/index.tsx
+++ b/src/components/pages/work/index.tsx
@@ -1,15 +1,12 @@
-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 PageHero from "../../general/page-hero";
export default function Main() {
return (
-
-
- My Work
- Some of My Work
-
-
+
);
}
diff --git a/src/layouts/main/Aside.tsx b/src/layouts/main/Aside.tsx
index aa5f210..fd09bff 100644
--- a/src/layouts/main/Aside.tsx
+++ b/src/layouts/main/Aside.tsx
@@ -21,10 +21,14 @@ export default function Aside({}: Props) {
"px-6 py-4 h-[var(--header-height)] border-0",
"border-b border-white/10",
)}
+ data-reveal="fade"
>
-
+
{SocialLinks.map((link, index) => {
return ;
})}
diff --git a/src/layouts/main/Header.tsx b/src/layouts/main/Header.tsx
index 20dd7fe..649fcd8 100644
--- a/src/layouts/main/Header.tsx
+++ b/src/layouts/main/Header.tsx
@@ -21,10 +21,13 @@ export default function Header({ menuOpen, setMenuOpen }: Props) {
"bg-background-dark z-10",
)}
>
-
+
-
+
diff --git a/src/pages/(partials)/root-head.tsx b/src/pages/(partials)/root-head.tsx
index 91a7ebd..713f2b2 100644
--- a/src/pages/(partials)/root-head.tsx
+++ b/src/pages/(partials)/root-head.tsx
@@ -1,13 +1,17 @@
import { SchemaJSON } from "@/src/layouts/main/(data)/schema";
+import { MotionHeadScript } from "@/src/components/lib/motion/head-script";
import { BunextPageHeadFCProps } from "@moduletrace/bunext/types";
export const RootHead = ({}: BunextPageHeadFCProps) => {
return (
-
+ <>
+
+
+ >
);
};
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 261070c..dd6c2d5 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -103,3 +103,14 @@
.markdown-rendered hr {
@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;
+}