diff --git a/src/screens/AboutMe.tsx b/src/screens/AboutMe.tsx
index b9a707e..94499f2 100644
--- a/src/screens/AboutMe.tsx
+++ b/src/screens/AboutMe.tsx
@@ -11,6 +11,7 @@ import { ProjectsTab } from './about-me/ProjectsTab'
import { CertificationsTab } from './about-me/CertificationsTab'
import { ExploreTab } from './about-me/ExploreTab'
import { ContactTab } from './about-me/ContactTab'
+import { ProfileLoader } from './about-me/ProfileLoader'
const TABS = [
{ id: 'experience', name: 'Experience', icon: Briefcase },
@@ -109,7 +110,7 @@ function AboutMeContent({
navigate: (path: string) => void
}) {
if (activeTab === 'explore') return
- if (loading) return
diff --git a/src/screens/about-me/ProfileLoader.test.tsx b/src/screens/about-me/ProfileLoader.test.tsx
new file mode 100644
index 0000000..9e8f8d2
--- /dev/null
+++ b/src/screens/about-me/ProfileLoader.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+import { ProfileLoader } from './ProfileLoader'
+
+function blurred(container: HTMLElement): string {
+ return (container.querySelector('[style*="blur"]') as HTMLElement).style.filter
+}
+
+describe('ProfileLoader', () => {
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => vi.useRealTimers())
+
+ it('starts fully blurred and advances the step counter', () => {
+ const { container } = render(
)
+ expect(screen.getByRole('status')).toHaveTextContent('step 0/50')
+ expect(blurred(container)).toContain('blur(24px)')
+
+ act(() => vi.advanceTimersByTime(90 * 10))
+ expect(screen.getByRole('status')).toHaveTextContent('step 10/50')
+ })
+
+ it('sharpens to zero blur at the final step and stops', () => {
+ const { container } = render(
)
+
+ act(() => vi.advanceTimersByTime(90 * 60))
+ expect(screen.getByRole('status')).toHaveTextContent('step 50/50')
+ expect(blurred(container)).toContain('blur(0px)')
+ })
+})
diff --git a/src/screens/about-me/ProfileLoader.tsx b/src/screens/about-me/ProfileLoader.tsx
new file mode 100644
index 0000000..6ead905
--- /dev/null
+++ b/src/screens/about-me/ProfileLoader.tsx
@@ -0,0 +1,97 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+const STEPS = 50
+const TICK_MS = 90
+const MAX_BLUR_PX = 24
+
+/** Grey placeholder bar; width is a Tailwind class so the skeleton mirrors the real card layout. */
+function Bar({ className }: { className: string }) {
+ return
+}
+
+/** Skeleton of an ExperienceCard: icon box, company, role, meta, bullets. */
+function SkeletonCard({ current, bullets }: { current: boolean; bullets: string[] }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {bullets.map((w, i) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+/**
+ * Diffusion-style placeholder: skeleton cards that start as a blurred, noisy image and
+ * sharpen step by step (like an image generator) while the profile loads.
+ */
+export function ProfileLoader() {
+ const [step, setStep] = useState(0)
+
+ useEffect(() => {
+ const interval = setInterval(() => setStep((s) => Math.min(s + 1, STEPS)), TICK_MS)
+ return () => clearInterval(interval)
+ }, [])
+
+ // Ease out: coarse structure appears fast, fine detail resolves slowly.
+ const progress = 1 - Math.pow(1 - step / STEPS, 3)
+ const blur = Math.round(MAX_BLUR_PX * (1 - progress) * 10) / 10
+ const noise = Math.round((1 - progress) * 70) / 100
+
+ return (
+
+
+
+ generating profile
+ · step {step}/{STEPS}
+
+
+
+
+
+
+
+
+
+ {/* Film-grain noise that fades out as the image "denoises" */}
+
+
+
+ )
+}