From 1c8f76c81cb123f29dcac5a61778221cef67e8f5 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Thu, 15 Oct 2026 16:23:53 +0200 Subject: [PATCH] feat(home): rework hero, skills ticker, project cards and image loading Co-Authored-By: Claude Opus 5.5 --- src/app/home/home.component.html | 123 ++++++------ src/app/home/home.component.spec.ts | 236 ++++++++++++---------- src/app/home/home.component.ts | 299 ++++++++++------------------ 3 files changed, 289 insertions(+), 369 deletions(-) diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 8d8fa3c..958b970 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -11,85 +11,75 @@
-
-

{{ content().home.contactTitle }}

- -
- -
- -
-
- {{ currentBioEntry.label }}{{ currentBioEntry.value }} +
+

{{ content().home.contactTitle }}

+
- @if (currentPortraitHighlight) { + @if (currentPortraitHighlight(); as highlight) {
} -
- - -
-
-

{{ content().home.openPgpTitle }}

-

{{ content().home.openPgpDescription }}

-
-
- - {{ content().home.openPgpDownload }} + +
+ {{ content().home.skillsLabel }}{{ currentBioText() }} +
-

- {{ content().home.openPgpFingerprintLabel }} - {{ openPgpKey.formattedFingerprint }} -

@@ -105,11 +95,11 @@
- @if (isLongBioMounted) { -
+ @if (isLongBioMounted()) { +

{{ content().home.detailParagraphs[0] }}

{{ content().home.detailParagraphs[1] }}

{{ content().home.detailParagraphs[2] }}

@@ -127,7 +117,7 @@

{{ content().home.projectsEyebrow }}

- @for (project of projects; track project.link + '-' + $index; let i = $index) { + @for (project of projects(); track project.link + '-' + $index; let i = $index) { }
- {{ project.title }} -
-
- @for (language of project.skills; track language + '-' + $index) { - {{ language }} - } + {{ project.titleHead + }}{{ project.titleTail }}

{{ project.description }}

@@ -169,4 +160,4 @@ - + diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts index 072d84e..ac5b02d 100644 --- a/src/app/home/home.component.spec.ts +++ b/src/app/home/home.component.spec.ts @@ -2,17 +2,13 @@ import type { Mock } from 'vitest'; import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { IMAGE_LOADER } from '@angular/common'; -import type { ImageLoaderConfig } from '@angular/common'; import { NgZone } from '@angular/core'; +import type { Signal, WritableSignal } from '@angular/core'; import { HomeComponent } from './home.component'; import { global } from '../../global'; import { enLanguage } from '../../languages/en'; -import type { - LanguageBioTextEntry, - LanguagePortraitHighlight, -} from '../../languages/language.types'; +import type { LanguagePortraitHighlight } from '../../languages/language.types'; describe('HomeComponent', (): void => { beforeEach((): void => { @@ -42,26 +38,24 @@ describe('HomeComponent', (): void => { interface ComponentAccess { age: number; - currentIndex: number; - isLongBioShown: boolean; - isLongBioMounted: boolean; - isPortraitSwitching: boolean; - currentPortraitHighlightIndex: number; + currentIndex: WritableSignal; + isBioTickerPaused: WritableSignal; + isLongBioShown: WritableSignal; + isLongBioMounted: WritableSignal; + isPortraitSwitching: WritableSignal; + currentPortraitHighlightIndex: WritableSignal; + portraitHighlights: Signal; bioHideTimeoutId: number | null; - scrollAnimationFrameId: number | null; - projectEntryAnimationFrameId: number | null; - aboutSectionAnimationFrameId: number | null; - scheduleNameGradientUpdate(): void; - scheduleProjectEntryRevealUpdate(): void; - scheduleAboutSectionScrollAnimationUpdate(): void; + scrollEffectsAnimationFrameId: number | null; + scheduleScrollEffectsUpdate(): void; shouldEnableScrollEffects(): boolean; toggleBio(): void; showNextPortraitHighlight(): void; scrollToAboutSection(): void; scrollToProjectsSection(): void; scrollToContactLinks(): void; - currentBioEntry: LanguageBioTextEntry; - currentPortraitHighlight: LanguagePortraitHighlight | null; + currentBioText: Signal; + currentPortraitHighlight: Signal; } let comp: ComponentAccess; @@ -69,13 +63,7 @@ describe('HomeComponent', (): void => { beforeEach(async (): Promise => { await TestBed.configureTestingModule({ imports: [HomeComponent], - providers: [ - provideRouter([]), - { - provide: IMAGE_LOADER, - useValue: (config: ImageLoaderConfig): string => config.src, - }, - ], + providers: [provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(HomeComponent); @@ -85,7 +73,7 @@ describe('HomeComponent', (): void => { }); afterEach((): void => { - // Explicitly destroy to stop the interval(1000) subscription started in ngOnInit. + // Explicitly destroy to stop the bio ticker interval started after the first render. // Without this, the interval keeps firing after the test has finished. fixture.destroy(); }); @@ -115,40 +103,69 @@ describe('HomeComponent', (): void => { // ── Bio cycling ──────────────────────────────────────────────────────────── - describe('currentBioEntry getter', (): void => { + describe('currentBioText', (): void => { it('should return the first bioTextsList entry at index 0', (): void => { - comp.currentIndex = 0; - expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]); + comp.currentIndex.set(0); + expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]); }); it('should return the correct entry when index changes', (): void => { - comp.currentIndex = 3; - expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]); + comp.currentIndex.set(3); + expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[3]); }); - it('should return the fallback entry when index is out of range', (): void => { - comp.currentIndex = 99999; - expect(comp.currentBioEntry.label).toBeDefined(); - expect(comp.currentBioEntry.value).toBeDefined(); + it('should return an empty text when index is out of range', (): void => { + comp.currentIndex.set(99999); + expect(comp.currentBioText()).toBe(''); }); }); describe('interval-driven bio cycling', (): void => { + it('should advance to the next entry every 1250 ms', async (): Promise => { + await vi.advanceTimersByTimeAsync(1249); + expect(comp.currentIndex()).toBe(0); + await vi.advanceTimersByTimeAsync(1); + expect(comp.currentIndex()).toBe(1); + }); + + it('should pause while the bio is hovered', async (): Promise => { + const meta = (fixture.nativeElement as HTMLElement).querySelector('.hero-meta'); + meta?.dispatchEvent(new MouseEvent('mouseenter')); + await vi.advanceTimersByTimeAsync(2500); + expect(comp.currentIndex()).toBe(0); + + meta?.dispatchEvent(new MouseEvent('mouseleave')); + await vi.advanceTimersByTimeAsync(1250); + expect(comp.currentIndex()).toBe(1); + }); + + it('should not cycle when reduced motion is requested', async (): Promise => { + vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: true }) as MediaQueryList); + const reducedFixture = TestBed.createComponent(HomeComponent); + reducedFixture.detectChanges(); + const reduced = reducedFixture.componentInstance as unknown as ComponentAccess; + + await vi.advanceTimersByTimeAsync(5000); + + expect(reduced.currentIndex()).toBe(0); + reducedFixture.destroy(); + }); + it('should start with currentIndex 0', (): void => { - expect(comp.currentIndex).toBe(0); + expect(comp.currentIndex()).toBe(0); }); it('should expose a valid bio entry for the current index', (): void => { - expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]); + expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]); }); it('should correctly wrap from the last entry back to 0 using modulo', (): void => { const last = enLanguage.bioTextsList.length - 1; - comp.currentIndex = last; - expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]); - comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length; - expect(comp.currentIndex).toBe(0); - expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]); + comp.currentIndex.set(last); + expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[last]); + comp.currentIndex.set((last + 1) % enLanguage.bioTextsList.length); + expect(comp.currentIndex()).toBe(0); + expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]); }); }); @@ -156,38 +173,52 @@ describe('HomeComponent', (): void => { describe('currentPortraitHighlight getter', (): void => { it('should return the highlight at the current index', (): void => { - comp.currentPortraitHighlightIndex = 0; - expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]); + comp.currentPortraitHighlightIndex.set(0); + expect(comp.currentPortraitHighlight()).toEqual(enLanguage.portraitHighlights[0]); }); it('should return null when index is out of range', (): void => { - comp.currentPortraitHighlightIndex = 99999; - expect(comp.currentPortraitHighlight).toBeNull(); + comp.currentPortraitHighlightIndex.set(99999); + expect(comp.currentPortraitHighlight()).toBeNull(); + }); + }); + + describe('portrait preloading', (): void => { + it('should preload every highlight image except the visible one', (): void => { + const preloaded: string[] = []; + vi.spyOn(window, 'Image').mockImplementation(function (): HTMLImageElement { + const image = document.createElement('img'); + Object.defineProperty(image, 'src', { + set: (value: string): void => { + preloaded.push(value); + }, + }); + return image; + }); + + const preloadFixture = TestBed.createComponent(HomeComponent); + preloadFixture.detectChanges(); + + expect(preloaded).toEqual(enLanguage.portraitHighlights.slice(1).map((h) => h.image)); + preloadFixture.destroy(); }); }); describe('showNextPortraitHighlight', (): void => { - // Timer tests run outside Angular zone to prevent NgOptimizedImage from - // throwing NG02953 when height/width inputs change during re-render. - it('should set isPortraitSwitching to true immediately', (): void => { - comp.isPortraitSwitching = false; + comp.isPortraitSwitching.set(false); comp.showNextPortraitHighlight(); - expect(comp.isPortraitSwitching).toBe(true); + expect(comp.isPortraitSwitching()).toBe(true); }); it('should not advance index when already switching', (): void => { - comp.isPortraitSwitching = true; + comp.isPortraitSwitching.set(true); comp.showNextPortraitHighlight(); - expect(comp.currentPortraitHighlightIndex).toBe(0); + expect(comp.currentPortraitHighlightIndex()).toBe(0); }); it('should not call setTimeout when only one portrait exists', (): void => { - const highlights = ( - comp as unknown as { - portraitHighlights: LanguagePortraitHighlight[]; - } - ).portraitHighlights; + const highlights = comp.portraitHighlights(); const saved = [...highlights]; // snapshot before mutation highlights.splice(1, saved.length - 1); // reduce to a single entry @@ -204,7 +235,7 @@ describe('HomeComponent', (): void => { comp.showNextPortraitHighlight(); }); await vi.advanceTimersByTimeAsync(125); - expect(comp.currentPortraitHighlightIndex).toBe(1); + expect(comp.currentPortraitHighlightIndex()).toBe(1); await vi.advanceTimersByTimeAsync(125); }); @@ -214,17 +245,17 @@ describe('HomeComponent', (): void => { comp.showNextPortraitHighlight(); }); await vi.advanceTimersByTimeAsync(250); - expect(comp.isPortraitSwitching).toBe(false); + expect(comp.isPortraitSwitching()).toBe(false); }); it('should wrap portrait index from the last position back to 0', async (): Promise => { - comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1; + comp.currentPortraitHighlightIndex.set(enLanguage.portraitHighlights.length - 1); const zone = TestBed.inject(NgZone); zone.runOutsideAngular((): void => { comp.showNextPortraitHighlight(); }); await vi.advanceTimersByTimeAsync(125); - expect(comp.currentPortraitHighlightIndex).toBe(0); + expect(comp.currentPortraitHighlightIndex()).toBe(0); await vi.advanceTimersByTimeAsync(125); }); }); @@ -234,7 +265,7 @@ describe('HomeComponent', (): void => { describe('toggleBio', (): void => { it('should mount bio immediately on opening', async (): Promise => { comp.toggleBio(); - expect(comp.isLongBioMounted).toBe(true); + expect(comp.isLongBioMounted()).toBe(true); await vi.advanceTimersByTimeAsync(100); }); @@ -242,7 +273,7 @@ describe('HomeComponent', (): void => { comp.toggleBio(); await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute fixture.detectChanges(); - expect(comp.isLongBioShown).toBe(true); + expect(comp.isLongBioShown()).toBe(true); }); it('should set isLongBioShown to false immediately on close', async (): Promise => { @@ -254,7 +285,7 @@ describe('HomeComponent', (): void => { // Close comp.toggleBio(); fixture.detectChanges(); - expect(comp.isLongBioShown).toBe(false); + expect(comp.isLongBioShown()).toBe(false); await vi.advanceTimersByTimeAsync(300); }); @@ -265,14 +296,14 @@ describe('HomeComponent', (): void => { comp.toggleBio(); fixture.detectChanges(); - expect(comp.isLongBioMounted).toBe(true); + expect(comp.isLongBioMounted()).toBe(true); await vi.advanceTimersByTimeAsync(299); - expect(comp.isLongBioMounted).toBe(true); + expect(comp.isLongBioMounted()).toBe(true); await vi.advanceTimersByTimeAsync(1); fixture.detectChanges(); - expect(comp.isLongBioMounted).toBe(false); + expect(comp.isLongBioMounted()).toBe(false); }); it('should cancel pending close timeout when re-opened during animation', async (): Promise => { @@ -287,8 +318,8 @@ describe('HomeComponent', (): void => { await vi.advanceTimersByTimeAsync(50); fixture.detectChanges(); - expect(comp.isLongBioMounted).toBe(true); - expect(comp.isLongBioShown).toBe(true); + expect(comp.isLongBioMounted()).toBe(true); + expect(comp.isLongBioShown()).toBe(true); await vi.advanceTimersByTimeAsync(500); }); @@ -400,7 +431,13 @@ describe('HomeComponent', (): void => { expect(h1?.textContent).toContain(global.lastname); }); - it('should render the bio label from the current bioTextsList entry', (): void => { + it('should link the skills box to the LinkedIn skills page', (): void => { + const el = fixture.nativeElement as HTMLElement; + const link = el.querySelector('a.hero-meta'); + expect(link?.getAttribute('href')).toBe(global.linkedinSkillsUrl); + }); + + it('should render the skills label above the rotating skill', (): void => { const el = fixture.nativeElement as HTMLElement; expect(el.querySelector('.meta-label')).not.toBeNull(); }); @@ -426,6 +463,17 @@ describe('HomeComponent', (): void => { expect(entries.length).toBe(enLanguage.projects.length); }); + it('should keep the arrow on the same line as the last title word', (): void => { + const el = fixture.nativeElement as HTMLElement; + const tails = Array.from(el.querySelectorAll('.project-title-tail')); + expect(tails.map((tail): string => tail.textContent?.trim() ?? '')).toEqual( + enLanguage.projects.map((project): string => project.title.split(' ').at(-1) ?? ''), + ); + tails.forEach((tail): void => { + expect(tail.querySelector('.project-arrow')).not.toBeNull(); + }); + }); + it('should render all project titles', (): void => { const el = fixture.nativeElement as HTMLElement; enLanguage.projects.forEach((project): void => { @@ -495,10 +543,10 @@ describe('HomeComponent', (): void => { expect(performance.now() - start).toBeLessThan(100); }); - it('should deduplicate rapid schedule calls to a single requestAnimationFrame', async (): Promise => { - // The RAF registered during beforeEach's ngOnInit is still pending under fake timers. + it('should batch all scroll effects into a single requestAnimationFrame', async (): Promise => { + // The RAF registered after the first render is still pending under fake timers. // Resetting the ID to null makes the schedule function treat the slot as free. - comp.scrollAnimationFrameId = null; + comp.scrollEffectsAnimationFrameId = null; let rafCallCount = 0; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => { @@ -506,41 +554,9 @@ describe('HomeComponent', (): void => { return rafCallCount; // returns non-null so subsequent calls deduplicate }); - comp.scheduleNameGradientUpdate(); - comp.scheduleNameGradientUpdate(); - comp.scheduleNameGradientUpdate(); - - expect(rafCallCount).toBe(1); - }); - - it('should deduplicate project-entry reveal scheduling the same way', async (): Promise => { - comp.projectEntryAnimationFrameId = null; - - let rafCallCount = 0; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => { - rafCallCount++; - return rafCallCount; - }); - - comp.scheduleProjectEntryRevealUpdate(); - comp.scheduleProjectEntryRevealUpdate(); - comp.scheduleProjectEntryRevealUpdate(); - - expect(rafCallCount).toBe(1); - }); - - it('should deduplicate about-section scroll scheduling the same way', async (): Promise => { - comp.aboutSectionAnimationFrameId = null; - - let rafCallCount = 0; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => { - rafCallCount++; - return rafCallCount; - }); - - comp.scheduleAboutSectionScrollAnimationUpdate(); - comp.scheduleAboutSectionScrollAnimationUpdate(); - comp.scheduleAboutSectionScrollAnimationUpdate(); + comp.scheduleScrollEffectsUpdate(); + comp.scheduleScrollEffectsUpdate(); + comp.scheduleScrollEffectsUpdate(); expect(rafCallCount).toBe(1); }); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 0bc15f2..350ada0 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -1,142 +1,103 @@ -import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core'; +import type { OnDestroy } from '@angular/core'; import { + afterNextRender, ChangeDetectionStrategy, Component, + computed, DestroyRef, inject, - NgZone, signal, } from '@angular/core'; -import { NgOptimizedImage } from '@angular/common'; -import { interval } from 'rxjs'; +import { filter, interval } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { global } from '../../global'; -import { openPgpKey } from '../generated/openpgp-key.generated'; -import type { - LanguageBioTextEntry, - LanguagePortraitHighlight, - LanguageProject, -} from '../../languages/language.types'; import { FooterComponent } from '../footer/footer.component'; import { LanguageService } from '../services/language.service'; import { IconComponent } from '../shared/icon/icon.component'; import { arrowDownIcon, arrowRightIcon, - discordIcon, envelopeIcon, - githubIcon, + giteaIcon, linkedinIcon, phoneIcon, + signalIcon, } from '../shared/icon/icons'; @Component({ selector: 'app-home', standalone: true, - imports: [FooterComponent, IconComponent, NgOptimizedImage], + imports: [FooterComponent, IconComponent], templateUrl: './home.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { +export class HomeComponent implements OnDestroy { + private static readonly bioTickerIntervalMs = 1250; + private readonly destroyRef = inject(DestroyRef); - private readonly zone = inject(NgZone); private readonly languageService = inject(LanguageService); protected readonly content = this.languageService.content; - private scrollAnimationFrameId: number | null = null; - private projectEntryAnimationFrameId: number | null = null; - private aboutSectionAnimationFrameId: number | null = null; - private readonly scheduleNameGradientUpdate: () => void = (): void => { - if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return; - this.scrollAnimationFrameId = window.requestAnimationFrame((): void => { - this.scrollAnimationFrameId = null; + private scrollEffectsAnimationFrameId: number | null = null; + private readonly scheduleScrollEffectsUpdate: () => void = (): void => { + if (this.scrollEffectsAnimationFrameId !== null) return; + this.scrollEffectsAnimationFrameId = window.requestAnimationFrame((): void => { + this.scrollEffectsAnimationFrameId = null; this.updateNameGradientProgress(); - }); - }; - private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => { - if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') return; - this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => { - this.projectEntryAnimationFrameId = null; - this.updateProjectEntryRevealProgress(); - }); - }; - private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => { - if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') return; - this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => { - this.aboutSectionAnimationFrameId = null; this.updateAboutSectionScrollProgress(); + this.updateProjectEntryRevealProgress(); }); }; protected readonly global = global; protected readonly age = this.calculateAge(global.birthdate); protected readonly contactMailHref = `mailto:${global.contactMail}`; - protected readonly openPgpKey = openPgpKey; protected readonly arrowRightIcon = arrowRightIcon; protected readonly arrowDownIcon = arrowDownIcon; protected readonly envelopeIcon = envelopeIcon; protected readonly phoneIcon = phoneIcon; - protected readonly discordIcon = discordIcon; - protected readonly githubIcon = githubIcon; + protected readonly signalIcon = signalIcon; + protected readonly giteaIcon = giteaIcon; protected readonly linkedinIcon = linkedinIcon; - private readonly isLongBioShownState = signal(false); - private readonly isLongBioMountedState = signal(false); - private readonly currentPortraitHighlightIndexState = signal(0); - private readonly isPortraitSwitchingState = signal(false); - private readonly currentIndexState = signal(0); + protected readonly isLongBioShown = signal(false); + protected readonly isLongBioMounted = signal(false); + protected readonly currentPortraitHighlightIndex = signal(0); + protected readonly isPortraitSwitching = signal(false); + protected readonly currentIndex = signal(0); + protected readonly isBioTickerPaused = signal(false); private bioHideTimeoutId: number | null = null; private portraitSwitchTimeoutId: number | null = null; private portraitSwitchDoneTimeoutId: number | null = null; - protected get isLongBioShown(): boolean { - return this.isLongBioShownState(); - } - protected set isLongBioShown(value: boolean) { - this.isLongBioShownState.set(value); - } - protected get isLongBioMounted(): boolean { - return this.isLongBioMountedState(); - } - protected set isLongBioMounted(value: boolean) { - this.isLongBioMountedState.set(value); - } - protected get currentPortraitHighlightIndex(): number { - return this.currentPortraitHighlightIndexState(); - } - protected set currentPortraitHighlightIndex(value: number) { - this.currentPortraitHighlightIndexState.set(value); - } - protected get isPortraitSwitching(): boolean { - return this.isPortraitSwitchingState(); - } - protected set isPortraitSwitching(value: boolean) { - this.isPortraitSwitchingState.set(value); - } - protected get currentIndex(): number { - return this.currentIndexState(); - } - protected set currentIndex(value: number) { - this.currentIndexState.set(value); - } - protected get portraitHighlights(): LanguagePortraitHighlight[] { - return this.content().portraitHighlights; - } - protected get projects(): LanguageProject[] { - return this.content().projects; - } - protected get currentPortraitHighlight(): LanguagePortraitHighlight | null { - return this.portraitHighlights[this.currentPortraitHighlightIndexState()] ?? null; - } - protected get currentBioEntry(): LanguageBioTextEntry { - return ( - this.content().bioTextsList[this.currentIndexState()] ?? { - label: this.content().home.fallbackBioLabel, - value: '', - } - ); - } + protected readonly portraitHighlights = computed(() => this.content().portraitHighlights); + /** Splits off the last title word so the arrow stays glued to it and never wraps alone. */ + protected readonly projects = computed(() => + this.content().projects.map((project) => { + const tailStart = project.title.lastIndexOf(' ') + 1; + return { + ...project, + titleHead: project.title.slice(0, tailStart), + titleTail: project.title.slice(tailStart), + }; + }), + ); + protected readonly currentPortraitHighlight = computed( + () => this.portraitHighlights()[this.currentPortraitHighlightIndex()] ?? null, + ); + protected readonly currentBioText = computed( + (): string => this.content().bioTextsList[this.currentIndex()] ?? '', + ); + + private readonly startBrowserEffects = afterNextRender((): void => { + this.preloadPortraitHighlights(); + if (!this.shouldEnableScrollEffects()) return; + this.startBioTicker(); + window.addEventListener('scroll', this.scheduleScrollEffectsUpdate, { passive: true }); + window.addEventListener('resize', this.scheduleScrollEffectsUpdate, { passive: true }); + this.scheduleScrollEffectsUpdate(); + }); protected interpolate(text: string): string { return text @@ -144,78 +105,48 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { .replaceAll('{{age}}', String(this.age)); } - ngOnInit(): void { - this.zone.runOutsideAngular((): void => { - this.initNameGradientScrollAnimation(); - }); - this.zone.runOutsideAngular((): void => { - interval(1000) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((): void => { - this.zone.run((): void => { - const len = this.content().bioTextsList.length; - this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0; - }); - }); - }); - } - - ngAfterViewInit(): void { - this.zone.runOutsideAngular((): void => { - this.initProjectEntryRevealObserver(); - this.initAboutSectionScrollAnimation(); - }); - } ngOnDestroy(): void { + if (typeof window === 'undefined') return; if (this.bioHideTimeoutId !== null) { window.clearTimeout(this.bioHideTimeoutId); this.bioHideTimeoutId = null; } this.clearPortraitSwitchTimeouts(); - this.destroyNameGradientScrollAnimation(); - this.destroyProjectEntryRevealObserver(); - this.destroyAboutSectionScrollAnimation(); + this.destroyScrollEffects(); } protected toggleBio(): void { - if (this.isLongBioShown) { - this.isLongBioShown = false; - if (typeof window !== 'undefined') { - if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId); - this.bioHideTimeoutId = window.setTimeout((): void => { - this.isLongBioMounted = false; - this.bioHideTimeoutId = null; - }, 300); - } else { - this.isLongBioMounted = false; - } + if (this.isLongBioShown()) { + this.isLongBioShown.set(false); + if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId); + this.bioHideTimeoutId = window.setTimeout((): void => { + this.isLongBioMounted.set(false); + this.bioHideTimeoutId = null; + }, 300); return; } - if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') { + if (this.bioHideTimeoutId !== null) { window.clearTimeout(this.bioHideTimeoutId); this.bioHideTimeoutId = null; } - this.isLongBioMounted = true; - if (typeof window !== 'undefined') { + this.isLongBioMounted.set(true); + window.requestAnimationFrame((): void => { window.requestAnimationFrame((): void => { - window.requestAnimationFrame((): void => { - this.isLongBioShown = true; - }); + this.isLongBioShown.set(true); }); - } else { - this.isLongBioShown = true; - } + }); } protected showNextPortraitHighlight(): void { - if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return; - this.isPortraitSwitching = true; + if (this.portraitHighlights().length <= 1 || this.isPortraitSwitching()) return; + this.isPortraitSwitching.set(true); this.portraitSwitchTimeoutId = window.setTimeout((): void => { - this.currentPortraitHighlightIndex = - (this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length; + this.currentPortraitHighlightIndex.update( + (index: number): number => (index + 1) % this.portraitHighlights().length, + ); this.portraitSwitchTimeoutId = null; }, 125); this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => { - this.isPortraitSwitching = false; + this.isPortraitSwitching.set(false); this.portraitSwitchDoneTimeoutId = null; }, 250); } @@ -239,62 +170,47 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { return !prefersReducedMotion; } - private initNameGradientScrollAnimation(): void { - if (!this.shouldEnableScrollEffects()) return; - window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true }); - window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true }); - this.scheduleNameGradientUpdate(); - } - private initProjectEntryRevealObserver(): void { - if (!this.shouldEnableScrollEffects()) return; - window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true }); - window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true }); - this.scheduleProjectEntryRevealUpdate(); - } - private destroyProjectEntryRevealObserver(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') return; - window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate); - window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate); - if (this.projectEntryAnimationFrameId !== null) { - window.cancelAnimationFrame(this.projectEntryAnimationFrameId); - this.projectEntryAnimationFrameId = null; + /** Loads the other highlight images at low priority so a click swaps them without a visible delay. */ + private preloadPortraitHighlights(): void { + const current = this.currentPortraitHighlight(); + for (const highlight of this.portraitHighlights()) { + if (highlight === current) continue; + const image = new Image(); + image.fetchPriority = 'low'; + image.sizes = highlight.imageSizes; + image.srcset = highlight.imageSrcset; + image.src = highlight.image; } + } + + private startBioTicker(): void { + interval(HomeComponent.bioTickerIntervalMs) + .pipe( + filter((): boolean => !this.isBioTickerPaused()), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((): void => { + const length = this.content().bioTextsList.length; + this.currentIndex.update((index: number): number => + length > 0 ? (index + 1) % length : 0, + ); + }); + } + + private destroyScrollEffects(): void { + window.removeEventListener('scroll', this.scheduleScrollEffectsUpdate); + window.removeEventListener('resize', this.scheduleScrollEffectsUpdate); + if (this.scrollEffectsAnimationFrameId !== null) { + window.cancelAnimationFrame(this.scrollEffectsAnimationFrameId); + this.scrollEffectsAnimationFrameId = null; + } + document.documentElement.style.removeProperty('--name-pride-progress'); + document.getElementById('about')?.style.removeProperty('--about-scroll-progress'); document.querySelectorAll('.project-entry').forEach((entry: HTMLElement): void => { entry.style.removeProperty('--project-entry-progress'); }); } - private initAboutSectionScrollAnimation(): void { - if (!this.shouldEnableScrollEffects()) return; - window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, { - passive: true, - }); - window.addEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate, { - passive: true, - }); - this.scheduleAboutSectionScrollAnimationUpdate(); - } - private destroyAboutSectionScrollAnimation(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') return; - window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate); - window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate); - if (this.aboutSectionAnimationFrameId !== null) { - window.cancelAnimationFrame(this.aboutSectionAnimationFrameId); - this.aboutSectionAnimationFrameId = null; - } - document.getElementById('about')?.style.removeProperty('--about-scroll-progress'); - } - private destroyNameGradientScrollAnimation(): void { - if (typeof window === 'undefined') return; - window.removeEventListener('scroll', this.scheduleNameGradientUpdate); - window.removeEventListener('resize', this.scheduleNameGradientUpdate); - if (this.scrollAnimationFrameId !== null) { - window.cancelAnimationFrame(this.scrollAnimationFrameId); - this.scrollAnimationFrameId = null; - } - document.documentElement.style.removeProperty('--name-pride-progress'); - } private clearPortraitSwitchTimeouts(): void { - if (typeof window === 'undefined') return; if (this.portraitSwitchTimeoutId !== null) { window.clearTimeout(this.portraitSwitchTimeoutId); this.portraitSwitchTimeoutId = null; @@ -305,7 +221,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { } } private updateNameGradientProgress(): void { - if (typeof window === 'undefined') return; const root: HTMLElement = document.documentElement; const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow'); const portfolioTriggerY: number = @@ -326,7 +241,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { root.style.setProperty('--name-pride-progress', progress.toFixed(4)); } private updateAboutSectionScrollProgress(): void { - if (typeof window === 'undefined') return; const aboutSection: HTMLElement | null = document.getElementById('about'); if (aboutSection === null) return; const sectionRect: DOMRect = aboutSection.getBoundingClientRect(); @@ -339,7 +253,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4)); } private updateProjectEntryRevealProgress(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') return; const projectEntries: NodeListOf = document.querySelectorAll('.project-entry'); const viewportHeight: number = window.innerHeight || 1; const startY: number = viewportHeight * 0.98;