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
@@ -4,7 +4,7 @@
This repository is an Angular 21 SSR application. Main app code lives in `src/`,
with standalone components under `src/app/` such as `home/`, `footer/`, and
`imprint/`. Shared constants are kept in `src/global.fields.ts`. Component tests
`imprint/`. Shared constants are kept in `src/global.ts`. Component tests
sit next to their source files as `*.spec.ts`. Automation and project
maintenance scripts live in `scripts/`. Generated build output goes to `dist/`
and should not be edited manually.
@@ -42,7 +42,7 @@ tests for changed behavior.
## Commit & Pull Request Guidelines
Recent history mixes short imperative messages (`Update src/global.fields.ts`)
Recent history mixes short imperative messages (`Update src/global.ts`)
with lightweight conventional commits
(`feat: pre-render routes and restore scroll`). Prefer concise, imperative
commit subjects and include a scope or prefix when it adds clarity. PRs should
+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({
+26 -22
View File
@@ -28,12 +28,17 @@ export interface Global {
contactPhone: string;
hrefContactPhone: string;
birthdate: string;
bioTextsList: string[];
bioTextsList: BioTextEntry[];
portraitHighlights: PortraitHighlight[];
projects: PortfolioProject[];
address: ImprintData;
}
export interface BioTextEntry {
label: string;
value: string;
}
export const global: Global = {
firstname: 'Julian',
lastname: 'Lechner',
@@ -43,27 +48,26 @@ export const global: Global = {
hrefContactPhone: '+436609254001',
birthdate: '2009-03-03',
bioTextsList: [
'C#',
'.NET',
'.NET Framework',
'WPF',
'Avalonia',
'NodeJS',
'Angular',
'TypeScript',
'JavaScript',
'Java',
'HTML',
'(S)CSS',
'Github',
'Gitlab',
'Azure DevOps',
'Ubuntu',
'Debian',
'SSMS',
'MariaDB',
'MySQL',
'Grandle',
{ label: 'Languages', value: 'C#' },
{ label: 'Languages', value: 'TypeScript' },
{ label: 'Languages', value: 'Bash' },
{ label: 'Frontend', value: 'Angular' },
{ label: 'Frontend', value: 'WPF' },
{ label: 'Frontend', value: 'Avalonia' },
{ label: 'Backend', value: '.NET' },
{ label: 'Backend', value: 'ASP.NET Core' },
{ label: 'Backend', value: 'Entity Framework Core' },
{ label: 'Backend', value: 'Node.js' },
{ label: 'Backend', value: 'REST' },
{ label: 'Backend', value: 'Swagger' },
{ label: 'Database', value: 'Microsoft SQL' },
{ label: 'Database', value: 'MariaDB' },
{ label: 'DevOps', value: 'Git' },
{ label: 'DevOps', value: 'GitHub & GitLab' },
{ label: 'DevOps', value: 'Azure DevOps' },
{ label: 'DevOps', value: 'Docker' },
{ label: 'DevOps', value: 'Ubuntu' },
{ label: 'DevOps', value: 'Arch' },
],
portraitHighlights: [
{
+32 -7
View File
@@ -1,5 +1,5 @@
:root {
--page-max-width: 1160px;
--page-max-width: 1000px;
--content-max-width: 920px;
--surface: rgba(20, 18, 22, 0.62);
--surface-strong: rgba(15, 14, 18, 0.78);
@@ -24,6 +24,7 @@
--space-7: 4.5rem;
--transition-fast: 180ms ease;
--transition-medium: 320ms ease;
--name-pride-progress: 0;
}
* {
@@ -68,7 +69,7 @@ hr {
}
.page-shell {
width: min(calc(100% - 2rem), var(--page-max-width));
width: min(calc(100% - 6rem), var(--page-max-width));
margin: 0 auto;
padding: var(--space-6) 0 var(--space-7);
}
@@ -94,6 +95,11 @@ hr {
max-width: 44rem;
}
.section-heading-centered {
margin-inline: auto;
text-align: center;
}
.section-heading.compact {
max-width: 52rem;
}
@@ -111,7 +117,7 @@ hr {
grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr);
gap: var(--space-6);
align-items: start;
min-height: min(86svh, 780px);
min-height: var(--hero-section-min-height, auto);
padding: clamp(2rem, 4vw, 4rem);
}
@@ -122,8 +128,27 @@ hr {
}
.hero-name-part {
position: relative;
display: inline-block;
margin-right: 0.28em;
color: var(--text-primary);
}
.hero-name-part::after {
content: attr(data-name);
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
pointer-events: none;
color: transparent;
}
.hero-name-part::after {
--name-pride-reveal: clamp(0, var(--name-pride-progress), 1);
width: calc(var(--name-pride-reveal) * (100% + 0.14em));
overflow: hidden;
color: transparent;
background: linear-gradient(
to right,
#e40303 0%,
@@ -141,9 +166,10 @@ hr {
);
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: 0 50%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.hero-name-part:last-child {
@@ -623,7 +649,6 @@ hr {
min-width: 100px;
height: 100px;
object-fit: cover;
background: rgba(255, 255, 255, 0.06);
box-shadow: var(--shadow-soft);
}
@@ -728,7 +753,7 @@ hr {
}
.footer-inner {
width: min(calc(100% - 2rem), var(--page-max-width));
width: min(calc(100% - 6rem), var(--page-max-width));
margin: 0 auto;
padding-top: var(--space-4);
border-top: 1px solid var(--border-soft);
@@ -785,7 +810,7 @@ hr {
@media (max-width: 700px) {
.page-shell {
width: min(calc(100% - 2rem), var(--page-max-width));
width: min(calc(100% - 4rem), var(--page-max-width));
padding: var(--space-4) 0 var(--space-6);
}