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 @@
- @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.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;