From 87ca723bd083f93ea9241f85ec4685431413b7aa Mon Sep 17 00:00:00 2001 From: joye Date: Mon, 22 Jun 2026 15:59:25 +1000 Subject: [PATCH 01/21] feat(intro): cinematic 3D particle entry + zoom reveal (skeleton) Adds a Three.js-driven cinematic intro overlay on the landing page: - SSR renders a dark overlay shell + an inline script that decides play/skip synchronously (prefers-reduced-motion or sessionStorage flag) - src/scripts/intro.ts lazy-imports three + gsap so they never enter the first-paint critical path - Phase 1 (0~0.9s): 5000-particle cloud fades in (2200 on mobile) - Phase 2 (0.9~1.4s): hold - Phase 3 (1.4~2.3s): cloud expands + dissolves, camera dollies in - Phase 4 (2.0~2.8s): overlay dissolves, hero zooms from scale(0.94)+blur(8px) to scale(1)+blur(0) via GSAP - Locks the page (overflow hidden) and suppresses the default .animate fade-in-up while active so GSAP can take over hero transitions - 4.5s hard timeout fallback in case WebGL boot fails - Adds @/scripts/* path alias Phase 2 (particles forming the avatar silhouette) and the shader-based noise dissolution are intentionally deferred until the base vibe feels right. New deps: three, @types/three --- bun.lock | 20 +- package.json | 2 + src/components/intro/IntroOverlay.astro | 49 +++++ src/components/intro/intro.css | 96 +++++++++ src/pages/index.astro | 2 + src/scripts/intro.ts | 275 ++++++++++++++++++++++++ tsconfig.json | 3 +- 7 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 src/components/intro/IntroOverlay.astro create mode 100644 src/components/intro/intro.css create mode 100644 src/scripts/intro.ts diff --git a/bun.lock b/bun.lock index eee564d..d9ca28d 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@resvg/resvg-js": "^2.6.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.1", "@unocss/preset-wind3": "^66.1.3", "@unocss/reset": "^66.1.3", "@vercel/analytics": "^1.6.1", @@ -29,6 +30,7 @@ "remark-math": "^6.0.0", "satori": "^0.26.0", "sharp": "^0.34.2", + "three": "^0.184.0", "typescript": "^5.8.3", }, "devDependencies": { @@ -120,6 +122,8 @@ "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "https://registry.npmmirror.com/@capsizecss/unpack/-/unpack-2.4.0.tgz", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "https://registry.npmmirror.com/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "https://registry.npmmirror.com/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], @@ -420,6 +424,8 @@ "@trysound/sax": ["@trysound/sax@0.2.0", "https://registry.npmmirror.com/@trysound/sax/-/sax-0.2.0.tgz", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@types/acorn": ["@types/acorn@4.0.6", "https://registry.npmmirror.com/@types/acorn/-/acorn-4.0.6.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -466,10 +472,16 @@ "@types/sax": ["@types/sax@1.2.7", "https://registry.npmmirror.com/@types/sax/-/sax-1.2.7.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], + "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.33.0", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/type-utils": "8.33.0", "@typescript-eslint/utils": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.33.0", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.33.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ=="], @@ -898,7 +910,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -1160,6 +1172,8 @@ "merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + "micromark": ["micromark@4.0.1", "https://registry.npmmirror.com/micromark/-/micromark-4.0.1.tgz", { "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-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.2", "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", { "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-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w=="], @@ -1522,6 +1536,8 @@ "terser": ["terser@5.39.0", "https://registry.npmmirror.com/terser/-/terser-5.39.0.tgz", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw=="], + "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "https://registry.npmmirror.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tinyexec": ["tinyexec@0.3.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -1770,6 +1786,8 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@types/babel__core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], diff --git a/package.json b/package.json index ccbca53..25c0b70 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@resvg/resvg-js": "^2.6.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.1", "@unocss/preset-wind3": "^66.1.3", "@unocss/reset": "^66.1.3", "@vercel/analytics": "^1.6.1", @@ -44,6 +45,7 @@ "remark-math": "^6.0.0", "satori": "^0.26.0", "sharp": "^0.34.2", + "three": "^0.184.0", "typescript": "^5.8.3" }, "devDependencies": { diff --git a/src/components/intro/IntroOverlay.astro b/src/components/intro/IntroOverlay.astro new file mode 100644 index 0000000..dc7719b --- /dev/null +++ b/src/components/intro/IntroOverlay.astro @@ -0,0 +1,49 @@ +--- +/** + * IntroOverlay — cinematic 3D particle entry animation. + * + * Strategy: + * 1. SSR renders a dark overlay shell (#intro-overlay) + canvas immediately. + * 2. An inline script in runs ASAP to decide whether to play: + * - skip if sessionStorage says we've played this session + * - skip if prefers-reduced-motion + * - otherwise lock the page (`intro-active` class) + * 3. The heavy three.js logic is lazy-loaded via a module script so it never + * blocks first paint of the document. + */ +import './intro.css' +--- + +{ + /* Inline script — runs synchronously during parse, before body exists. + Sets a class on so CSS can hide the overlay immediately if needed, + without waiting for any JS bundle. */ +} + + + + + + diff --git a/src/components/intro/intro.css b/src/components/intro/intro.css new file mode 100644 index 0000000..fe0e609 --- /dev/null +++ b/src/components/intro/intro.css @@ -0,0 +1,96 @@ +/* ===== Intro Cinematic Overlay ===== */ + +/* Lock the page while the intro is playing. */ +html.intro-active { + overflow: hidden; + height: 100vh; +} + +/* Suppress the default `.animate` fade-in-up so it doesn't run behind the overlay. + GSAP takes over the hero zoom-in once the overlay starts dissolving. */ +html.intro-active .animate { + animation: none !important; +} + +/* Hero starts in a zoomed-in / blurred state; GSAP animates it back to neutral. + ID selector (1,1,1) outranks the .animate rule (0,2,1) so opacity:0 wins. */ +html.intro-active #content-header, +html.intro-active #content { + opacity: 0; + transform: scale(0.94); + filter: blur(8px); + transition: none; + will-change: transform, opacity, filter; +} + +/* If the visitor already saw the intro this session, skip entirely. */ +html.intro-skip #intro-overlay { + display: none !important; +} + +/* Full-screen cinematic overlay. */ +#intro-overlay { + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + background: + radial-gradient(ellipse at 50% 45%, rgba(82, 125, 148, 0.18) 0%, rgba(5, 10, 16, 0) 60%), + radial-gradient(circle at center, #0c1a25 0%, #050a10 100%); + opacity: 1; + transition: opacity 0.6s ease-out; +} + +#intro-overlay.intro-hidden { + opacity: 0; +} + +#intro-overlay.intro-done { + display: none; +} + +#intro-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} + +/* Tiny progress hint in case the cold-start of three.js takes a moment. */ +#intro-overlay::after { + content: ''; + position: absolute; + left: 50%; + bottom: 14%; + width: 36px; + height: 2px; + margin-left: -18px; + background: linear-gradient(90deg, transparent, rgba(82, 125, 148, 0.7), transparent); + opacity: 0; + animation: intro-pulse 1.6s ease-in-out infinite; + animation-delay: 0.6s; +} + +@keyframes intro-pulse { + 0%, + 100% { + opacity: 0; + transform: scaleX(0.4); + } + 50% { + opacity: 0.9; + transform: scaleX(1); + } +} + +html.intro-hidden #intro-overlay::after { + animation: none; +} + +/* Respect reduced-motion: never show the overlay. */ +@media (prefers-reduced-motion: reduce) { + #intro-overlay { + display: none !important; + } +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 66aaead..b872f6b 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -16,6 +16,7 @@ import JoJo from '@/components/mascot/JoJo' import Terminal from '@/components/terminal/Terminal.astro' import config from '@/site-config' import { formatTalkDateCN, sortTalks } from '@/lib/talks' +import IntroOverlay from '@/components/intro/IntroOverlay.astro' export const prerender = true @@ -91,6 +92,7 @@ const latestTalk = talks[0] --- +
+ * - prefers-reduced-motion is not set + * + * Heavy deps (three, gsap) are imported dynamically here so they never enter + * the first-paint critical path. + */ + +const SKIP = + document.documentElement.classList.contains('intro-skip') || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + +if (!SKIP) { + void runIntro().catch((err) => { + console.warn('[intro] aborted:', err) + revealImmediately() + }) +} + +async function runIntro() { + const canvas = document.getElementById('intro-canvas') as HTMLCanvasElement | null + const overlay = document.getElementById('intro-overlay') + if (!canvas || !overlay) { + revealImmediately() + return + } + + const [THREE, { gsap }] = await Promise.all([ + import('three'), + import('gsap') + ]) + + const isMobile = window.matchMedia('(max-width: 768px)').matches + const COUNT = isMobile ? 2200 : 5000 + const PRIMARY = new THREE.Color('#527D94') + const WARM = new THREE.Color('#D88D72') + const MINT = new THREE.Color('#86B9A7') + const BRIGHT = new THREE.Color('#E8EEF2') + + // === Scene setup === + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: !isMobile + }) + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + renderer.setSize(window.innerWidth, window.innerHeight) + renderer.setClearColor(0x000000, 0) + + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 100 + ) + camera.position.set(0, 0, 11) + + // === Particle geometry: spherical shell, weighted color palette === + const positions = new Float32Array(COUNT * 3) + const sizes = new Float32Array(COUNT) + const colors = new Float32Array(COUNT * 3) + + for (let i = 0; i < COUNT; i++) { + const r = 4 + Math.random() * 5 + const theta = Math.random() * Math.PI * 2 + const phi = Math.acos(2 * Math.random() - 1) + positions[i * 3] = r * Math.sin(phi) * Math.cos(theta) + positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta) + positions[i * 3 + 2] = r * Math.cos(phi) + + sizes[i] = 0.6 + Math.random() * 1.2 + + const rnd = Math.random() + const c = + rnd < 0.7 ? PRIMARY : rnd < 0.88 ? WARM : rnd < 0.96 ? MINT : BRIGHT + colors[i * 3] = c.r + colors[i * 3 + 1] = c.g + colors[i * 3 + 2] = c.b + } + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)) + geometry.setAttribute('aColor', new THREE.BufferAttribute(colors, 3)) + + // === Shader material: soft round particles with drift + glow === + const material = new THREE.ShaderMaterial({ + uniforms: { + uTime: { value: 0 }, + uOpacity: { value: 0 }, + uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) } + }, + vertexShader: /* glsl */ ` + attribute float aSize; + attribute vec3 aColor; + uniform float uTime; + uniform float uPixelRatio; + varying vec3 vColor; + void main() { + vColor = aColor; + vec3 pos = position; + // gentle drift — each particle sways based on its own position + pos.x += sin(uTime * 0.4 + position.y * 0.5) * 0.18; + pos.y += cos(uTime * 0.3 + position.x * 0.4) * 0.18; + pos.z += sin(uTime * 0.25 + position.x * 0.3) * 0.10; + vec4 mv = modelViewMatrix * vec4(pos, 1.0); + gl_Position = projectionMatrix * mv; + gl_PointSize = aSize * uPixelRatio * (200.0 / -mv.z); + } + `, + fragmentShader: /* glsl */ ` + uniform float uOpacity; + varying vec3 vColor; + void main() { + vec2 uv = gl_PointCoord - vec2(0.5); + float d = length(uv); + if (d > 0.5) discard; + float a = smoothstep(0.5, 0.0, d); + a *= a; + gl_FragColor = vec4(vColor, a * uOpacity); + } + `, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending + }) + + const particles = new THREE.Points(geometry, material) + scene.add(particles) + + // === Pointer parallax (desktop only) === + let targetX = 0 + let targetY = 0 + let mouseX = 0 + let mouseY = 0 + const onPointerMove = (e: PointerEvent) => { + mouseX = (e.clientX / window.innerWidth) * 2 - 1 + mouseY = -((e.clientY / window.innerHeight) * 2 - 1) + } + if (!isMobile) { + window.addEventListener('pointermove', onPointerMove, { passive: true }) + } + + const onResize = () => { + renderer.setSize(window.innerWidth, window.innerHeight) + camera.aspect = window.innerWidth / window.innerHeight + camera.updateProjectionMatrix() + } + window.addEventListener('resize', onResize) + + // === Render loop === + let raf = 0 + const state = { opacity: 0 } + let frameCount = 0 + let lastTickAt = 0 + const tick = (time: number) => { + raf = requestAnimationFrame(tick) + frameCount++ + lastTickAt = performance.now() + targetX += (mouseX * 1.4 - targetX) * 0.04 + targetY += (mouseY * 0.9 - targetY) * 0.04 + camera.position.x = targetX + camera.position.y = targetY + camera.lookAt(0, 0, 0) + + particles.rotation.y += 0.0016 + particles.rotation.x += 0.0004 + + material.uniforms.uTime.value = time * 0.001 + material.uniforms.uOpacity.value = state.opacity + renderer.render(scene, camera) + } + raf = requestAnimationFrame(tick) + + // Debug surface — only in dev, handy for tuning from the console. + if (import.meta.env.DEV) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).__intro = { + get state() { + return { + frameCount, + lastTickAgoMs: Math.round(performance.now() - lastTickAt), + opacity: state.opacity, + canvasSize: canvas.width + 'x' + canvas.height, + particlesRotY: particles.rotation.y.toFixed(3), + camPos: camera.position.toArray().map((n: number) => n.toFixed(2)).join(',') + } + }, + renderer, + scene, + camera, + particles + } + } + + // === Cleanup === + let cleaned = false + const cleanup = () => { + if (cleaned) return + cleaned = true + clearTimeout(fallback) + cancelAnimationFrame(raf) + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('resize', onResize) + renderer.dispose() + geometry.dispose() + material.dispose() + overlay.classList.add('intro-done') + document.documentElement.classList.remove('intro-active') + gsap.set(['#content-header', '#content'], { clearProps: 'all' }) + } + + // Hard fallback — never let the overlay get stuck. + const fallback = setTimeout(() => { + console.warn('[intro] hard timeout, forcing reveal') + cleanup() + }, 4500) + + // === Timeline === + const tl = gsap.timeline({ onComplete: cleanup }) + + // Phase 1: particle cloud fades in (0 ~ 0.9s) + tl.to(state, { opacity: 1, duration: 0.9, ease: 'power2.out' }) + + // Phase 2: hold (0.9 ~ 1.4s) — let the cloud breathe + tl.to({}, { duration: 0.5 }) + + // Phase 3: cloud expands + dissipates (1.4 ~ 2.3s) + tl.to(particles.scale, { x: 1.8, y: 1.8, z: 1.8, duration: 0.9, ease: 'power2.in' }) + tl.to(camera.position, { z: 7.5, duration: 0.9, ease: 'power2.in' }, '<') + tl.to(state, { opacity: 0, duration: 0.7, ease: 'power2.in' }, '<+0.2') + + // Phase 4: overlay dissolves (2.0 ~ 2.6s) + hero zoom-in runs in parallel + tl.to(overlay, { + opacity: 0, + duration: 0.6, + ease: 'power2.inOut', + onComplete: () => overlay.classList.add('intro-hidden') + }, '-=0.4') + + // Unlock the page so GSAP can take over hero transitions + tl.add(() => { + document.documentElement.classList.remove('intro-active') + document.documentElement.classList.add('intro-hidden') + }, '<+0.05') + + tl.to('#content-header', { + scale: 1, + opacity: 1, + filter: 'blur(0px)', + duration: 0.9, + ease: 'power3.out' + }, '<+0.02') + + tl.to('#content', { + scale: 1, + opacity: 1, + filter: 'blur(0px)', + duration: 0.9, + ease: 'power3.out' + }, '<+0.08') +} + +/** Safety net — if three.js fails to boot, just reveal the page. */ +function revealImmediately() { + const overlay = document.getElementById('intro-overlay') + const doc = document.documentElement + doc.classList.remove('intro-active') + doc.classList.add('intro-skip') + if (overlay) overlay.classList.add('intro-done') +} diff --git a/tsconfig.json b/tsconfig.json index 5721129..3f7b551 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,8 @@ "@/pages/*": ["src/pages/*"], "@/types": ["src/types/index.ts"], "@/site-config": ["src/site.config.ts"], - "@/data/*": ["src/data/*"] + "@/data/*": ["src/data/*"], + "@/scripts/*": ["src/scripts/*"] } }, "exclude": ["node_modules", "**/node_modules/*", ".vscode", "dist", "public/scripts/*", "test/*"] From a616704136200c4d20647da3c395614edbe8c070 Mon Sep 17 00:00:00 2001 From: joye Date: Mon, 22 Jun 2026 17:49:50 +1000 Subject: [PATCH 02/21] feat(intro): rewrite with ASCII-particle avatar assemble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous Three.js particle cloud (which didn't fit the terminal/CLI/minimalist theme DNA) with a Canvas 2D ASCII-particle version that matches the blog's visual language: - Drops three.js entirely (-150KB), keeps only gsap (already a dep) - Particles are now monospace glyphs (█▓▒░*··>/<_0123…) rendered via Canvas 2D - Strictly monochrome — single primary blue, no rainbow palette - Phase 1 ENTER (0~1.5s): chars fly in from off-screen - Phase 2 CHAOS (1.5~3.5s): chars drift + flicker (data-stream feel) - Phase 3 ASSEMBLE (3.5~6s): chars lerp to avatar silhouette targets, shifting from decode glyphs to dense block glyphs as they approach - Phase 4 HOLD (6~7s): avatar complete, micro-flicker on outline - Phase 5 REVEAL (7~8.5s): chars burst outward, overlay dissolves, hero zooms from scale(.94)+blur(8px) - Avatar PNG URL is passed via data-attribute and sampled client-side (alpha channel → ~1200 target points on a 220x220 grid) - Subtle scanline pattern reinforces terminal DNA - New dev-only Replay button (top-right) clears sessionStorage + reloads - Dev-mode __intro.state hook for live tuning from the console --- bun.lock | 20 +- package.json | 2 - src/components/intro/IntroOverlay.astro | 40 +- src/components/intro/intro.css | 82 ++-- src/scripts/intro.ts | 601 +++++++++++++++++------- 5 files changed, 510 insertions(+), 235 deletions(-) diff --git a/bun.lock b/bun.lock index d9ca28d..eee564d 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,6 @@ "@resvg/resvg-js": "^2.6.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/three": "^0.184.1", "@unocss/preset-wind3": "^66.1.3", "@unocss/reset": "^66.1.3", "@vercel/analytics": "^1.6.1", @@ -30,7 +29,6 @@ "remark-math": "^6.0.0", "satori": "^0.26.0", "sharp": "^0.34.2", - "three": "^0.184.0", "typescript": "^5.8.3", }, "devDependencies": { @@ -122,8 +120,6 @@ "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "https://registry.npmmirror.com/@capsizecss/unpack/-/unpack-2.4.0.tgz", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], - "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], - "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "https://registry.npmmirror.com/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "https://registry.npmmirror.com/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], @@ -424,8 +420,6 @@ "@trysound/sax": ["@trysound/sax@0.2.0", "https://registry.npmmirror.com/@trysound/sax/-/sax-0.2.0.tgz", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], - "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], - "@types/acorn": ["@types/acorn@4.0.6", "https://registry.npmmirror.com/@types/acorn/-/acorn-4.0.6.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -472,16 +466,10 @@ "@types/sax": ["@types/sax@1.2.7", "https://registry.npmmirror.com/@types/sax/-/sax-1.2.7.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], - "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], - - "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], - "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], - "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.33.0", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/type-utils": "8.33.0", "@typescript-eslint/utils": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.33.0", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.33.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ=="], @@ -910,7 +898,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -1172,8 +1160,6 @@ "merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], - "micromark": ["micromark@4.0.1", "https://registry.npmmirror.com/micromark/-/micromark-4.0.1.tgz", { "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-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.2", "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", { "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-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w=="], @@ -1536,8 +1522,6 @@ "terser": ["terser@5.39.0", "https://registry.npmmirror.com/terser/-/terser-5.39.0.tgz", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw=="], - "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], - "tiny-inflate": ["tiny-inflate@1.0.3", "https://registry.npmmirror.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tinyexec": ["tinyexec@0.3.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -1786,8 +1770,6 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "@types/babel__core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@types/babel__core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], diff --git a/package.json b/package.json index 25c0b70..ccbca53 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "@resvg/resvg-js": "^2.6.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/three": "^0.184.1", "@unocss/preset-wind3": "^66.1.3", "@unocss/reset": "^66.1.3", "@vercel/analytics": "^1.6.1", @@ -45,7 +44,6 @@ "remark-math": "^6.0.0", "satori": "^0.26.0", "sharp": "^0.34.2", - "three": "^0.184.0", "typescript": "^5.8.3" }, "devDependencies": { diff --git a/src/components/intro/IntroOverlay.astro b/src/components/intro/IntroOverlay.astro index dc7719b..869e922 100644 --- a/src/components/intro/IntroOverlay.astro +++ b/src/components/intro/IntroOverlay.astro @@ -1,6 +1,6 @@ --- /** - * IntroOverlay — cinematic 3D particle entry animation. + * IntroOverlay — cinematic ASCII-particle entry animation. * * Strategy: * 1. SSR renders a dark overlay shell (#intro-overlay) + canvas immediately. @@ -8,10 +8,16 @@ * - skip if sessionStorage says we've played this session * - skip if prefers-reduced-motion * - otherwise lock the page (`intro-active` class) - * 3. The heavy three.js logic is lazy-loaded via a module script so it never - * blocks first paint of the document. + * 3. The heavy logic is lazy-loaded via a module script so it never blocks + * first paint of the document. + * 4. The avatar PNG import URL is passed as a data attribute so the client + * can sample its silhouette without an extra fetch round-trip. + * 5. A dev-only replay button lets you re-watch the animation locally. */ +import avatar from 'src/assets/avatar.png' import './intro.css' + +const isDev = import.meta.env.DEV --- { @@ -40,10 +46,36 @@ import './intro.css' })() -