feat: style

This commit is contained in:
2026-04-20 21:57:57 +02:00
parent 7bbcdfa1b9
commit 0888795c09
7 changed files with 257 additions and 46 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import type { Global } from '../../global.fields';
import { global } from '../../global.fields';
import type { Global } from '../../global';
import { global } from '../../global';
@Component({
selector: 'app-footer',
+7 -9
View File
@@ -2,10 +2,10 @@
<section class="hero-section panel">
<div class="hero-copy">
<div class="hero-copy-main">
<p class="eyebrow">Portfolio</p>
<p id="portfolio-eyebrow" class="eyebrow">Portfolio</p>
<h1>
<span class="hero-name-part">{{ global.firstname }}</span>
<span class="hero-name-part">{{ global.lastname }}</span>
<span class="hero-name-part" [attr.data-name]="global.firstname">{{ global.firstname }}</span>
<span class="hero-name-part" [attr.data-name]="global.lastname">{{ global.lastname }}</span>
</h1>
<p class="hero-intro">
Full-stack development, thoughtful interfaces, and tailored digital products for companies, associations, and private clients.
@@ -43,8 +43,8 @@
<div class="hero-meta">
<div>
<span class="meta-label">my stack</span>
<strong>{{ global.bioTextsList[currentIndex] }}</strong>
<span class="meta-label">{{ currentBioEntry.label }}</span>
<strong>{{ currentBioEntry.value }}</strong>
</div>
</div>
</div>
@@ -81,7 +81,6 @@
<section id="about" class="story-section panel">
<div class="section-heading">
<p class="eyebrow">About me</p>
<h2>Direct communication, solid implementation, and software that stays understandable.</h2>
</div>
<div class="story-grid">
@@ -120,9 +119,8 @@
</section>
<section id="projects" class="projects-section">
<div class="section-heading">
<p class="eyebrow">Selected Projects</p>
<h2>A compact look at products, platforms, and systems I have helped shape.</h2>
<div class="section-heading section-heading-centered">
<p class="eyebrow">Projects</p>
</div>
<div class="project-list">
+186 -2
View File
@@ -9,8 +9,8 @@ import type { SafeUrl } from '@angular/platform-browser';
import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
import type { Global, PortraitHighlight, PortfolioProject } from '../../global.fields';
import { global } from '../../global.fields';
import type { BioTextEntry, Global, PortraitHighlight, PortfolioProject } from '../../global';
import { global } from '../../global';
import { FooterComponent } from '../footer/footer.component';
@Component({
@@ -23,6 +23,31 @@ export class HomeComponent implements OnInit, OnDestroy {
private readonly sanitizer = inject(DomSanitizer);
private readonly zone = inject(NgZone);
private preloadedImages: HTMLImageElement[] = [];
private tallestPortraitAspectRatio: number | null = null;
private lockedHeroSectionMinHeight: number = 0;
private scrollAnimationFrameId: number | null = null;
private heroHeightAnimationFrameId: number | null = null;
private readonly scheduleNameGradientUpdate: () => void = (): void => {
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
this.scrollAnimationFrameId = null;
this.updateNameGradientProgress();
});
};
private readonly scheduleHeroSectionHeightUpdate: () => void = (): void => {
if (this.heroHeightAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
this.heroHeightAnimationFrameId = window.requestAnimationFrame((): void => {
this.heroHeightAnimationFrameId = null;
this.lockedHeroSectionMinHeight = 0;
this.updateHeroSectionMinHeight();
});
};
protected readonly global: Global = global;
protected readonly age: number | string = this.calculateAge(global.birthdate);
@@ -43,6 +68,7 @@ export class HomeComponent implements OnInit, OnDestroy {
protected currentIndex = 0;
protected readonly projects: PortfolioProject[] = global.projects;
protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' };
private sub!: Subscription;
constructor() {
@@ -51,6 +77,7 @@ export class HomeComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.preloadImageAssets();
this.initNameGradientScrollAnimation();
this.zone.runOutsideAngular((): void => {
this.sub = interval(1000).subscribe((): void => {
@@ -63,6 +90,7 @@ export class HomeComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.sub.unsubscribe();
this.destroyNameGradientScrollAnimation();
}
protected toggleBio(): void {
@@ -73,6 +101,10 @@ export class HomeComponent implements OnInit, OnDestroy {
return this.portraitHighlights[this.currentPortraitHighlightIndex] ?? null;
}
protected get currentBioEntry(): BioTextEntry {
return this.global.bioTextsList[this.currentIndex] ?? this.fallbackBioEntry;
}
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) {
return;
@@ -113,6 +145,158 @@ export class HomeComponent implements OnInit, OnDestroy {
image.src = url;
return image;
});
this.syncHeroHeightToLargestPortraitImage();
}
private syncHeroHeightToLargestPortraitImage(): void {
if (typeof window === 'undefined') {
return;
}
const portraitImageUrls: Set<string> = new Set<string>(
this.portraitHighlights.map((highlight: PortraitHighlight): string => new URL(highlight.image, window.location.href).href),
);
const portraitImages: HTMLImageElement[] = this.preloadedImages.filter((image: HTMLImageElement): boolean =>
portraitImageUrls.has(image.src),
);
if (portraitImages.length === 0) {
return;
}
const finalizeAspectRatio = (): void => {
const minAspectRatio: number = portraitImages.reduce((minRatio: number, image: HTMLImageElement): number => {
if (image.naturalWidth === 0 || image.naturalHeight === 0) {
return minRatio;
}
const currentRatio: number = image.naturalWidth / image.naturalHeight;
return minRatio === 0 ? currentRatio : Math.min(minRatio, currentRatio);
}, 0);
if (minAspectRatio > 0) {
this.tallestPortraitAspectRatio = minAspectRatio;
this.scheduleHeroSectionHeightUpdate();
}
};
let pendingLoads: number = portraitImages.length;
const onImageSettled = (): void => {
pendingLoads -= 1;
if (pendingLoads === 0) {
finalizeAspectRatio();
}
};
for (const image of portraitImages) {
if (image.complete) {
onImageSettled();
continue;
}
image.addEventListener('load', onImageSettled, { once: true });
image.addEventListener('error', onImageSettled, { once: true });
}
}
private initNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
window.addEventListener('resize', this.scheduleHeroSectionHeightUpdate, { passive: true });
this.scheduleNameGradientUpdate();
this.scheduleHeroSectionHeightUpdate();
}
private destroyNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
window.removeEventListener('scroll', this.scheduleNameGradientUpdate);
window.removeEventListener('resize', this.scheduleNameGradientUpdate);
window.removeEventListener('resize', this.scheduleHeroSectionHeightUpdate);
if (this.scrollAnimationFrameId !== null) {
window.cancelAnimationFrame(this.scrollAnimationFrameId);
this.scrollAnimationFrameId = null;
}
if (this.heroHeightAnimationFrameId !== null) {
window.cancelAnimationFrame(this.heroHeightAnimationFrameId);
this.heroHeightAnimationFrameId = null;
}
document.documentElement.style.removeProperty('--name-pride-progress');
document.documentElement.style.removeProperty('--hero-section-min-height');
this.lockedHeroSectionMinHeight = 0;
}
private updateNameGradientProgress(): void {
if (typeof window === 'undefined') {
return;
}
const root: HTMLElement = document.documentElement;
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
const portfolioTriggerY: number =
portfolioEyebrow === null
? Number.POSITIVE_INFINITY
: portfolioEyebrow.getBoundingClientRect().top + window.scrollY;
const triggerDistance: number = Math.max(portfolioTriggerY, 1);
const rawProgress: number = Math.min(window.scrollY / triggerDistance, 1);
const acceleratedProgress: number = Math.pow(rawProgress, 0.66);
const progress: number =
window.scrollY > 0 ? Math.max(acceleratedProgress, 0.05) : acceleratedProgress;
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
}
private updateHeroSectionMinHeight(): void {
if (typeof window === 'undefined' || this.tallestPortraitAspectRatio === null) {
return;
}
const heroSection: HTMLElement | null = document.querySelector('.hero-section');
const heroCopy: HTMLElement | null = document.querySelector('.hero-copy');
const portraitHighlight: HTMLElement | null = document.querySelector('.portrait-highlight');
const portraitButton: HTMLElement | null = document.querySelector('.portrait-highlight-button');
if (
heroSection === null ||
heroCopy === null ||
portraitHighlight === null ||
portraitButton === null
) {
return;
}
const buttonWidth: number = portraitButton.getBoundingClientRect().width;
if (buttonWidth <= 0) {
return;
}
const maxMediaHeight: number = buttonWidth / this.tallestPortraitAspectRatio;
const currentButtonHeight: number = portraitButton.getBoundingClientRect().height;
const currentHighlightHeight: number = portraitHighlight.getBoundingClientRect().height;
const staticHighlightOverhead: number = Math.max(currentHighlightHeight - currentButtonHeight, 0);
const requiredHighlightHeight: number = maxMediaHeight + staticHighlightOverhead;
const requiredContentHeight: number = Math.max(heroCopy.getBoundingClientRect().height, requiredHighlightHeight);
const heroSectionStyles: CSSStyleDeclaration = window.getComputedStyle(heroSection);
const paddingTop: number = Number.parseFloat(heroSectionStyles.paddingTop) || 0;
const paddingBottom: number = Number.parseFloat(heroSectionStyles.paddingBottom) || 0;
const requiredSectionMinHeight: number = requiredContentHeight + paddingTop + paddingBottom;
this.lockedHeroSectionMinHeight = Math.max(this.lockedHeroSectionMinHeight, requiredSectionMinHeight);
document.documentElement.style.setProperty(
'--hero-section-min-height',
`${Math.ceil(this.lockedHeroSectionMinHeight)}px`,
);
}
private calculateAge(birthDateString: string): number | string {
+2 -2
View File
@@ -7,8 +7,8 @@ import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import type { Global } from '../../global.fields';
import { global } from '../../global.fields';
import type { Global } from '../../global';
import { global } from '../../global';
import { FooterComponent } from '../footer/footer.component';
@Component({