From 58474fbf0d60b0768c35ee736ec38b0462198bce Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 15:45:45 +0000 Subject: [PATCH 1/2] feat(about): stream AI-style status lines while profile loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the plain 'Loading…' text on the About Me page with a ProfileLoader that types status lines character by character with a blinking cursor, so slow profile fetches look like generation instead of a stall. Co-Authored-By: Claude Opus 5 (1M context) --- src/screens/AboutMe.tsx | 3 +- src/screens/about-me/ProfileLoader.test.tsx | 31 ++++++++++ src/screens/about-me/ProfileLoader.tsx | 64 +++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/screens/about-me/ProfileLoader.test.tsx create mode 100644 src/screens/about-me/ProfileLoader.tsx 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..545fcb8 --- /dev/null +++ b/src/screens/about-me/ProfileLoader.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import { ProfileLoader } from './ProfileLoader' + +describe('ProfileLoader', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('streams the first status line character by character', () => { + render() + const status = screen.getByRole('status') + expect(status).toHaveTextContent('generating profile') + + act(() => vi.advanceTimersByTime(28 * 5)) + expect(status).toHaveTextContent('Fetch') + expect(status).not.toHaveTextContent('Fetching profile data…') + + act(() => vi.advanceTimersByTime(28 * 30)) + expect(status).toHaveTextContent('Fetching profile data…') + }) + + it('moves on to the next line after a pause', () => { + render() + const status = screen.getByRole('status') + + act(() => vi.advanceTimersByTime(28 * 30 + 350)) + expect(status).toHaveTextContent('Fetching profile data…') + act(() => vi.advanceTimersByTime(28 * 10)) + expect(status).toHaveTextContent('Reading e') + }) +}) diff --git a/src/screens/about-me/ProfileLoader.tsx b/src/screens/about-me/ProfileLoader.tsx new file mode 100644 index 0000000..85cb2d0 --- /dev/null +++ b/src/screens/about-me/ProfileLoader.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useEffect, useState } from 'react' + +/** Status lines "generated" while the profile JSON loads. The last one repeats. */ +const LINES = [ + 'Fetching profile data…', + 'Reading experience…', + 'Indexing projects…', + 'Compiling certifications…', + 'Formatting output…', +] + +const CHAR_DELAY_MS = 28 +const LINE_PAUSE_MS = 350 + +/** AI-style streaming placeholder shown while the profile loads. */ +export function ProfileLoader() { + const [done, setDone] = useState([]) + const [lineIndex, setLineIndex] = useState(0) + const [typed, setTyped] = useState('') + + useEffect(() => { + const line = LINES[Math.min(lineIndex, LINES.length - 1)] + let charIndex = 0 + let pauseTimer: ReturnType | undefined + + const typeInterval = setInterval(() => { + charIndex += 1 + setTyped(line.slice(0, charIndex)) + if (charIndex < line.length) return + + clearInterval(typeInterval) + pauseTimer = setTimeout(() => { + setDone((prev) => [...prev, line]) + setTyped('') + setLineIndex((prev) => prev + 1) + }, LINE_PAUSE_MS) + }, CHAR_DELAY_MS) + + return () => { + clearInterval(typeInterval) + clearTimeout(pauseTimer) + } + }, [lineIndex]) + + return ( +
+

+ + generating profile +

+ {done.map((line, i) => ( +

+ {line} +

+ ))} +

+ {typed} + ▍ +

+
+ ) +} From acc44a1f52329e96dbeaa99356b1a4b6c13917b8 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Thu, 17 Sep 2026 16:16:52 +0000 Subject: [PATCH 2/2] feat(about): denoise skeleton cards into view while profile loads Replaces the typewriter placeholder with skeleton ExperienceCards that start as a blurred, film-grain image and sharpen over 50 steps, so a slow profile fetch looks like an image being generated. Co-Authored-By: Claude Opus 5 (1M context) --- src/screens/about-me/ProfileLoader.test.tsx | 32 +++-- src/screens/about-me/ProfileLoader.tsx | 125 +++++++++++++------- 2 files changed, 94 insertions(+), 63 deletions(-) diff --git a/src/screens/about-me/ProfileLoader.test.tsx b/src/screens/about-me/ProfileLoader.test.tsx index 545fcb8..9e8f8d2 100644 --- a/src/screens/about-me/ProfileLoader.test.tsx +++ b/src/screens/about-me/ProfileLoader.test.tsx @@ -2,30 +2,28 @@ 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('streams the first status line character by character', () => { - render() - const status = screen.getByRole('status') - expect(status).toHaveTextContent('generating profile') - - act(() => vi.advanceTimersByTime(28 * 5)) - expect(status).toHaveTextContent('Fetch') - expect(status).not.toHaveTextContent('Fetching profile data…') + 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(28 * 30)) - expect(status).toHaveTextContent('Fetching profile data…') + act(() => vi.advanceTimersByTime(90 * 10)) + expect(screen.getByRole('status')).toHaveTextContent('step 10/50') }) - it('moves on to the next line after a pause', () => { - render() - const status = screen.getByRole('status') + it('sharpens to zero blur at the final step and stops', () => { + const { container } = render() - act(() => vi.advanceTimersByTime(28 * 30 + 350)) - expect(status).toHaveTextContent('Fetching profile data…') - act(() => vi.advanceTimersByTime(28 * 10)) - expect(status).toHaveTextContent('Reading e') + 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 index 85cb2d0..6ead905 100644 --- a/src/screens/about-me/ProfileLoader.tsx +++ b/src/screens/about-me/ProfileLoader.tsx @@ -2,63 +2,96 @@ import { useEffect, useState } from 'react' -/** Status lines "generated" while the profile JSON loads. The last one repeats. */ -const LINES = [ - 'Fetching profile data…', - 'Reading experience…', - 'Indexing projects…', - 'Compiling certifications…', - 'Formatting output…', -] +const STEPS = 50 +const TICK_MS = 90 +const MAX_BLUR_PX = 24 -const CHAR_DELAY_MS = 28 -const LINE_PAUSE_MS = 350 +/** 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) => ( + + ))} +
+
+
+
+ ) +} -/** AI-style streaming placeholder shown while the profile loads. */ +/** + * 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 [done, setDone] = useState([]) - const [lineIndex, setLineIndex] = useState(0) - const [typed, setTyped] = useState('') + const [step, setStep] = useState(0) useEffect(() => { - const line = LINES[Math.min(lineIndex, LINES.length - 1)] - let charIndex = 0 - let pauseTimer: ReturnType | undefined + const interval = setInterval(() => setStep((s) => Math.min(s + 1, STEPS)), TICK_MS) + return () => clearInterval(interval) + }, []) - const typeInterval = setInterval(() => { - charIndex += 1 - setTyped(line.slice(0, charIndex)) - if (charIndex < line.length) return - - clearInterval(typeInterval) - pauseTimer = setTimeout(() => { - setDone((prev) => [...prev, line]) - setTyped('') - setLineIndex((prev) => prev + 1) - }, LINE_PAUSE_MS) - }, CHAR_DELAY_MS) - - return () => { - clearInterval(typeInterval) - clearTimeout(pauseTimer) - } - }, [lineIndex]) + // 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}

- {done.map((line, i) => ( -

- {line} -

- ))} -

- {typed} - ▍ -

+ +
+ + + {/* Film-grain noise that fades out as the image "denoises" */} + +
) }