feat(home): rework hero, skills ticker, project cards and image loading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fraujulianandClaude Opus 5.5 committed 2026-10-15 16:23:53 +02:00
1 parent 20aaf4faa7
commit 1c8f76c81c
3 files changed
+289 -369

No files matched your search

+57 -66
View File
@@ -11,85 +11,75 @@
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<div class="hero-contact-block">
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
<div id="contact-links" class="contact-area hero-contact-area">
<a [href]="contactMailHref" aria-label="Send mail"><app-icon [icon]="envelopeIcon" size="lg"></app-icon></a>
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number"
><app-icon [icon]="phoneIcon" size="lg"></app-icon
></a>
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile"
><app-icon [icon]="discordIcon" size="lg"></app-icon
></a>
<a href="https://git.lechner-systems.at/fraujulian" aria-label="Gitea profile"
><app-icon [icon]="githubIcon" size="lg"></app-icon
></a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile"
><app-icon [icon]="linkedinIcon" size="lg"></app-icon
></a>
</div>
</div>
<div class="hero-action-links"> <div class="hero-action-links">
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button> <button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button> <button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
</div> </div>
</div> <div class="hero-contact-block">
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
<div class="hero-meta"> <div id="contact-links" class="contact-area hero-contact-area">
<div> <a [href]="contactMailHref" [attr.aria-label]="content().home.mailAriaLabel"
<span class="meta-label">{{ currentBioEntry.label }}</span ><app-icon [icon]="envelopeIcon" size="lg"></app-icon
><strong>{{ currentBioEntry.value }}</strong> ></a>
<a href="tel:{{ global.hrefContactPhone }}" [attr.aria-label]="content().home.phoneAriaLabel"
><app-icon [icon]="phoneIcon" size="lg"></app-icon
></a>
<a [href]="global.signalUrl" [attr.aria-label]="content().home.signalAriaLabel"
><app-icon [icon]="signalIcon" size="lg"></app-icon
></a>
<a href="https://git.lechner-systems.at/fraujulian" [attr.aria-label]="content().home.giteaAriaLabel"
><app-icon [icon]="giteaIcon" size="lg"></app-icon
></a>
<a href="https://www.linkedin.com/in/julianlechner2009/" [attr.aria-label]="content().home.linkedinAriaLabel"
><app-icon [icon]="linkedinIcon" size="lg"></app-icon
></a>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="hero-visual"> <div class="hero-visual">
@if (currentPortraitHighlight) { @if (currentPortraitHighlight(); as highlight) {
<div class="portrait-highlight"> <div class="portrait-highlight">
<button <button
type="button" type="button"
class="portrait-highlight-button" class="portrait-highlight-button"
(click)="showNextPortraitHighlight()" (click)="showNextPortraitHighlight()"
[attr.aria-label]="content().home.nextHighlightAriaLabel" [attr.aria-label]="content().home.nextHighlightAriaLabel"
[title]="portraitHighlights.length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle" [title]="portraitHighlights().length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
> >
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching"> <div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching()">
<img <img
class="hero-feature-image" class="hero-feature-image"
[src]="currentPortraitHighlight.image" [src]="highlight.image"
[attr.srcset]="currentPortraitHighlight.imageSrcset" [attr.srcset]="highlight.imageSrcset"
[attr.sizes]="currentPortraitHighlight.imageSizes" [attr.sizes]="highlight.imageSizes"
[width]="currentPortraitHighlight.imageWidth" [width]="highlight.imageWidth"
[height]="currentPortraitHighlight.imageHeight" [height]="highlight.imageHeight"
fetchpriority="high" fetchpriority="high"
decoding="async" decoding="async"
[alt]="currentPortraitHighlight.text + ' ' + content().home.portraitAltSuffix" [alt]="highlight.text + ' ' + content().home.portraitAltSuffix"
/> />
@if (currentPortraitHighlightIndex === 0) { @if (currentPortraitHighlightIndex() === 0) {
<span class="portrait-click-hint">{{ content().home.clickHint }}</span> <span class="portrait-click-hint">{{ content().home.clickHint }}</span>
} }
<span class="portrait-caption">{{ currentPortraitHighlight.text }}</span> <span class="portrait-caption">{{ highlight.text }}</span>
</div> </div>
</button> </button>
</div> </div>
} }
</div> <a
</section> class="hero-meta"
[href]="global.linkedinSkillsUrl"
<section class="panel openpgp-section"> [attr.aria-label]="content().home.skillsLinkAriaLabel"
<div class="openpgp-copy"> (mouseenter)="isBioTickerPaused.set(true)"
<p class="eyebrow">{{ content().home.openPgpTitle }}</p> (mouseleave)="isBioTickerPaused.set(false)"
<p class="openpgp-description">{{ content().home.openPgpDescription }}</p> >
</div> <div>
<div class="openpgp-actions"> <span class="meta-label">{{ content().home.skillsLabel }}</span
<a class="action-link openpgp-download-link" [href]="openPgpKey.assetPath" download="julian-lechner-public.asc"> ><strong>{{ currentBioText() }}</strong>
{{ content().home.openPgpDownload }} </div>
</a> </a>
<p class="openpgp-fingerprint">
<span>{{ content().home.openPgpFingerprintLabel }}</span>
<strong>{{ openPgpKey.formattedFingerprint }}</strong>
</p>
</div> </div>
</section> </section>
@@ -105,11 +95,11 @@
</div> </div>
<div class="story-detail"> <div class="story-detail">
<button class="detail-toggle" type="button" (click)="toggleBio()"> <button class="detail-toggle" type="button" (click)="toggleBio()">
<span>{{ isLongBioShown ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span <span>{{ isLongBioShown() ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
><app-icon [icon]="isLongBioShown ? arrowDownIcon : arrowRightIcon"></app-icon> ><app-icon [icon]="isLongBioShown() ? arrowDownIcon : arrowRightIcon"></app-icon>
</button> </button>
@if (isLongBioMounted) { @if (isLongBioMounted()) {
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown"> <div class="detail-panel" [class.detail-panel-enter]="isLongBioShown()" [class.detail-panel-leave]="!isLongBioShown()">
<p>{{ content().home.detailParagraphs[0] }}</p> <p>{{ content().home.detailParagraphs[0] }}</p>
<p>{{ content().home.detailParagraphs[1] }}</p> <p>{{ content().home.detailParagraphs[1] }}</p>
<p>{{ content().home.detailParagraphs[2] }}</p> <p>{{ content().home.detailParagraphs[2] }}</p>
@@ -127,7 +117,7 @@
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p> <p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
</div> </div>
<div class="project-list"> <div class="project-list">
@for (project of projects; track project.link + '-' + $index; let i = $index) { @for (project of projects(); track project.link + '-' + $index; let i = $index) {
<a <a
class="panel project-entry" class="panel project-entry"
[class.project-entry-reverse]="i % 2 === 1" [class.project-entry-reverse]="i % 2 === 1"
@@ -141,24 +131,25 @@
<img <img
class="project-icon" class="project-icon"
[class.project-icon-circle]="project.circleIcon !== false" [class.project-icon-circle]="project.circleIcon !== false"
[ngSrc]="project.icon" [src]="project.icon"
[attr.srcset]="project.iconSrcset ?? null" [attr.srcset]="project.iconSrcset ?? null"
[attr.sizes]="project.iconSizes ?? null" [attr.sizes]="project.iconSizes ?? null"
[width]="project.iconWidth ?? 216" [width]="project.iconWidth ?? 216"
[height]="project.iconHeight ?? 216" [height]="project.iconHeight ?? 216"
disableOptimizedSrcset loading="eager"
[alt]="project.title + ' icon'" fetchpriority="low"
decoding="async"
[alt]="project.title + ' ' + content().home.projectIconAltSuffix"
/> />
} }
<div class="project-copy"> <div class="project-copy">
<div class="project-header"> <div class="project-header">
<div class="project-title-wrap"> <div class="project-title-wrap">
<span class="project-link">{{ project.title }}</span> <span class="project-link"
</div> >{{ project.titleHead
<div class="project-languages" [attr.aria-label]="content().home.projectLanguagesAriaLabel"> }}<span class="project-title-tail"
@for (language of project.skills; track language + '-' + $index) { >{{ project.titleTail }}<app-icon class="project-arrow" [icon]="arrowRightIcon"></app-icon></span
<span class="project-language-chip">{{ language }}</span> ></span>
}
</div> </div>
</div> </div>
<p class="project-description">{{ project.description }}</p> <p class="project-description">{{ project.description }}</p>
@@ -169,4 +160,4 @@
</section> </section>
</main> </main>
<app-footer ngSkipHydration></app-footer> <app-footer></app-footer>
+126 -110
View File
@@ -2,17 +2,13 @@ import type { Mock } from 'vitest';
import type { ComponentFixture } from '@angular/core/testing'; import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { IMAGE_LOADER } from '@angular/common';
import type { ImageLoaderConfig } from '@angular/common';
import { NgZone } from '@angular/core'; import { NgZone } from '@angular/core';
import type { Signal, WritableSignal } from '@angular/core';
import { HomeComponent } from './home.component'; import { HomeComponent } from './home.component';
import { global } from '../../global'; import { global } from '../../global';
import { enLanguage } from '../../languages/en'; import { enLanguage } from '../../languages/en';
import type { import type { LanguagePortraitHighlight } from '../../languages/language.types';
LanguageBioTextEntry,
LanguagePortraitHighlight,
} from '../../languages/language.types';
describe('HomeComponent', (): void => { describe('HomeComponent', (): void => {
beforeEach((): void => { beforeEach((): void => {
@@ -42,26 +38,24 @@ describe('HomeComponent', (): void => {
interface ComponentAccess { interface ComponentAccess {
age: number; age: number;
currentIndex: number; currentIndex: WritableSignal<number>;
isLongBioShown: boolean; isBioTickerPaused: WritableSignal<boolean>;
isLongBioMounted: boolean; isLongBioShown: WritableSignal<boolean>;
isPortraitSwitching: boolean; isLongBioMounted: WritableSignal<boolean>;
currentPortraitHighlightIndex: number; isPortraitSwitching: WritableSignal<boolean>;
currentPortraitHighlightIndex: WritableSignal<number>;
portraitHighlights: Signal<LanguagePortraitHighlight[]>;
bioHideTimeoutId: number | null; bioHideTimeoutId: number | null;
scrollAnimationFrameId: number | null; scrollEffectsAnimationFrameId: number | null;
projectEntryAnimationFrameId: number | null; scheduleScrollEffectsUpdate(): void;
aboutSectionAnimationFrameId: number | null;
scheduleNameGradientUpdate(): void;
scheduleProjectEntryRevealUpdate(): void;
scheduleAboutSectionScrollAnimationUpdate(): void;
shouldEnableScrollEffects(): boolean; shouldEnableScrollEffects(): boolean;
toggleBio(): void; toggleBio(): void;
showNextPortraitHighlight(): void; showNextPortraitHighlight(): void;
scrollToAboutSection(): void; scrollToAboutSection(): void;
scrollToProjectsSection(): void; scrollToProjectsSection(): void;
scrollToContactLinks(): void; scrollToContactLinks(): void;
currentBioEntry: LanguageBioTextEntry; currentBioText: Signal<string>;
currentPortraitHighlight: LanguagePortraitHighlight | null; currentPortraitHighlight: Signal<LanguagePortraitHighlight | null>;
} }
let comp: ComponentAccess; let comp: ComponentAccess;
@@ -69,13 +63,7 @@ describe('HomeComponent', (): void => {
beforeEach(async (): Promise<void> => { beforeEach(async (): Promise<void> => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [HomeComponent], imports: [HomeComponent],
providers: [ providers: [provideRouter([])],
provideRouter([]),
{
provide: IMAGE_LOADER,
useValue: (config: ImageLoaderConfig): string => config.src,
},
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(HomeComponent); fixture = TestBed.createComponent(HomeComponent);
@@ -85,7 +73,7 @@ describe('HomeComponent', (): void => {
}); });
afterEach((): 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. // Without this, the interval keeps firing after the test has finished.
fixture.destroy(); fixture.destroy();
}); });
@@ -115,40 +103,69 @@ describe('HomeComponent', (): void => {
// ── Bio cycling ──────────────────────────────────────────────────────────── // ── Bio cycling ────────────────────────────────────────────────────────────
describe('currentBioEntry getter', (): void => { describe('currentBioText', (): void => {
it('should return the first bioTextsList entry at index 0', (): void => { it('should return the first bioTextsList entry at index 0', (): void => {
comp.currentIndex = 0; comp.currentIndex.set(0);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]); expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
}); });
it('should return the correct entry when index changes', (): void => { it('should return the correct entry when index changes', (): void => {
comp.currentIndex = 3; comp.currentIndex.set(3);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]); expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[3]);
}); });
it('should return the fallback entry when index is out of range', (): void => { it('should return an empty text when index is out of range', (): void => {
comp.currentIndex = 99999; comp.currentIndex.set(99999);
expect(comp.currentBioEntry.label).toBeDefined(); expect(comp.currentBioText()).toBe('');
expect(comp.currentBioEntry.value).toBeDefined();
}); });
}); });
describe('interval-driven bio cycling', (): void => { describe('interval-driven bio cycling', (): void => {
it('should advance to the next entry every 1250 ms', async (): Promise<void> => {
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<void> => {
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<void> => {
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 => { 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 => { 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 => { it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
const last = enLanguage.bioTextsList.length - 1; const last = enLanguage.bioTextsList.length - 1;
comp.currentIndex = last; comp.currentIndex.set(last);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]); expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[last]);
comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length; comp.currentIndex.set((last + 1) % enLanguage.bioTextsList.length);
expect(comp.currentIndex).toBe(0); expect(comp.currentIndex()).toBe(0);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]); expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
}); });
}); });
@@ -156,38 +173,52 @@ describe('HomeComponent', (): void => {
describe('currentPortraitHighlight getter', (): void => { describe('currentPortraitHighlight getter', (): void => {
it('should return the highlight at the current index', (): void => { it('should return the highlight at the current index', (): void => {
comp.currentPortraitHighlightIndex = 0; comp.currentPortraitHighlightIndex.set(0);
expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]); expect(comp.currentPortraitHighlight()).toEqual(enLanguage.portraitHighlights[0]);
}); });
it('should return null when index is out of range', (): void => { it('should return null when index is out of range', (): void => {
comp.currentPortraitHighlightIndex = 99999; comp.currentPortraitHighlightIndex.set(99999);
expect(comp.currentPortraitHighlight).toBeNull(); 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 => { 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 => { it('should set isPortraitSwitching to true immediately', (): void => {
comp.isPortraitSwitching = false; comp.isPortraitSwitching.set(false);
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
expect(comp.isPortraitSwitching).toBe(true); expect(comp.isPortraitSwitching()).toBe(true);
}); });
it('should not advance index when already switching', (): void => { it('should not advance index when already switching', (): void => {
comp.isPortraitSwitching = true; comp.isPortraitSwitching.set(true);
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
expect(comp.currentPortraitHighlightIndex).toBe(0); expect(comp.currentPortraitHighlightIndex()).toBe(0);
}); });
it('should not call setTimeout when only one portrait exists', (): void => { it('should not call setTimeout when only one portrait exists', (): void => {
const highlights = ( const highlights = comp.portraitHighlights();
comp as unknown as {
portraitHighlights: LanguagePortraitHighlight[];
}
).portraitHighlights;
const saved = [...highlights]; // snapshot before mutation const saved = [...highlights]; // snapshot before mutation
highlights.splice(1, saved.length - 1); // reduce to a single entry highlights.splice(1, saved.length - 1); // reduce to a single entry
@@ -204,7 +235,7 @@ describe('HomeComponent', (): void => {
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
}); });
await vi.advanceTimersByTimeAsync(125); await vi.advanceTimersByTimeAsync(125);
expect(comp.currentPortraitHighlightIndex).toBe(1); expect(comp.currentPortraitHighlightIndex()).toBe(1);
await vi.advanceTimersByTimeAsync(125); await vi.advanceTimersByTimeAsync(125);
}); });
@@ -214,17 +245,17 @@ describe('HomeComponent', (): void => {
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
}); });
await vi.advanceTimersByTimeAsync(250); 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<void> => { it('should wrap portrait index from the last position back to 0', async (): Promise<void> => {
comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1; comp.currentPortraitHighlightIndex.set(enLanguage.portraitHighlights.length - 1);
const zone = TestBed.inject(NgZone); const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => { zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight(); comp.showNextPortraitHighlight();
}); });
await vi.advanceTimersByTimeAsync(125); await vi.advanceTimersByTimeAsync(125);
expect(comp.currentPortraitHighlightIndex).toBe(0); expect(comp.currentPortraitHighlightIndex()).toBe(0);
await vi.advanceTimersByTimeAsync(125); await vi.advanceTimersByTimeAsync(125);
}); });
}); });
@@ -234,7 +265,7 @@ describe('HomeComponent', (): void => {
describe('toggleBio', (): void => { describe('toggleBio', (): void => {
it('should mount bio immediately on opening', async (): Promise<void> => { it('should mount bio immediately on opening', async (): Promise<void> => {
comp.toggleBio(); comp.toggleBio();
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
}); });
@@ -242,7 +273,7 @@ describe('HomeComponent', (): void => {
comp.toggleBio(); comp.toggleBio();
await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioShown).toBe(true); expect(comp.isLongBioShown()).toBe(true);
}); });
it('should set isLongBioShown to false immediately on close', async (): Promise<void> => { it('should set isLongBioShown to false immediately on close', async (): Promise<void> => {
@@ -254,7 +285,7 @@ describe('HomeComponent', (): void => {
// Close // Close
comp.toggleBio(); comp.toggleBio();
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioShown).toBe(false); expect(comp.isLongBioShown()).toBe(false);
await vi.advanceTimersByTimeAsync(300); await vi.advanceTimersByTimeAsync(300);
}); });
@@ -265,14 +296,14 @@ describe('HomeComponent', (): void => {
comp.toggleBio(); comp.toggleBio();
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(299); await vi.advanceTimersByTimeAsync(299);
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted()).toBe(true);
await vi.advanceTimersByTimeAsync(1); await vi.advanceTimersByTimeAsync(1);
fixture.detectChanges(); 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<void> => { it('should cancel pending close timeout when re-opened during animation', async (): Promise<void> => {
@@ -287,8 +318,8 @@ describe('HomeComponent', (): void => {
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
fixture.detectChanges(); fixture.detectChanges();
expect(comp.isLongBioMounted).toBe(true); expect(comp.isLongBioMounted()).toBe(true);
expect(comp.isLongBioShown).toBe(true); expect(comp.isLongBioShown()).toBe(true);
await vi.advanceTimersByTimeAsync(500); await vi.advanceTimersByTimeAsync(500);
}); });
@@ -400,7 +431,13 @@ describe('HomeComponent', (): void => {
expect(h1?.textContent).toContain(global.lastname); 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<HTMLAnchorElement>('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; const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.meta-label')).not.toBeNull(); expect(el.querySelector('.meta-label')).not.toBeNull();
}); });
@@ -426,6 +463,17 @@ describe('HomeComponent', (): void => {
expect(entries.length).toBe(enLanguage.projects.length); 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 => { it('should render all project titles', (): void => {
const el = fixture.nativeElement as HTMLElement; const el = fixture.nativeElement as HTMLElement;
enLanguage.projects.forEach((project): void => { enLanguage.projects.forEach((project): void => {
@@ -495,10 +543,10 @@ describe('HomeComponent', (): void => {
expect(performance.now() - start).toBeLessThan(100); expect(performance.now() - start).toBeLessThan(100);
}); });
it('should deduplicate rapid schedule calls to a single requestAnimationFrame', async (): Promise<void> => { it('should batch all scroll effects into a single requestAnimationFrame', async (): Promise<void> => {
// The RAF registered during beforeEach's ngOnInit is still pending under fake timers. // 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. // Resetting the ID to null makes the schedule function treat the slot as free.
comp.scrollAnimationFrameId = null; comp.scrollEffectsAnimationFrameId = null;
let rafCallCount = 0; let rafCallCount = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
@@ -506,41 +554,9 @@ describe('HomeComponent', (): void => {
return rafCallCount; // returns non-null so subsequent calls deduplicate return rafCallCount; // returns non-null so subsequent calls deduplicate
}); });
comp.scheduleNameGradientUpdate(); comp.scheduleScrollEffectsUpdate();
comp.scheduleNameGradientUpdate(); comp.scheduleScrollEffectsUpdate();
comp.scheduleNameGradientUpdate(); comp.scheduleScrollEffectsUpdate();
expect(rafCallCount).toBe(1);
});
it('should deduplicate project-entry reveal scheduling the same way', async (): Promise<void> => {
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<void> => {
comp.aboutSectionAnimationFrameId = null;
let rafCallCount = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
rafCallCount++;
return rafCallCount;
});
comp.scheduleAboutSectionScrollAnimationUpdate();
comp.scheduleAboutSectionScrollAnimationUpdate();
comp.scheduleAboutSectionScrollAnimationUpdate();
expect(rafCallCount).toBe(1); expect(rafCallCount).toBe(1);
}); });
+106 -193
View File
@@ -1,142 +1,103 @@
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core'; import type { OnDestroy } from '@angular/core';
import { import {
afterNextRender,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject, inject,
NgZone,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { NgOptimizedImage } from '@angular/common'; import { filter, interval } from 'rxjs';
import { interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { global } from '../../global'; 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 { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service'; import { LanguageService } from '../services/language.service';
import { IconComponent } from '../shared/icon/icon.component'; import { IconComponent } from '../shared/icon/icon.component';
import { import {
arrowDownIcon, arrowDownIcon,
arrowRightIcon, arrowRightIcon,
discordIcon,
envelopeIcon, envelopeIcon,
githubIcon, giteaIcon,
linkedinIcon, linkedinIcon,
phoneIcon, phoneIcon,
signalIcon,
} from '../shared/icon/icons'; } from '../shared/icon/icons';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [FooterComponent, IconComponent, NgOptimizedImage], imports: [FooterComponent, IconComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, 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 destroyRef = inject(DestroyRef);
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content; protected readonly content = this.languageService.content;
private scrollAnimationFrameId: number | null = null; private scrollEffectsAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null; private readonly scheduleScrollEffectsUpdate: () => void = (): void => {
private aboutSectionAnimationFrameId: number | null = null; if (this.scrollEffectsAnimationFrameId !== null) return;
private readonly scheduleNameGradientUpdate: () => void = (): void => { this.scrollEffectsAnimationFrameId = window.requestAnimationFrame((): void => {
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return; this.scrollEffectsAnimationFrameId = null;
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
this.scrollAnimationFrameId = null;
this.updateNameGradientProgress(); 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.updateAboutSectionScrollProgress();
this.updateProjectEntryRevealProgress();
}); });
}; };
protected readonly global = global; protected readonly global = global;
protected readonly age = this.calculateAge(global.birthdate); protected readonly age = this.calculateAge(global.birthdate);
protected readonly contactMailHref = `mailto:${global.contactMail}`; protected readonly contactMailHref = `mailto:${global.contactMail}`;
protected readonly openPgpKey = openPgpKey;
protected readonly arrowRightIcon = arrowRightIcon; protected readonly arrowRightIcon = arrowRightIcon;
protected readonly arrowDownIcon = arrowDownIcon; protected readonly arrowDownIcon = arrowDownIcon;
protected readonly envelopeIcon = envelopeIcon; protected readonly envelopeIcon = envelopeIcon;
protected readonly phoneIcon = phoneIcon; protected readonly phoneIcon = phoneIcon;
protected readonly discordIcon = discordIcon; protected readonly signalIcon = signalIcon;
protected readonly githubIcon = githubIcon; protected readonly giteaIcon = giteaIcon;
protected readonly linkedinIcon = linkedinIcon; protected readonly linkedinIcon = linkedinIcon;
private readonly isLongBioShownState = signal(false); protected readonly isLongBioShown = signal(false);
private readonly isLongBioMountedState = signal(false); protected readonly isLongBioMounted = signal(false);
private readonly currentPortraitHighlightIndexState = signal(0); protected readonly currentPortraitHighlightIndex = signal(0);
private readonly isPortraitSwitchingState = signal(false); protected readonly isPortraitSwitching = signal(false);
private readonly currentIndexState = signal(0); protected readonly currentIndex = signal(0);
protected readonly isBioTickerPaused = signal(false);
private bioHideTimeoutId: number | null = null; private bioHideTimeoutId: number | null = null;
private portraitSwitchTimeoutId: number | null = null; private portraitSwitchTimeoutId: number | null = null;
private portraitSwitchDoneTimeoutId: number | null = null; private portraitSwitchDoneTimeoutId: number | null = null;
protected get isLongBioShown(): boolean { protected readonly portraitHighlights = computed(() => this.content().portraitHighlights);
return this.isLongBioShownState(); /** Splits off the last title word so the arrow stays glued to it and never wraps alone. */
} protected readonly projects = computed(() =>
protected set isLongBioShown(value: boolean) { this.content().projects.map((project) => {
this.isLongBioShownState.set(value); const tailStart = project.title.lastIndexOf(' ') + 1;
} return {
protected get isLongBioMounted(): boolean { ...project,
return this.isLongBioMountedState(); titleHead: project.title.slice(0, tailStart),
} titleTail: project.title.slice(tailStart),
protected set isLongBioMounted(value: boolean) { };
this.isLongBioMountedState.set(value); }),
} );
protected get currentPortraitHighlightIndex(): number { protected readonly currentPortraitHighlight = computed(
return this.currentPortraitHighlightIndexState(); () => this.portraitHighlights()[this.currentPortraitHighlightIndex()] ?? null,
} );
protected set currentPortraitHighlightIndex(value: number) { protected readonly currentBioText = computed(
this.currentPortraitHighlightIndexState.set(value); (): string => this.content().bioTextsList[this.currentIndex()] ?? '',
} );
protected get isPortraitSwitching(): boolean {
return this.isPortraitSwitchingState(); private readonly startBrowserEffects = afterNextRender((): void => {
} this.preloadPortraitHighlights();
protected set isPortraitSwitching(value: boolean) { if (!this.shouldEnableScrollEffects()) return;
this.isPortraitSwitchingState.set(value); this.startBioTicker();
} window.addEventListener('scroll', this.scheduleScrollEffectsUpdate, { passive: true });
protected get currentIndex(): number { window.addEventListener('resize', this.scheduleScrollEffectsUpdate, { passive: true });
return this.currentIndexState(); this.scheduleScrollEffectsUpdate();
} });
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 interpolate(text: string): string { protected interpolate(text: string): string {
return text return text
@@ -144,78 +105,48 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
.replaceAll('{{age}}', String(this.age)); .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 { ngOnDestroy(): void {
if (typeof window === 'undefined') return;
if (this.bioHideTimeoutId !== null) { if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId); window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null; this.bioHideTimeoutId = null;
} }
this.clearPortraitSwitchTimeouts(); this.clearPortraitSwitchTimeouts();
this.destroyNameGradientScrollAnimation(); this.destroyScrollEffects();
this.destroyProjectEntryRevealObserver();
this.destroyAboutSectionScrollAnimation();
} }
protected toggleBio(): void { protected toggleBio(): void {
if (this.isLongBioShown) { if (this.isLongBioShown()) {
this.isLongBioShown = false; this.isLongBioShown.set(false);
if (typeof window !== 'undefined') { if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId); this.bioHideTimeoutId = window.setTimeout((): void => {
this.bioHideTimeoutId = window.setTimeout((): void => { this.isLongBioMounted.set(false);
this.isLongBioMounted = false; this.bioHideTimeoutId = null;
this.bioHideTimeoutId = null; }, 300);
}, 300);
} else {
this.isLongBioMounted = false;
}
return; return;
} }
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') { if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId); window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null; this.bioHideTimeoutId = null;
} }
this.isLongBioMounted = true; this.isLongBioMounted.set(true);
if (typeof window !== 'undefined') { window.requestAnimationFrame((): void => {
window.requestAnimationFrame((): void => { window.requestAnimationFrame((): void => {
window.requestAnimationFrame((): void => { this.isLongBioShown.set(true);
this.isLongBioShown = true;
});
}); });
} else { });
this.isLongBioShown = true;
}
} }
protected showNextPortraitHighlight(): void { protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return; if (this.portraitHighlights().length <= 1 || this.isPortraitSwitching()) return;
this.isPortraitSwitching = true; this.isPortraitSwitching.set(true);
this.portraitSwitchTimeoutId = window.setTimeout((): void => { this.portraitSwitchTimeoutId = window.setTimeout((): void => {
this.currentPortraitHighlightIndex = this.currentPortraitHighlightIndex.update(
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length; (index: number): number => (index + 1) % this.portraitHighlights().length,
);
this.portraitSwitchTimeoutId = null; this.portraitSwitchTimeoutId = null;
}, 125); }, 125);
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => { this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
this.isPortraitSwitching = false; this.isPortraitSwitching.set(false);
this.portraitSwitchDoneTimeoutId = null; this.portraitSwitchDoneTimeoutId = null;
}, 250); }, 250);
} }
@@ -239,62 +170,47 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
return !prefersReducedMotion; return !prefersReducedMotion;
} }
private initNameGradientScrollAnimation(): void { /** Loads the other highlight images at low priority so a click swaps them without a visible delay. */
if (!this.shouldEnableScrollEffects()) return; private preloadPortraitHighlights(): void {
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true }); const current = this.currentPortraitHighlight();
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true }); for (const highlight of this.portraitHighlights()) {
this.scheduleNameGradientUpdate(); if (highlight === current) continue;
} const image = new Image();
private initProjectEntryRevealObserver(): void { image.fetchPriority = 'low';
if (!this.shouldEnableScrollEffects()) return; image.sizes = highlight.imageSizes;
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true }); image.srcset = highlight.imageSrcset;
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true }); image.src = highlight.image;
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;
} }
}
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<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => { document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
entry.style.removeProperty('--project-entry-progress'); 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 { private clearPortraitSwitchTimeouts(): void {
if (typeof window === 'undefined') return;
if (this.portraitSwitchTimeoutId !== null) { if (this.portraitSwitchTimeoutId !== null) {
window.clearTimeout(this.portraitSwitchTimeoutId); window.clearTimeout(this.portraitSwitchTimeoutId);
this.portraitSwitchTimeoutId = null; this.portraitSwitchTimeoutId = null;
@@ -305,7 +221,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
private updateNameGradientProgress(): void { private updateNameGradientProgress(): void {
if (typeof window === 'undefined') return;
const root: HTMLElement = document.documentElement; const root: HTMLElement = document.documentElement;
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow'); const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
const portfolioTriggerY: number = const portfolioTriggerY: number =
@@ -326,7 +241,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
root.style.setProperty('--name-pride-progress', progress.toFixed(4)); root.style.setProperty('--name-pride-progress', progress.toFixed(4));
} }
private updateAboutSectionScrollProgress(): void { private updateAboutSectionScrollProgress(): void {
if (typeof window === 'undefined') return;
const aboutSection: HTMLElement | null = document.getElementById('about'); const aboutSection: HTMLElement | null = document.getElementById('about');
if (aboutSection === null) return; if (aboutSection === null) return;
const sectionRect: DOMRect = aboutSection.getBoundingClientRect(); 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)); aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
} }
private updateProjectEntryRevealProgress(): void { private updateProjectEntryRevealProgress(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry'); const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
const viewportHeight: number = window.innerHeight || 1; const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.98; const startY: number = viewportHeight * 0.98;