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

Loading…

+ if (loading) return if (error || !profile) { 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" */} + +
+
+ ) +}