diff --git a/.intro-archive/intro-3d-embedding.ts.bak b/.intro-archive/intro-3d-embedding.ts.bak new file mode 100644 index 0000000..0869cc1 --- /dev/null +++ b/.intro-archive/intro-3d-embedding.ts.bak @@ -0,0 +1,729 @@ +/** + * Intro Cinematic — "Embedding Space Dive" + * + * A 3D journey through a concept-space (Agent / LLM / Code / RAG clusters) + * connected by attention lines, culminating in the tokens converging into + * the author's avatar silhouette. + * + * Phases (~11s total): + * 1 BOOT 0 ~ 1.5s "initializing joye@mind..." terminal line + * 2 DIVE 1.5 ~ 5.0s camera dives into the cluster cloud, + * tokens light up, attention pulses flow + * 3 CONVERGE 5.0 ~ 8.0s tokens fall toward the global center, + * clusters dissolve + * 4 MATERIALIZE 8.0 ~ 9.5s tokens lerp onto the avatar silhouette + * 5 REVEAL 9.5 ~ 11s overlay dissolves, hero zooms in + */ + +import gsap from 'gsap' +import * as THREE from 'three' + +const SKIP = + document.documentElement.classList.contains('intro-skip') || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + +if (!SKIP) { + void runIntro().catch((err) => { + if (import.meta.env.DEV) { + try { + localStorage.setItem( + 'intro-last-error', + `[${new Date().toISOString()}] ${err instanceof Error ? err.stack || err.message : String(err)}` + ) + } catch {} + } + console.warn('[intro] aborted:', err) + revealImmediately() + }) +} + +// === Concept clusters — each represents a domain of the author's work === +type Cluster = { + name: string + center: [number, number, number] + color: THREE.Color + /** Number of tokens in this cluster (rest are ambient). */ + tokenCount: number +} + +const PRIMARY = new THREE.Color('#7BB8D4') // lightened primary blue (good on dark) +const ACCENT_AGENT = new THREE.Color('#9CCADD') +const ACCENT_LLM = new THREE.Color('#B8D4E2') +const ACCENT_CODE = new THREE.Color('#8FB3C7') +const ACCENT_RAG = new THREE.Color('#6E96B0') + +const CLUSTERS: Cluster[] = [ + { + name: 'Agent', + center: [-9, 3, -2], + color: ACCENT_AGENT, + tokenCount: 22 + }, + { + name: 'LLM', + center: [7, 4, -4], + color: ACCENT_LLM, + tokenCount: 22 + }, + { + name: 'Code', + center: [-5, -5, 4], + color: ACCENT_CODE, + tokenCount: 22 + }, + { + name: 'RAG', + center: [8, -3, 2], + color: ACCENT_RAG, + tokenCount: 22 + } +] + +const AMBIENT_COUNT = 220 // free-floating points scattered across the scene + +async function runIntro() { + const overlay = document.getElementById('intro-overlay') + const canvas = document.getElementById('intro-canvas') as HTMLCanvasElement | null + if (!overlay || !canvas) { + revealImmediately() + return + } + + // Defensive: ensure the page is scrolled to the top before measuring the + // hero avatar's position. The inline script in IntroOverlay.astro tries to + // do this too, but module scripts run on a later tick — make sure no other + // script has scrolled the page in between. + window.scrollTo(0, 0) + + // === Renderer === + const isMobile = window.matchMedia('(max-width: 768px)').matches + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: !isMobile + }) + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) + + const resize = () => { + renderer.setSize(window.innerWidth, window.innerHeight, false) + camera.aspect = window.innerWidth / window.innerHeight + camera.updateProjectionMatrix() + } + + // === Scene + Camera === + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera( + 62, + window.innerWidth / window.innerHeight, + 0.1, + 100 + ) + // far away initially — dive begins from here + camera.position.set(0, 1.5, 26) + camera.lookAt(0, 0, 0) + resize() + window.addEventListener('resize', resize) + + // === Resolve avatar silhouette target points === + const targetPoints = await loadSilhouettePoints(overlay.dataset.avatar || '') + const realAvatar = + (document.querySelector('#content-header img') as HTMLImageElement | null) ?? + (document.querySelector('main img[alt="profile"]') as HTMLImageElement | null) + const avatarRect = realAvatar?.getBoundingClientRect() + + // Map silhouette sample coords ([-110, 110]) into NDC, then into the 3D + // plane at z=0 such that the assembled silhouette lands exactly over the + // real hero when the camera is at its reveal position. + const revealCamZ = 11 + const revealFov = camera.fov + const revealHalfH = Math.tan((revealFov * Math.PI) / 360) * revealCamZ + const revealHalfW = revealHalfH * camera.aspect + // Map viewport pixel → world unit at z=0 from reveal camera. + const pxToNdcX = (pxX: number) => (pxX / window.innerWidth) * 2 - 1 + const pxToNdcY = (pxY: number) => + -((pxY / window.innerHeight) * 2 - 1) // flip Y (WebGL Y up) + const avatarCenterNdcX = avatarRect + ? pxToNdcX(avatarRect.left + avatarRect.width / 2) + : 0 + const avatarCenterNdcY = avatarRect + ? pxToNdcY(avatarRect.top + avatarRect.height / 2) + : 0 + const avatarCenterWorldX = avatarCenterNdcX * revealHalfW + const avatarCenterWorldY = avatarCenterNdcY * revealHalfH + + // === Build per-token data === + type TokenData = { + // initial cluster-relative position (used during DIVE) + home: THREE.Vector3 + // target on the avatar silhouette (used during MATERIALIZE) + target: THREE.Vector3 + hasTarget: boolean + clusterIdx: number + // phase weights (0..1) used by shader to fade in / converge + fadeIn: number + converge: number + materialize: number + } + + const tokenData: TokenData[] = [] + + // Cluster tokens + CLUSTERS.forEach((cluster, ci) => { + for (let i = 0; i < cluster.tokenCount; i++) { + // distribute within a sphere of radius 2.6 around cluster center + const r = 0.4 + Math.random() * 2.2 + const theta = Math.random() * Math.PI * 2 + const phi = Math.acos(2 * Math.random() - 1) + const home = new THREE.Vector3( + cluster.center[0] + r * Math.sin(phi) * Math.cos(theta), + cluster.center[1] + r * Math.sin(phi) * Math.sin(theta), + cluster.center[2] + r * Math.cos(phi) * 0.7 + ) + tokenData.push({ + home: home, + target: new THREE.Vector3(), + hasTarget: false, + clusterIdx: ci, + fadeIn: 0, + converge: 0, + materialize: 0 + }) + } + }) + // Ambient tokens scattered across the whole scene + for (let i = 0; i < AMBIENT_COUNT; i++) { + const home = new THREE.Vector3( + (Math.random() - 0.5) * 30, + (Math.random() - 0.5) * 18, + (Math.random() - 0.5) * 14 - 2 + ) + tokenData.push({ + home, + target: new THREE.Vector3(), + hasTarget: false, + clusterIdx: -1, + fadeIn: 0, + converge: 0, + materialize: 0 + }) + } + + // Assign avatar silhouette targets to the first N tokens (cluster ones + // preferred so the silhouette feels meaningful, not random). + // Spread targets across all clusters proportionally so each contributes. + const targetAssignmentOrder: number[] = [] + // round-robin across clusters + const perCluster = Math.min( + Math.ceil(targetPoints.length / CLUSTERS.length), + CLUSTERS[0].tokenCount + ) + for (let i = 0; i < perCluster; i++) { + CLUSTERS.forEach((_c, ci) => { + const tokenIdx = ci * CLUSTERS[0].tokenCount + i + if (tokenIdx < tokenData.length) targetAssignmentOrder.push(tokenIdx) + }) + } + // Fixed silhouette size in world units — independent of how much of the + // 220x220 sample grid the avatar actually fills. Height = ~22% of viewport + // (≈ revealHalfH * 0.45) so the particle avatar is clearly visible but + // never spills off the top of the screen. + // + // First compute the actual bounding box of the sampled silhouette points + // so we can scale them uniformly (preserving the PNG's intrinsic aspect + // ratio — the avatar is much wider than tall, head+shoulders). + let minSX = Infinity, maxSX = -Infinity + let minSY = Infinity, maxSY = -Infinity + for (const pt of targetPoints) { + if (pt.x < minSX) minSX = pt.x + if (pt.x > maxSX) maxSX = pt.x + if (pt.y < minSY) minSY = pt.y + if (pt.y > maxSY) maxSY = pt.y + } + const sampleH = Math.max(1, maxSY - minSY) + const sampleMidX = (minSX + maxSX) / 2 + const sampleMidY = (minSY + maxSY) / 2 + // Height = ~27% of viewport (revealHalfH * 0.55). Cap is set so the + // top of the silhouette never crosses past the top edge of the screen + // (avatarCenterWorldY + silhouetteWorldHeight/2 ≤ revealHalfH). + const silhouetteWorldHeight = revealHalfH * 0.55 + const uniformScale = silhouetteWorldHeight / sampleH + + targetPoints.forEach((pt, i) => { + const tokenIdx = targetAssignmentOrder[i] ?? i + const t = tokenData[tokenIdx] + if (!t) return + t.hasTarget = true + // Note the Y flip: PNG pixel Y grows downward, world Y grows upward. + t.target.set( + avatarCenterWorldX + (pt.x - sampleMidX) * uniformScale, + avatarCenterWorldY - (pt.y - sampleMidY) * uniformScale, + 0 + ) + }) + + // === Token points — BufferGeometry + Points with custom shader === + // Each token is one vertex; the vertex shader computes its position from + // home/converge/materialize states, and the fragment shader paints a soft + // glowing dot (much more reliable than InstancedMesh + InstancedBufferAttribute). + const TOKEN_COUNT = tokenData.length + const tokenPositions = new Float32Array(TOKEN_COUNT * 3) // placeholder — shader ignores via position + const tokenHomes = new Float32Array(TOKEN_COUNT * 3) + const tokenTargets = new Float32Array(TOKEN_COUNT * 3) + const tokenColors = new Float32Array(TOKEN_COUNT * 3) + const tokenFadeIns = new Float32Array(TOKEN_COUNT) + const tokenConverges = new Float32Array(TOKEN_COUNT) + const tokenMaterializes = new Float32Array(TOKEN_COUNT) + + for (let i = 0; i < TOKEN_COUNT; i++) { + const t = tokenData[i] + // position attribute is required by THREE.Points but the vertex shader + // overrides gl_Position from custom attributes, so we just use home. + tokenPositions[i * 3] = t.home.x + tokenPositions[i * 3 + 1] = t.home.y + tokenPositions[i * 3 + 2] = t.home.z + tokenHomes[i * 3] = t.home.x + tokenHomes[i * 3 + 1] = t.home.y + tokenHomes[i * 3 + 2] = t.home.z + tokenTargets[i * 3] = t.target.x + tokenTargets[i * 3 + 1] = t.target.y + tokenTargets[i * 3 + 2] = t.target.z + const col = t.clusterIdx >= 0 ? CLUSTERS[t.clusterIdx].color : PRIMARY + tokenColors[i * 3] = col.r + tokenColors[i * 3 + 1] = col.g + tokenColors[i * 3 + 2] = col.b + tokenFadeIns[i] = 0 + tokenConverges[i] = 0 + tokenMaterializes[i] = 0 + } + + const tokenGeo = new THREE.BufferGeometry() + tokenGeo.setAttribute('position', new THREE.BufferAttribute(tokenPositions, 3)) + tokenGeo.setAttribute('aHome', new THREE.BufferAttribute(tokenHomes, 3)) + tokenGeo.setAttribute('aTarget', new THREE.BufferAttribute(tokenTargets, 3)) + tokenGeo.setAttribute('aColor', new THREE.BufferAttribute(tokenColors, 3)) + tokenGeo.setAttribute('aFadeIn', new THREE.BufferAttribute(tokenFadeIns, 1)) + tokenGeo.setAttribute('aConverge', new THREE.BufferAttribute(tokenConverges, 1)) + tokenGeo.setAttribute('aMaterialize', new THREE.BufferAttribute(tokenMaterializes, 1)) + + const tokenMat = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { + uTime: { value: 0 }, + uPixelRatio: { value: Math.min(window.devicePixelRatio || 1, 2) }, + uDiveCamZ: { value: 26 }, + uRevealCamZ: { value: revealCamZ } + }, + vertexShader: /* glsl */ ` + attribute vec3 aHome; + attribute vec3 aTarget; + attribute vec3 aColor; + attribute float aFadeIn; + attribute float aConverge; + attribute float aMaterialize; + uniform float uTime; + uniform float uPixelRatio; + varying vec3 vColor; + varying float vGlow; + + void main() { + // 3 states: home -> converge center -> avatar target. + vec3 convergePos = vec3(0.0) + 0.5 * normalize(aHome); + vec3 afterConverge = mix(aHome, convergePos, aConverge); + vec3 finalPos = mix(afterConverge, aTarget, aMaterialize); + + // gentle breathing + finalPos += 0.05 * vec3( + sin(uTime * 0.6 + finalPos.y * 0.7), + cos(uTime * 0.5 + finalPos.x * 0.6), + sin(uTime * 0.4 + finalPos.z * 0.5) + ); + + vec4 mvPosition = modelViewMatrix * vec4(finalPos, 1.0); + gl_Position = projectionMatrix * mvPosition; + + // size attenuates with distance — bigger when closer. + float size = 5.5 + 4.0 * aFadeIn; + gl_PointSize = size * uPixelRatio * (24.0 / max(1.0, -mvPosition.z)); + vColor = aColor; + vGlow = clamp(aFadeIn, 0.0, 1.0); + } + `, + fragmentShader: /* glsl */ ` + varying vec3 vColor; + varying float vGlow; + void main() { + vec2 uv = gl_PointCoord - vec2(0.5); + float d = length(uv); + if (d > 0.5) discard; + // soft round dot with a hot core + float core = smoothstep(0.5, 0.0, d); + float glow = smoothstep(0.5, 0.15, d); + float intensity = core * core + glow * 0.4; + gl_FragColor = vec4(vColor * (1.4 + vGlow * 0.7), intensity * vGlow); + } + ` + }) + + const tokenMesh = new THREE.Points(tokenGeo, tokenMat) + scene.add(tokenMesh) + + // Keep references to the per-vertex attributes so we can drive them via GSAP. + const fadeInAttr = tokenGeo.getAttribute('aFadeIn') as THREE.BufferAttribute + const convergeAttr = tokenGeo.getAttribute('aConverge') as THREE.BufferAttribute + const materializeAttr = tokenGeo.getAttribute('aMaterialize') as THREE.BufferAttribute + + // === Attention lines (intra-cluster + a few cross-cluster) === + const linePositions: number[] = [] + const lineColors: number[] = [] + const lineProgress: number[] = [] // for pulse: progress along the line 0..1 of "head" of pulse + const lineLen = (a: THREE.Vector3, b: THREE.Vector3) => a.distanceTo(b) + type Line = { a: number; b: number; pulseOffset: number } + const lines: Line[] = [] + + // Intra-cluster: each token connects to its 2 nearest cluster-mates. + CLUSTERS.forEach((_cluster, ci) => { + const clusterTokenIndices: number[] = [] + tokenData.forEach((t, i) => { + if (t.clusterIdx === ci) clusterTokenIndices.push(i) + }) + clusterTokenIndices.forEach((i) => { + const ti = tokenData[i].home + const distances = clusterTokenIndices + .filter((j) => j !== i) + .map((j) => ({ j, d: lineLen(ti, tokenData[j].home) })) + .sort((a, b) => a.d - b.d) + .slice(0, 2) + distances.forEach(({ j }) => { + if (i < j) { + lines.push({ a: i, b: j, pulseOffset: Math.random() }) + } + }) + }) + }) + // Cross-cluster: 2 lines between consecutive cluster centers (via their nearest tokens). + for (let ci = 0; ci < CLUSTERS.length; ci++) { + const cj = (ci + 1) % CLUSTERS.length + const ai = tokenData.findIndex((t) => t.clusterIdx === ci) + const bi = tokenData.findIndex((t) => t.clusterIdx === cj) + if (ai >= 0 && bi >= 0) { + lines.push({ a: ai, b: bi, pulseOffset: Math.random() }) + // one more cross link + const ai2 = + tokenData.findIndex((t, idx) => t.clusterIdx === ci && idx > ai) ?? ai + if (ai2 >= 0) lines.push({ a: ai2, b: bi, pulseOffset: Math.random() }) + } + } + + // Each line is 2 vertices; we'll update positions every frame because + // token positions are computed in shader (CPU can't easily read them). + // Trick: we re-evaluate the line endpoints on the CPU side using the + // same home/converge/target logic as the shader. + lines.forEach(() => { + linePositions.push(0, 0, 0, 0, 0, 0) + lineColors.push(0, 0, 0, 0, 0, 0) + lineProgress.push(0) + }) + const lineGeo = new THREE.BufferGeometry() + lineGeo.setAttribute( + 'position', + new THREE.BufferAttribute(new Float32Array(linePositions), 3) + ) + lineGeo.setAttribute( + 'color', + new THREE.BufferAttribute(new Float32Array(lineColors), 3) + ) + const lineMat = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.45, + blending: THREE.AdditiveBlending, + depthWrite: false + }) + const lineSegments = new THREE.LineSegments(lineGeo, lineMat) + scene.add(lineSegments) + + // State shared between render loop and GSAP. + const state = { + fadeInGlobal: 0, // 0..1 drives token fade-in + convergeGlobal: 0, // 0..1 drives home -> center + materializeGlobal: 0, // 0..1 drives center -> avatar target + pulse: 0, // time accumulator for line pulse + linesActive: 0 // 0..1 drives line opacity + } + + // Helper to evaluate a token's position based on the current state. + // Mirrors the vertex-shader math so we can place line endpoints consistently. + function evalTokenPos(idx: number, out: THREE.Vector3) { + const t = tokenData[idx] + const convergePos = new THREE.Vector3(0, 0, 0).add(t.home.clone().normalize().multiplyScalar(0.4)) + const afterConverge = t.home.clone().lerp(convergePos, state.convergeGlobal) + const finalPos = afterConverge.lerp(t.target, state.materializeGlobal) + out.copy(finalPos) + } + + // === Mouse parallax === + let mouseX = 0 + let mouseY = 0 + let camOffsetX = 0 + let camOffsetY = 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 }) + } + + // === Boot text overlay (Phase 1) === + const bootEl = document.createElement('div') + bootEl.className = 'intro-boot-text' + bootEl.innerHTML = `> initializing joye@mind` + overlay.appendChild(bootEl) + + // === Render loop === + let raf = 0 + const tick = (time: number) => { + raf = requestAnimationFrame(tick) + const t = time * 0.001 + tokenMat.uniforms.uTime.value = t + + // Re-lookAt every frame so the camera is always in sync with whatever + // position GSAP has moved it to this tick. (Relying on GSAP's onUpdate + // alone can leave the camera matrix a frame behind on some timings.) + camera.lookAt(0, 0, 0) + + // smooth camera parallax + camOffsetX += (mouseX * 0.6 - camOffsetX) * 0.05 + camOffsetY += (mouseY * 0.4 - camOffsetY) * 0.05 + // (parallax applied as small offset on top of GSAP-controlled camera) + + // Update token attributes + for (let i = 0; i < TOKEN_COUNT; i++) { + ;(fadeInAttr.array as Float32Array)[i] = state.fadeInGlobal + } + fadeInAttr.needsUpdate = true + for (let i = 0; i < TOKEN_COUNT; i++) { + ;(convergeAttr.array as Float32Array)[i] = state.convergeGlobal + ;(materializeAttr.array as Float32Array)[i] = td_hasTarget(tokenData[i]) + ? state.materializeGlobal + : 0 + } + convergeAttr.needsUpdate = true + materializeAttr.needsUpdate = true + + // Update line endpoints + colors + const linePosAttr = lineGeo.getAttribute('position') as THREE.BufferAttribute + const lineColAttr = lineGeo.getAttribute('color') as THREE.BufferAttribute + const tmpA = new THREE.Vector3() + const tmpB = new THREE.Vector3() + for (let i = 0; i < lines.length; i++) { + const ln = lines[i] + evalTokenPos(ln.a, tmpA) + evalTokenPos(ln.b, tmpB) + linePosAttr.array[i * 6] = tmpA.x + linePosAttr.array[i * 6 + 1] = tmpA.y + linePosAttr.array[i * 6 + 2] = tmpA.z + linePosAttr.array[i * 6 + 3] = tmpB.x + linePosAttr.array[i * 6 + 4] = tmpB.y + linePosAttr.array[i * 6 + 5] = tmpB.z + + const brightness = 0.4 + 0.6 * state.linesActive + // simple flicker — both endpoints get same color for now + const flicker = 0.7 + 0.3 * Math.sin(t * 2 + i) + const ca = tokenData[ln.a].clusterIdx >= 0 + ? CLUSTERS[tokenData[ln.a].clusterIdx].color + : PRIMARY + const cb = tokenData[ln.b].clusterIdx >= 0 + ? CLUSTERS[tokenData[ln.b].clusterIdx].color + : PRIMARY + lineColAttr.array[i * 6] = ca.r * brightness * flicker + lineColAttr.array[i * 6 + 1] = ca.g * brightness * flicker + lineColAttr.array[i * 6 + 2] = ca.b * brightness * flicker + lineColAttr.array[i * 6 + 3] = cb.r * brightness * flicker + lineColAttr.array[i * 6 + 4] = cb.g * brightness * flicker + lineColAttr.array[i * 6 + 5] = cb.b * brightness * flicker + } + linePosAttr.needsUpdate = true + lineColAttr.needsUpdate = true + + renderer.render(scene, camera) + } + raf = requestAnimationFrame(tick) + + function td_hasTarget(t: TokenData) { + return t.hasTarget + } + + // === GSAP Timeline === + const tl = gsap.timeline({ + onComplete: cleanup, + onUpdate: () => {} + }) + + // Phase 1: BOOT — boot text fades in and out. + tl.fromTo( + bootEl, + { opacity: 0, y: 8 }, + { opacity: 1, y: 0, duration: 0.5, ease: 'power2.out' } + ) + tl.to({}, { duration: 0.6 }) + tl.to(bootEl, { opacity: 0, y: -8, duration: 0.4, ease: 'power2.in' }) + + // Phase 2: DIVE — camera swoops into the cluster cloud. + // First appear tokens (fade in), then attention lines. + tl.to(state, { fadeInGlobal: 1, duration: 1.4, ease: 'power2.out' }, '<+0.1') + tl.to(state, { linesActive: 1, duration: 0.8, ease: 'power2.out' }, '<+0.6') + tl.to( + camera.position, + { + x: 0, + y: 0, + z: revealCamZ, + duration: 3.3, + ease: 'power3.inOut', + onUpdate: () => { + // Keep the camera looking at the scene origin so the avatar silhouette + // (positioned at avatarCenterWorldX/Y) ends up at the same screen + // location as the real hero . + camera.lookAt(0, 0, 0) + } + }, + '<' + ) + + // Phase 3: CONVERGE — tokens fall toward the global center. + tl.to(state, { + convergeGlobal: 1, + duration: 2.5, + ease: 'power2.inOut' + }) + tl.to(state, { linesActive: 0.35, duration: 1.5, ease: 'power2.out' }, '<') + + // Phase 4: MATERIALIZE — tokens lerp onto avatar silhouette targets. + tl.to(state, { + materializeGlobal: 1, + duration: 1.4, + ease: 'power3.inOut' + }) + tl.to(state, { linesActive: 0, duration: 0.6, ease: 'power2.out' }, '<') + + // Phase 5: REVEAL — overlay dissolves, hero zooms in. + tl.to(overlay, { + opacity: 0, + duration: 0.6, + ease: 'power2.inOut', + onComplete: () => overlay.classList.add('intro-hidden') + }, '+=0.2') + + 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: 1.0, + ease: 'power3.out' + }, '<+0.02') + tl.to('#content', { + scale: 1, + opacity: 1, + filter: 'blur(0px)', + duration: 1.0, + ease: 'power3.out' + }, '<+0.08') + + // === Cleanup === + let cleaned = false + function cleanup() { + if (cleaned) return + cleaned = true + clearTimeout(fallback) + cancelAnimationFrame(raf) + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('resize', resize) + if (bootEl.parentNode) bootEl.parentNode.removeChild(bootEl) + renderer.dispose() + tokenGeo.dispose() + tokenMat.dispose() + lineGeo.dispose() + lineMat.dispose() + if (overlay) overlay.classList.add('intro-done') + document.documentElement.classList.remove('intro-active') + document.documentElement.classList.remove('intro-hidden') + gsap.set(['#content-header', '#content'], { clearProps: 'all' }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).__introDone = true + window.dispatchEvent(new CustomEvent('intro:complete')) + } + + const fallback = setTimeout(() => { + console.warn('[intro] hard timeout, forcing reveal') + cleanup() + }, 16000) + + // Dev hook. + if (import.meta.env.DEV) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).__intro = { + state, + camera, + scene, + tl + } + } +} + +/** Load avatar PNG and sample alpha-channel silhouette points (in [-110, 110]). */ +async function loadSilhouettePoints(src: string): Promise<{ x: number; y: number }[]> { + if (!src) return [] + const img = new Image() + img.crossOrigin = 'anonymous' + img.src = src + try { + await img.decode() + } catch { + return [] + } + + const SIZE = 220 + const c = document.createElement('canvas') + c.width = SIZE + c.height = SIZE + const cx = c.getContext('2d', { willReadFrequently: true }) + if (!cx) return [] + cx.drawImage(img, 0, 0, SIZE, SIZE) + const data = cx.getImageData(0, 0, SIZE, SIZE).data + + const points: { x: number; y: number }[] = [] + const STEP = 6 + for (let y = 0; y < SIZE; y += STEP) { + for (let x = 0; x < SIZE; x += STEP) { + const i = (y * SIZE + x) * 4 + const alpha = data[i + 3] + const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3 + if (alpha > 100 && brightness > 60) { + points.push({ x: x - SIZE / 2, y: y - SIZE / 2 }) + } + } + } + return points +} + +function revealImmediately() { + const overlay = document.getElementById('intro-overlay') + const doc = document.documentElement + doc.classList.remove('intro-active') + doc.classList.remove('intro-hidden') + doc.classList.add('intro-skip') + if (overlay) overlay.classList.add('intro-done') + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).__introDone = true + window.dispatchEvent(new CustomEvent('intro:complete')) +} diff --git a/public/intro/aixcut.png b/public/intro/aixcut.png new file mode 100644 index 0000000..68c6af9 Binary files /dev/null and b/public/intro/aixcut.png differ diff --git a/public/intro/atypica.png b/public/intro/atypica.png new file mode 100644 index 0000000..c45f393 Binary files /dev/null and b/public/intro/atypica.png differ diff --git a/public/intro/faishion.png b/public/intro/faishion.png new file mode 100644 index 0000000..ab8a80a Binary files /dev/null and b/public/intro/faishion.png differ diff --git a/public/intro/playyy.png b/public/intro/playyy.png new file mode 100644 index 0000000..e803996 Binary files /dev/null and b/public/intro/playyy.png differ diff --git a/src/components/intro/IntroOverlay.astro b/src/components/intro/IntroOverlay.astro new file mode 100644 index 0000000..cc4ca14 --- /dev/null +++ b/src/components/intro/IntroOverlay.astro @@ -0,0 +1,130 @@ +--- +/** + * IntroOverlay — "The Generation" + * + * The site introduces its author the same way their products work: by + * generating. A prompt appears, then a short bio streams in token-by-token; + * the entities it names (products, companies, topics) light up as the + * model "predicts" them. When generation finishes, the text lifts away and + * the landing page rises into place beneath. + * + * SSR renders the full bio (with entity spans already in place); the client + * script walks the text, hides it char-by-char, then streams it back in. + */ +import { JOYE_LOG_GROUPS } from '@/data/joye-log' +import './intro.css' + +const isDev = import.meta.env.DEV + +// Counts are derived from the canonical inventory so the status line never +// drifts out of sync with the rest of the site. +const counts = (() => { + const by = (t: string) => + JOYE_LOG_GROUPS.find((g) => g.type === t)?.entries.length ?? 0 + return { roles: by('work'), repos: by('repo'), posts: by('blog') } +})() +--- + + + + + + + +{ + isDev && ( + + ) +} + +{ + isDev && ( + + ) +} diff --git a/src/components/intro/JoJoTour.astro b/src/components/intro/JoJoTour.astro new file mode 100644 index 0000000..3b6919b --- /dev/null +++ b/src/components/intro/JoJoTour.astro @@ -0,0 +1,34 @@ +--- +/** + * JoJoTour — onboarding tour driven by the JoJo mascot. + * + * Mounts the React component via Astro island (client:idle). + * The component itself decides whether to play (based on prefers-reduced-motion + * + sessionStorage) and waits for the `intro:complete` event dispatched by + * src/scripts/intro.ts once the cinematic intro finishes. + */ +import './jojo-tour.css' +import JoJoTourRoot from './JoJoTour.tsx' + +const isDev = import.meta.env.DEV +--- + + + +{ + /* Expose a window hook for the dev Replay button to reset both + intro + tour so the whole experience can be re-watched in dev. */ + isDev && ( + + ) +} diff --git a/src/components/intro/JoJoTour.tsx b/src/components/intro/JoJoTour.tsx new file mode 100644 index 0000000..bae6f85 --- /dev/null +++ b/src/components/intro/JoJoTour.tsx @@ -0,0 +1,318 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import JoJo from '@/components/mascot/JoJo' +import './jojo-tour.css' + +type Side = 'left' | 'right' | 'top' | 'bottom' + +type Step = { + id: string + /** Resolve the target element to spotlight. Return null to skip the step. */ + target: () => HTMLElement | null + /** JoJo's line — supports \n for multi-line bubbles. */ + line: string + /** Which side of the target JoJo stands on. */ + side: Side + /** Auto-advance duration in ms (default 2400). */ + duration?: number +} +/** Find the first
whose

matches the title exactly. */ +function sectionByTitle(title: string): HTMLElement | null { + const headings = document.querySelectorAll('main section h2') + for (const h of headings) { + if (h.textContent?.trim() === title) { + return (h.closest('section') as HTMLElement | null) ?? null + } + } + return null +} + +const STEPS: Step[] = [ + { + id: 'welcome', + target: () => document.querySelector('#content-header') as HTMLElement | null, + line: "hi! 我是 JoJo ✨\n30 秒带你逛完这个博客", + side: 'right', + duration: 3200 + }, + { + id: 'terminal', + target: () => document.querySelector('.wt-intro') as HTMLElement | null, + line: "power user?\ntype 'help' 'ls /blog' 'chat'", + side: 'right', + duration: 2800 + }, + { + id: 'about', + target: () => sectionByTitle('About'), + line: "Joye — 墨尔本大学\nAdastra / Tezign / AIXCut / fAIshion\n现在在做 Playyy.ai", + side: 'right' + }, + { + id: 'blog', + target: () => sectionByTitle('Blog'), + line: "最新文章 — Transformer / Agent\n面试心得,深度+工程", + side: 'right' + }, + { + id: 'notes', + target: () => sectionByTitle('Notes'), + line: "碎片笔记 — idea / 草稿 / 研究\n更新得快", + side: 'right' + }, + { + id: 'talks', + target: () => sectionByTitle('Talks'), + line: "群里每周一次技术分享\n内容+幻灯片都公开沉淀", + side: 'right' + }, + { + id: 'experience', + target: () => sectionByTitle('Experience'), + line: "实习时间线 — 在哪做了什么", + side: 'right' + }, + { + id: 'open-source', + target: () => sectionByTitle('Open Source'), + line: "Learn-Open-Harness (297⭐)\nminimind-notes (93⭐)", + side: 'right' + }, + { + id: 'skills', + target: () => sectionByTitle('Skills'), + line: "技术栈 — TS/React/Node\n+ Claude Code / Agent / RAG", + side: 'right' + }, + { + id: 'bye', + target: () => document.querySelector('#content-header') as HTMLElement | null, + line: "enjoy exploring! ✨\n我在右下角,需要叫我", + side: 'right', + duration: 3200 + } +] + +const JOJO_BOX_W = 110 +const JOJO_BOX_H = 140 +const TARGET_PADDING = 10 + +export default function JoJoTour() { + // 'idle' = waiting for intro to complete + // 'playing' = actively touring + // 'done' = finished or skipped; component renders nothing + const [phase, setPhase] = useState<'idle' | 'playing' | 'done'>('idle') + const [stepIndex, setStepIndex] = useState(0) + const [jojoPos, setJojoPos] = useState<{ x: number; y: number } | null>(null) + const [spotRect, setSpotRect] = useState<{ + x: number + y: number + w: number + h: number + } | null>(null) + const [fadingOut, setFadingOut] = useState(false) + const advanceTimer = useRef | null>(null) + const scrollTimer = useRef | null>(null) + + // Decide whether to play at all, then wait for intro:complete. + useEffect(() => { + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const played = sessionStorage.getItem('tour-played') + if (reduce || played) { + setPhase('done') + return + } + + const launch = () => { + // Re-check in case another tab finished the tour while we waited. + if (sessionStorage.getItem('tour-played')) { + setPhase('done') + return + } + setPhase('playing') + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((window as any).__introDone === true) { + const t = setTimeout(launch, 600) + return () => clearTimeout(t) + } + window.addEventListener('intro:complete', launch, { once: true }) + return () => window.removeEventListener('intro:complete', launch) + }, []) + + const finish = useCallback(() => { + setFadingOut(true) + try { + sessionStorage.setItem('tour-played', '1') + } catch {} + setTimeout(() => setPhase('done'), 400) + }, []) + + const advance = useCallback(() => { + setStepIndex((i) => { + if (i + 1 >= STEPS.length) { + finish() + return i + } + return i + 1 + }) + }, [finish]) + + const step = STEPS[stepIndex] + + // Per-step effect: scroll target into view, compute spotlight + JoJo position. + useEffect(() => { + if (phase !== 'playing' || !step) return + + const target = step.target() + if (!target) { + const t = setTimeout(advance, 100) + return () => clearTimeout(t) + } + + // === Compute the target's "future" rect — i.e. where it will sit on + // screen once we've scrolled to center it. We use this immediately so + // the spotlight doesn't lag behind the smooth-scroll for 500ms. + const rect = target.getBoundingClientRect() + const currentScrollY = window.scrollY + const docTop = rect.top + currentScrollY + const viewportH = window.innerHeight + const viewportW = window.innerWidth + // Where the target's top will be in viewport coords after scroll: + const futureTop = (viewportH - rect.height) / 2 + + const targetScrollY = docTop - (viewportH - rect.height) / 2 + // Smooth-scroll the window. Use 'auto' if user prefers reduced motion. + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches + window.scrollTo({ + top: Math.max(0, targetScrollY), + behavior: reduce ? 'auto' : 'smooth' + }) + + // Set spotlight to the "future" position immediately — CSS transition + // animates it from the previous spot. + setSpotRect({ + x: Math.max(0, rect.left - TARGET_PADDING), + y: Math.max(0, futureTop - TARGET_PADDING), + w: Math.min(viewportW, rect.width + TARGET_PADDING * 2), + h: Math.min(viewportH, rect.height + TARGET_PADDING * 2) + }) + + // Position JoJo beside the (future) target rect. + let x: number + let y: number + if (step.side === 'right') { + x = rect.right + 20 + y = futureTop + rect.height / 2 - JOJO_BOX_H / 2 + } else if (step.side === 'left') { + x = rect.left - JOJO_BOX_W - 20 + y = futureTop + rect.height / 2 - JOJO_BOX_H / 2 + } else if (step.side === 'top') { + x = rect.left + rect.width / 2 - JOJO_BOX_W / 2 + y = futureTop - JOJO_BOX_H - 20 + } else { + x = rect.left + rect.width / 2 - JOJO_BOX_W / 2 + y = futureTop + rect.height + 20 + } + if (step.side === 'right' && x + JOJO_BOX_W > viewportW - 16) { + x = Math.max(16, rect.left - JOJO_BOX_W - 20) + } + if (step.side === 'left' && x < 16) { + x = Math.min(viewportW - JOJO_BOX_W - 16, rect.right + 20) + } + x = Math.max(16, Math.min(viewportW - JOJO_BOX_W - 16, x)) + y = Math.max(16, Math.min(viewportH - JOJO_BOX_H - 16, y)) + setJojoPos({ x, y }) + + // Schedule auto-advance. + const duration = step.duration ?? 2400 + advanceTimer.current = setTimeout(advance, duration) + + return () => { + if (advanceTimer.current) clearTimeout(advanceTimer.current) + if (scrollTimer.current) clearTimeout(scrollTimer.current) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stepIndex, phase]) + + // Lock background scroll while playing. + useEffect(() => { + if (phase !== 'playing') return + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, [phase]) + + // Recompute on viewport changes. + useEffect(() => { + if (phase !== 'playing' || !step) return + const onResize = () => { + const target = step.target() + if (!target) return + const rect = target.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + // Target should be centered (we scrolled to center it). + const futureTop = (vh - rect.height) / 2 + setSpotRect({ + x: Math.max(0, rect.left - TARGET_PADDING), + y: Math.max(0, futureTop - TARGET_PADDING), + w: Math.min(vw, rect.width + TARGET_PADDING * 2), + h: Math.min(vh, rect.height + TARGET_PADDING * 2) + }) + } + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, [phase, step]) + + if (phase !== 'playing' || !step) return null + + return ( +
+ {spotRect && ( + + ) +} diff --git a/src/components/intro/intro.css b/src/components/intro/intro.css new file mode 100644 index 0000000..4e76d4a --- /dev/null +++ b/src/components/intro/intro.css @@ -0,0 +1,288 @@ +/* ===== "The Generation" — cinematic entry ===== */ + +html.intro-active { + overflow: hidden; + height: 100vh; +} + +html.intro-active .animate { + animation: none !important; +} + +html.intro-active #content-header, +html.intro-active #content { + opacity: 0; + transform: scale(0.96); + filter: blur(8px); + transition: none; + will-change: transform, opacity, filter; +} + +html.intro-skip #intro-overlay { + display: none !important; +} + +/* === Overlay shell === */ +#intro-overlay { + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + /* Soft, deep gradient — calm, not a flat black slab. */ + background: + radial-gradient(ellipse 60% 45% at 50% 42%, rgba(70, 110, 140, 0.20) 0%, rgba(8, 13, 20, 0) 62%), + radial-gradient(circle at center, rgb(11, 16, 23) 0%, rgb(5, 8, 11) 100%); + opacity: 1; + transform: scale(1); + filter: blur(0px); + transition: + opacity 0.9s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.9s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.9s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: safe center; + justify-content: center; + overflow-y: auto; + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + color: hsl(200, 20%, 86%); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +#intro-overlay.intro-hidden { + opacity: 0; + transform: scale(1.05); + filter: blur(14px); +} + +#intro-overlay.intro-done { + display: none; +} + +/* === Atmosphere — vignette + faint grain only (no scanlines). === */ +.intro-atmosphere { + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 52%, rgba(0, 0, 0, 0.5) 100%); + z-index: 1; +} +.intro-atmosphere::before { + content: ''; + position: absolute; + inset: -50%; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.05; + mix-blend-mode: screen; + animation: grain-shift 0.7s steps(4) infinite; +} +@keyframes grain-shift { + 0% { transform: translate(0, 0); } + 25% { transform: translate(-3%, 2%); } + 50% { transform: translate(2%, -3%); } + 75% { transform: translate(-2%, -2%); } + 100% { transform: translate(3%, 3%); } +} + +/* === The generation wrapper — centered, generous negative space. === */ +#intro-gen-wrap { + position: relative; + z-index: 2; + width: 100%; + max-width: 720px; + /* The "padding" fix: real breathing room, sized to the viewport so it + never overflows. */ + padding: 6vh 40px; + margin: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; + text-align: center; + opacity: 0; +} + +/* === Prompt === */ +#intro-gen-prompt { + display: inline-flex; + align-items: baseline; + gap: 10px; + font-size: 15px; + letter-spacing: 0.4px; + color: hsl(200, 16%, 62%); +} +.prompt-arrow { + color: hsl(155, 55%, 60%); + font-weight: 600; + text-shadow: 0 0 10px hsl(155 55% 60% / 0.4); +} +.prompt-text { + color: hsl(195, 30%, 80%); +} + +/* === The generated bio — large, calm, lots of air. === */ +#intro-gen { + font-size: clamp(18px, 2.5vw, 25px); + line-height: 1.6; + letter-spacing: 0.1px; + font-weight: 400; + color: hsl(200, 16%, 90%); + max-width: 660px; + text-wrap: balance; +} +.gen-line { + margin: 0; +} +.gen-line + .gen-line { + margin-top: 0.28em; +} + +/* Each character is wrapped by the client; default hidden, faded in. */ +.gen-char { + display: inline-block; + opacity: 0; + transition: + opacity 0.22s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.22s cubic-bezier(0.4, 0, 0.2, 1); + filter: blur(3px); + white-space: pre; +} + +/* Streaming cursor — a thin bar that follows the last emitted char. */ +.gen-cursor { + display: inline-block; + width: 0.5ch; + height: 1.05em; + vertical-align: text-bottom; + margin-left: 2px; + background: hsl(195, 70%, 66%); + box-shadow: 0 0 10px hsl(195 70% 66% / 0.6); + animation: gen-cursor-blink 1s steps(2) infinite; + transform: translateY(0.06em); +} +@keyframes gen-cursor-blink { + 0%, 50% { opacity: 1; } + 50.01%, 100% { opacity: 0; } +} + +/* === Entities — light up once the model "predicts" them. === */ +.gen-entity { + position: relative; + color: inherit; + transition: + color 0.45s cubic-bezier(0.2, 0.7, 0.2, 1), + text-shadow 0.45s cubic-bezier(0.2, 0.7, 0.2, 1); + white-space: nowrap; +} +.gen-entity[data-kind='work'] { --ent-accent: 199 80% 68%; } +.gen-entity[data-kind='topic'] { --ent-accent: 38 88% 66%; } + +.gen-entity.entity-active { + color: hsl(var(--ent-accent)); + text-shadow: + 0 0 16px hsl(var(--ent-accent) / 0.45), + 0 0 32px hsl(var(--ent-accent) / 0.18); +} +/* Underline draws in once activated. */ +.gen-entity::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0.08em; + height: 1.5px; + background: hsl(var(--ent-accent) / 0.8); + transform: scaleX(0); + transform-origin: left center; + transition: transform 0.5s cubic-bezier(0.2, 0.7, 0.2, 1); + border-radius: 1px; +} +.gen-entity.entity-active::after { + transform: scaleX(1); +} + +/* === Status line === */ +#intro-gen-status { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 12px; + letter-spacing: 0.5px; + color: hsl(200, 14%, 48%); + text-transform: lowercase; +} +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: hsl(155, 55%, 58%); + box-shadow: 0 0 8px hsl(155 55% 58% / 0.7); + animation: status-pulse 1.3s ease-in-out infinite; +} +@keyframes status-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.82); } +} +#intro-gen-status.gen-done .status-dot { + background: hsl(195, 70%, 66%); + box-shadow: 0 0 8px hsl(195 70% 66% / 0.7); + animation: none; +} + +/* === Foot summary === */ +#intro-gen-foot { + font-size: 11.5px; + letter-spacing: 0.6px; + color: hsl(200, 12%, 40%); + opacity: 0; +} + +/* === Reduced motion === */ +@media (prefers-reduced-motion: reduce) { + #intro-overlay { display: none !important; } +} + +/* === Mobile === */ +@media (max-width: 640px) { + #intro-gen-wrap { + padding: 10vh 24px; + gap: 34px; + } + #intro-gen { + font-size: clamp(18px, 5.6vw, 24px); + line-height: 1.6; + } + #intro-gen-prompt { font-size: 13px; } +} + +/* ===== Replay button (dev only) ===== */ +#intro-replay { + position: fixed; + top: 14px; + right: 14px; + z-index: 10000; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--card) / 0.85); + color: hsl(var(--muted-foreground)); + cursor: pointer; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: + color 0.15s ease, + border-color 0.15s ease, + transform 0.3s ease; +} +#intro-replay:hover { + color: hsl(var(--primary)); + border-color: hsl(var(--primary) / 0.5); + transform: rotate(-180deg); +} +#intro-replay svg { width: 16px; height: 16px; } +html.intro-active #intro-replay { display: none !important; } diff --git a/src/components/intro/jojo-tour.css b/src/components/intro/jojo-tour.css new file mode 100644 index 0000000..7fbc49f --- /dev/null +++ b/src/components/intro/jojo-tour.css @@ -0,0 +1,226 @@ +/* ===== JoJo Mascot Tour ===== */ + +.jojo-tour-root { + position: fixed; + inset: 0; + z-index: 10001; + pointer-events: none; + /* Material standard ease — quick to settle, no bounce. */ + --jojo-tour-ease: cubic-bezier(0.4, 0, 0.2, 1); + animation: jojo-tour-fade-in 0.3s var(--jojo-tour-ease) both; +} + +.jojo-tour-root.jojo-tour-fading { + animation: jojo-tour-fade-out 0.4s var(--jojo-tour-ease) both; +} + +@keyframes jojo-tour-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes jojo-tour-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* Spotlight: a div sized to the target, with an enormous box-shadow that + effectively paints a dark layer with a "hole" cut out of it. */ +.jojo-tour-spotlight { + position: fixed; + top: 0; + left: 0; + border-radius: 14px; + border: 1.5px solid hsl(var(--primary) / 0.55); + background: transparent; + box-shadow: + 0 0 0 9999px rgba(0, 0, 0, 0.68), + 0 0 32px rgba(0, 0, 0, 0.35); + /* Snappier transitions — no floaty bounce between steps. */ + transition: + transform 0.4s var(--jojo-tour-ease), + width 0.4s var(--jojo-tour-ease), + height 0.4s var(--jojo-tour-ease); + pointer-events: none; +} + +.jojo-tour-mascot { + position: fixed; + top: 0; + left: 0; + width: 110px; + height: 140px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + transition: transform 0.4s var(--jojo-tour-ease); + pointer-events: auto; +} + +.jojo-tour-bubble { + position: absolute; + bottom: calc(100% - 6px); + left: 50%; + transform: translateX(-50%); + margin-bottom: 10px; + padding: 10px 14px; + background: hsl(var(--card)); + color: hsl(var(--foreground)); + border: 1px solid hsl(var(--border)); + border-radius: 12px; + font-size: 13px; + line-height: 1.5; + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + white-space: pre-line; + text-align: center; + max-width: 280px; + min-width: 160px; + box-shadow: + 0 12px 28px rgba(0, 0, 0, 0.18), + 0 2px 6px rgba(0, 0, 0, 0.08); + animation: jojo-tour-bubble-pop 0.3s var(--jojo-tour-ease) both; +} + +.jojo-tour-bubble::after { + content: ''; + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: hsl(var(--card)); + border-right: 1px solid hsl(var(--border)); + border-bottom: 1px solid hsl(var(--border)); +} + +@keyframes jojo-tour-bubble-pop { + from { + opacity: 0; + transform: translateX(-50%) translateY(6px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +.jojo-tour-mascot .jojo-root { + color: hsl(var(--primary)); +} + +/* Bottom control bar */ +.jojo-tour-controls { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 14px; + padding: 8px 8px 8px 18px; + background: hsl(var(--card) / 0.95); + border: 1px solid hsl(var(--border)); + border-radius: 999px; + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.2), + 0 2px 6px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + pointer-events: auto; + z-index: 10002; + animation: jojo-tour-fade-in 0.5s var(--jojo-tour-ease) 0.1s both; +} + +.jojo-tour-skip, +.jojo-tour-next { + padding: 7px 16px; + border: none; + border-radius: 999px; + font-size: 13px; + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + cursor: pointer; + transition: + transform 0.15s ease, + opacity 0.15s ease, + background 0.15s ease; +} + +.jojo-tour-skip { + background: transparent; + color: hsl(var(--muted-foreground)); +} +.jojo-tour-skip:hover { + color: hsl(var(--foreground)); + transform: translateY(-1px); +} + +.jojo-tour-next { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + font-weight: 600; +} +.jojo-tour-next:hover { + opacity: 0.92; + transform: translateY(-1px); +} + +.jojo-tour-progress { + font-size: 12px; + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + color: hsl(var(--muted-foreground)); + min-width: 48px; + text-align: center; + letter-spacing: 0.5px; +} + +.jojo-tour-progress-current { + color: hsl(var(--primary)); + font-weight: 700; +} + +.jojo-tour-progress-sep { + margin: 0 2px; + opacity: 0.5; +} + +@media (max-width: 640px) { + .jojo-tour-mascot { + width: 90px; + height: 120px; + } + .jojo-tour-bubble { + font-size: 12px; + max-width: 220px; + min-width: 140px; + } + .jojo-tour-controls { + bottom: 20px; + padding: 6px 6px 6px 14px; + } + .jojo-tour-skip, + .jojo-tour-next { + padding: 6px 12px; + font-size: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .jojo-tour-root, + .jojo-tour-spotlight, + .jojo-tour-mascot, + .jojo-tour-bubble, + .jojo-tour-controls { + animation: none !important; + transition: none !important; + } +} diff --git a/src/components/mascot/jojo.css b/src/components/mascot/jojo.css index 96d2a0c..5d65493 100644 --- a/src/components/mascot/jojo.css +++ b/src/components/mascot/jojo.css @@ -45,7 +45,7 @@ border: 1px solid hsl(var(--border)); border-radius: 10px; max-width: 220px; - white-space: nowrap; + white-space: pre-line; animation: jojo-bubble-in 0.2s ease-out both; font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; pointer-events: none; diff --git a/src/data/joye-log.ts b/src/data/joye-log.ts new file mode 100644 index 0000000..bdb22a5 --- /dev/null +++ b/src/data/joye-log.ts @@ -0,0 +1,242 @@ +/** + * Joye's real shipped work, organized into the same groups the landing + * page already shows (Blog / Open Source / Experience). The intro cinematic + * uses this to play a log cascade grouped by domain, then morph each group + * into the corresponding landing-page section. + * + * Update this file when you publish a new post / repo / role — the intro + * and the landing page will both reflect the change. + */ + +export type LogType = 'blog' | 'repo' | 'work' | 'edu' + +export type LogEntry = { + /** Display stamp — left column. */ + stamp: string + /** Main label — middle column. */ + title: string + /** Category — drives tag color. */ + type: LogType + /** Optional tag shown after the title. */ + tag?: string + /** + * Importance weight 0..2 — recent / marquee work is 2 (brightest, slightly + * larger), early work is 0 (dim, small). Drives the visual hierarchy so + * the cascade doesn't read as a flat list. + */ + weight?: 0 | 1 | 2 + /** Experience-only: homepage screenshot for the VR-style showcase. */ + screenshot?: string + /** Experience-only: one-line description shown under the screenshot. */ + description?: string + /** Experience-only: Joye's role at the company. */ + role?: string + /** Experience-only: time period. */ + period?: string + /** Experience-only: tech stack chips. */ + stack?: string[] + /** Experience-only: highlight bullets shown in the hover info panel. */ + highlights?: string[] + /** Experience-only: link to the product (for click-to-visit). */ + url?: string +} + +export type LogGroup = { + /** Group label — also matches a landing-page
for morph. */ + label: string + /** Same `type` value as the entries inside, drives group-header accent. */ + type: LogType + entries: LogEntry[] +} + +export const JOYE_LOG_GROUPS: LogGroup[] = [ + { + label: 'Experience', + type: 'work', + entries: [ + { + stamp: '— now —', + title: 'Adastra Labs → Playyy.ai', + type: 'work', + tag: 'AI full-stack', + weight: 2, + screenshot: '/intro/playyy.png', + description: 'AI image generation & brand design platform', + role: 'AI Full-Stack Engineer', + period: '2026 — present', + stack: ['TypeScript', 'React', 'Next.js', 'GPT Image 2', 'Nano Banana'], + highlights: [ + 'Click-to-edit element editor (no selection tools, just describe)', + 'BG remover + object remover + upscaler pipeline', + 'Brand-consistent style transfer for campaign visuals' + ], + url: 'https://playyy.ai/' + }, + { + stamp: '— prev —', + title: 'Tezign → atypica.ai', + type: 'work', + tag: 'multi-agent', + weight: 1, + screenshot: '/intro/atypica.png', + description: 'Multi-agent system for commercial research', + role: 'AIGC Full-Stack Intern', + period: '2026', + stack: ['Python', 'LangGraph', 'Multi-Agent', 'RAG'], + highlights: [ + 'Multi-agent orchestration for business research workflows', + 'Tool use + planning + reflection loop', + 'Retrieval pipeline over internal knowledge base' + ], + url: 'https://atypica.ai/' + }, + { + stamp: '— prev —', + title: 'AIXCut → AI video agent', + type: 'work', + tag: 'AI editing', + weight: 1, + screenshot: '/intro/aixcut.png', + description: 'AI video editing agent', + role: 'AI Full-Stack Engineer', + period: '2025', + stack: ['TypeScript', 'FFmpeg', 'LLM', 'Agent'], + highlights: [ + 'Agentic video editing — describe the cut, AI assembles it', + 'Scene detection + automatic B-roll insertion', + 'Real-time preview pipeline' + ], + url: 'https://aixcut.cn/' + }, + { + stamp: '— prev —', + title: 'fAIshion.ai → virtual try-on', + type: 'work', + tag: 'AI try-on', + weight: 0, + screenshot: '/intro/faishion.png', + description: 'AI virtual try-on & styling', + role: 'AI Full-Stack Engineer (Remote)', + period: '2025', + stack: ['Python', 'Diffusion', 'FastAPI', 'React'], + highlights: [ + 'Virtual try-on powered by diffusion models', + 'Outfit recommendation engine', + 'Model integration + serving infrastructure' + ], + url: 'https://www.faishion.ai/' + } + ] + }, + { + label: 'Blog', + type: 'blog', + entries: [ + // Newest first — the visitor sees the current self before the history. + { + stamp: '2026.05.28', + title: '46-Minute Mock Interview Review', + type: 'blog', + tag: 'Agent', + weight: 2 + }, + { + stamp: '2026.05.23', + title: "Interviews Aren't Exams", + type: 'blog', + tag: 'Agent', + weight: 2 + }, + { + stamp: '2026.05.17', + title: 'Agent Onboarding Guide v1.0', + type: 'blog', + tag: 'Agent', + weight: 2 + }, + { + stamp: '2026.05.12', + title: '1h19m Agent Engineer Mock Interview', + type: 'blog', + tag: 'Agent', + weight: 2 + }, + { + stamp: '2026.04.10', + title: 'Reading OpenHarness — 11,733 lines', + type: 'blog', + tag: 'Agent', + weight: 2 + }, + { + stamp: '2026.03.09', + title: 'Agent Dev Interview Field Guide', + type: 'blog', + tag: 'Agent', + weight: 1 + }, + { + stamp: '2025.12.19', + title: 'FeedForward & Transformer Block', + type: 'blog', + tag: 'LLM', + weight: 1 + }, + { + stamp: '2025.12.18', + title: 'Understanding Attention: Q, K, V', + type: 'blog', + tag: 'LLM', + weight: 1 + }, + { + stamp: '2025.12.17', + title: 'RoPE Position Encoding', + type: 'blog', + tag: 'LLM', + weight: 1 + }, + { + stamp: '2025.12.16', + title: 'Why Transformers Need Normalization', + type: 'blog', + tag: 'LLM', + weight: 1 + }, + { + stamp: '2025.10.23', + title: 'Frontend Intern Interviews Prep Guide', + type: 'blog', + tag: 'internship', + weight: 0 + }, + { + stamp: '2025.07.01', + title: 'My First Pull Request', + type: 'blog', + tag: 'open-source', + weight: 0 + } + ] + }, + { + label: 'Open Source', + type: 'repo', + entries: [ + { + stamp: '— repo —', + title: 'Learn-Open-Harness', + type: 'repo', + tag: '297 stars', + weight: 2 + }, + { + stamp: '— repo —', + title: 'minimind-notes', + type: 'repo', + tag: '93 stars', + weight: 1 + } + ] + } +] diff --git a/src/pages/index.astro b/src/pages/index.astro index 66aaead..346a270 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -16,6 +16,8 @@ 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' +// import JoJoTour from '@/components/intro/JoJoTour.astro' export const prerender = true @@ -91,6 +93,7 @@ const latestTalk = talks[0] --- +
{ + console.warn('[intro] aborted:', err) + revealImmediately() + }) +} + +async function runIntro() { + window.scrollTo(0, 0) + + const overlay = document.getElementById('intro-overlay') + const wrap = document.getElementById('intro-gen-wrap') + const gen = document.getElementById('intro-gen') + const status = document.getElementById('intro-gen-status') + const statusText = status?.querySelector('.status-text') + const foot = document.getElementById('intro-gen-foot') + + if (!overlay || !wrap || !gen) { + revealImmediately() + return + } + + // Pre-hide landing sections so they don't flash when intro-active lifts. + const allSections = Array.from( + document.querySelectorAll('main #content section') + ) + gsap.set(allSections, { opacity: 0, y: 16 }) + + // === 1. Wrap fades in === + await new Promise((resolve) => { + gsap.fromTo( + wrap, + { opacity: 0, y: 14, filter: 'blur(6px)' }, + { + opacity: 1, + y: 0, + filter: 'blur(0px)', + duration: 0.7, + ease: 'power2.out', + onComplete: resolve + } + ) + }) + await sleep(300) + + // === 2. Stream the bio === + const elapsed = await streamGeneration(gen) + + // === 3. Status: done === + status?.classList.add('gen-done') + if (statusText) statusText.textContent = `done · ${elapsed.toFixed(1)}s` + if (foot) { + gsap.to(foot, { opacity: 0.75, duration: 0.6, ease: 'power2.out' }) + } + await sleep(1000) + + // === 4. Handoff — text lifts away, page rises beneath === + document.documentElement.classList.remove('intro-active') + await new Promise((resolve) => { + const tl = gsap.timeline({ onComplete: resolve }) + tl.to( + wrap, + { y: -56, opacity: 0, filter: 'blur(14px)', duration: 0.9, ease: 'power2.in' }, + 0 + ) + allSections.forEach((s, i) => { + tl.to( + s, + { opacity: 1, y: 0, duration: 0.8, ease: 'power2.out' }, + 0.12 + i * 0.06 + ) + }) + tl.add(() => revealHero(), 0) + tl.add(() => overlay.classList.add('intro-hidden'), 0.18) + }) + + overlay.classList.add('intro-done') + document.documentElement.classList.remove('intro-hidden') + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).__introDone = true + window.dispatchEvent(new CustomEvent('intro:complete')) +} + +/** + * Stream `#intro-gen` char-by-char. The full bio is already in the DOM (SSR); + * we split every text node into `.gen-char` spans (hidden by CSS), then emit + * them in small "token" batches with punctuation-aware pacing. Each `.gen-entity` + * lights up the moment its final character is emitted. + * + * Returns the elapsed stream time in seconds (shown in the status line). + */ +function streamGeneration(gen: HTMLElement): Promise { + const start = performance.now() + + // Split text nodes into per-char spans. + const walker = document.createTreeWalker(gen, NodeFilter.SHOW_TEXT) + const textNodes: Text[] = [] + let node: Node | null + while ((node = walker.nextNode())) textNodes.push(node as Text) + for (const t of textNodes) { + const text = t.textContent ?? '' + const frag = document.createDocumentFragment() + for (const ch of text) { + const span = document.createElement('span') + span.className = 'gen-char' + span.textContent = ch === ' ' ? '\u00A0' : ch + frag.appendChild(span) + } + t.replaceWith(frag) + } + + const chars = Array.from(gen.querySelectorAll('.gen-char')) + // Map each entity to the index of its last char so we know when to light it. + const entityLast = new Map() + chars.forEach((c, i) => { + const ent = c.closest('.gen-entity') + if (ent) entityLast.set(ent, i) + }) + + const cursor = document.createElement('span') + cursor.className = 'gen-cursor' + gen.appendChild(cursor) + + return new Promise((resolve) => { + let i = 0 + const tick = async () => { + if (i >= chars.length) { + resolve((performance.now() - start) / 1000) + return + } + // Emit a small "token" (1–3 chars) per tick. + const tokLen = 1 + Math.floor(Math.random() * 3) + for (let k = 0; k < tokLen && i < chars.length; k++, i++) { + const c = chars[i] + c.after(cursor) + c.style.opacity = '1' + c.style.filter = 'blur(0px)' + const ent = c.closest('.gen-entity') + if (ent && entityLast.get(ent) === i) { + ent.classList.add('entity-active') + } + } + // Punctuation-aware pacing — reads like real sampling. + const last = chars[i - 1]?.textContent || '' + let delay = 26 + Math.random() * 24 + if (last === '.') delay += 240 + else if (last === ',') delay += 90 + if (Math.random() < 0.05) delay += 130 // occasional "thinking" pause + await sleep(delay) + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + }) +} + +/** Reveal the hero (#content-header + #content container). */ +function revealHero(): void { + const header = document.getElementById('content-header') + const content = document.getElementById('content') + for (const el of [header, content]) { + if (!el) continue + gsap.to(el, { + opacity: 1, + scale: 1, + filter: 'blur(0px)', + duration: 1, + ease: 'power2.out' + }) + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +/** Safety net — if anything fails, just reveal the page. */ +function revealImmediately() { + const overlay = document.getElementById('intro-overlay') + const doc = document.documentElement + doc.classList.remove('intro-active') + doc.classList.remove('intro-hidden') + doc.classList.add('intro-skip') + if (overlay) overlay.classList.add('intro-done') + + document + .querySelectorAll('main #content section, #content, #content-header') + .forEach((s) => { + s.style.opacity = '' + s.style.transform = '' + s.style.filter = '' + s.style.transition = '' + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).__introDone = true + window.dispatchEvent(new CustomEvent('intro:complete')) +} 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/*"]