fix: performance

This commit is contained in:
2026-04-28 21:44:20 +02:00
parent 07000e539c
commit 1e2b1efc66
8 changed files with 388 additions and 24 deletions
+61
View File
@@ -0,0 +1,61 @@
# Repository Guidelines
## Project Structure & Module Organization
`src/` contains the Angular application, SSR entry points, and global styles.
Feature code lives under `src/app/` with standalone components such as `home/`,
`imprint/`, `footer/`, and shared services in `src/app/services/`. Language
packs and shared language types are in `src/languages/`. Source images belong in
`src/assets/unoptimized/`; generated web assets are written to
`src/assets/optimized/`. Utility and generation scripts live in `scripts/`. CI
workflows and issue templates are under `.github/`.
## Build, Test, and Development Commands
Use npm scripts only.
- `npm run dev` starts the local Angular dev server after generating assets.
- `npm run test` runs the Angular/Karma test suite.
- `npm run check` runs the repository validation pipeline.
- `npm run fix` applies automatic fixes for version placeholders, ESLint issues,
text cleanup, and formatting.
- `npm run lint` runs base and type-aware ESLint checks.
- `npm run format:check` verifies Prettier formatting.
For validation, use `npm run check` and `npm run test`. Do not run
`npm run build` directly.
## Coding Style & Naming Conventions
Use TypeScript with strict typing. Prefer `type`, `interface`, and `enum`
deliberately; do not use `any` or `unknown` unless unavoidable and justified.
`var` is forbidden. Only mark values nullable when `null` is a real runtime
state. Always declare the correct access modifier (`private`, `protected`,
`public`) explicitly when it improves clarity.
Formatting is enforced with Prettier and ESLint. Follow existing conventions:
2-space indentation, single quotes, semicolons, readonly where appropriate, and
Angular standalone components with files named
`feature.component.ts|html|spec.ts`.
## Testing Guidelines
Tests use Jasmine with Karma and live beside implementation files as
`*.spec.ts`. Add or update tests when changing component behavior, services,
routing, or asset-generation logic. Prefer focused unit tests over broad
integration-style setup. Run `npm run test` locally before opening a PR.
## Commit & Pull Request Guidelines
Recent history favors short conventional-style subjects such as
`fix: performance`, `fix: text`, and `feat: added languages`. Keep commits
scoped and imperative. PRs should include a concise summary, linked issue when
relevant, validation notes (`npm run check`, `npm run test`), and screenshots
for UI changes, especially mobile or performance-related updates.
## Asset & Content Notes
Do not edit generated optimized assets by hand. Update the source files in
`src/assets/unoptimized/` and regenerate through the provided scripts. Keep
localized text changes synchronized between `src/languages/de.ts` and
`src/languages/en.ts`.
+3 -1
View File
@@ -37,7 +37,7 @@ export class AppComponent implements OnInit, OnDestroy {
{ code: 'de', accent: 'DE' },
];
protected readonly dialogContent = computed(() =>
this.languageService.getPack(this.selectedLanguage()),
this.languageService.getShellPack(this.selectedLanguage()),
);
protected readonly languageSwitcherLabel = computed(() =>
this.getLanguageLabel(this.currentLanguageCode(), this.content()),
@@ -66,6 +66,7 @@ export class AppComponent implements OnInit, OnDestroy {
protected confirmLanguage(): void {
this.languageService.confirmLanguage(this.selectedLanguage());
this.isLanguageSelectorOpen.set(false);
this.kofiWidgetService.prioritizeLoad();
}
protected reopenLanguageSelector(): void {
@@ -80,6 +81,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(false);
this.kofiWidgetService.prioritizeLoad();
}
protected closeLanguageSelectorOnBackdrop(event: MouseEvent): void {
+23 -5
View File
@@ -1,5 +1,5 @@
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, NgZone } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, NgZone } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons';
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
@@ -8,9 +8,12 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import { global } from '../../global';
import type {
LanguageBioTextEntry,
LanguagePack,
LanguagePortraitHighlight,
LanguageProject,
} from '../../languages/language.types';
@@ -27,7 +30,9 @@ import { LanguageService } from '../services/language.service';
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly content = computed<LanguagePack>(() =>
this.languageService.languageCode() === 'de' ? deLanguage : enLanguage,
);
private scrollAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
@@ -174,14 +179,27 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
protected scrollToContactLinks(): void {
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
}
private shouldEnableScrollEffects(): boolean {
if (typeof window === 'undefined') return false;
const isSmallViewport: boolean = window.innerWidth <= 700;
const prefersReducedMotion: boolean = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
const isCoarsePointer: boolean = window.matchMedia('(pointer: coarse)').matches;
return !isSmallViewport && !prefersReducedMotion && !isCoarsePointer;
}
private initNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') return;
if (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
this.scheduleNameGradientUpdate();
}
private initProjectEntryRevealObserver(): void {
if (typeof window === 'undefined') return;
if (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
this.scheduleProjectEntryRevealUpdate();
@@ -199,7 +217,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
});
}
private initAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined') return;
if (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
+7 -2
View File
@@ -1,10 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import { global } from '../../global';
import type { LanguagePack } from '../../languages/language.types';
import { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service';
@@ -18,7 +21,9 @@ import { LanguageService } from '../services/language.service';
export class ImprintComponent {
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly content = computed<LanguagePack>(() =>
this.languageService.languageCode() === 'de' ? deLanguage : enLanguage,
);
protected readonly global = global;
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
protected readonly contactMailHref = `mailto:${global.contactMail}`;
+142 -10
View File
@@ -8,11 +8,14 @@ export class KofiWidgetService {
private readonly widgetScriptUrl = 'https://storage.ko-fi.com/cdn/scripts/overlay-widget.js';
private readonly widgetIframeTitle = 'Ko-fi support chat';
private readonly fallbackDelayMs = 10000;
private readonly fallbackDelayMs = 2500;
private readonly mobileBreakpointPx = 700;
private readonly mobileViewportOffsetPx = 24;
private loadTimeoutId: number | null = null;
private loadIdleCallbackId: number | null = null;
private loadListenerAbortController: AbortController | null = null;
private resizeListenerAbortController: AbortController | null = null;
private iframeObserver: MutationObserver | null = null;
private isWidgetScheduled = false;
private isWidgetLoaded = false;
@@ -85,10 +88,50 @@ export class KofiWidgetService {
}
}
teardown(): void {
prioritizeLoad(): void {
if (!isPlatformBrowser(this.platformId) || this.isWidgetLoaded || this.isWidgetDrawn) {
return;
}
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
this.clearStartListeners();
this.clearPendingLoadTimers();
const loadWidget = (): void => {
this.loadIdleCallbackId = null;
this.loadTimeoutId = null;
void this.loadWidget();
};
if (typeof windowRef.requestIdleCallback === 'function') {
this.loadIdleCallbackId = windowRef.requestIdleCallback(loadWidget, { timeout: 1200 });
return;
}
this.loadTimeoutId = windowRef.setTimeout(loadWidget, 200);
}
teardown(): void {
this.clearStartListeners();
this.resizeListenerAbortController?.abort();
this.resizeListenerAbortController = null;
this.clearPendingLoadTimers();
this.iframeObserver?.disconnect();
this.iframeObserver = null;
}
private clearStartListeners(): void {
this.loadListenerAbortController?.abort();
this.loadListenerAbortController = null;
}
private clearPendingLoadTimers(): void {
const windowRef = this.document.defaultView;
if (windowRef !== null && this.loadTimeoutId !== null) {
windowRef.clearTimeout(this.loadTimeoutId);
@@ -103,14 +146,6 @@ export class KofiWidgetService {
windowRef.cancelIdleCallback(this.loadIdleCallbackId);
this.loadIdleCallbackId = null;
}
this.iframeObserver?.disconnect();
this.iframeObserver = null;
}
private clearStartListeners(): void {
this.loadListenerAbortController?.abort();
this.loadListenerAbortController = null;
}
private async loadWidget(): Promise<void> {
@@ -190,7 +225,9 @@ export class KofiWidgetService {
this.isWidgetDrawn = true;
this.ensureAccessibleIframeTitles();
this.applyResponsiveWidgetLayout();
this.observeInjectedIframes();
this.observeViewportChanges();
}
private ensureAccessibleIframeTitles(): void {
@@ -201,6 +238,75 @@ export class KofiWidgetService {
});
}
private applyResponsiveWidgetLayout(): void {
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
const isMobileViewport: boolean = windowRef.innerWidth <= this.mobileBreakpointPx;
const mobileWidthPx: number = Math.max(windowRef.innerWidth - this.mobileViewportOffsetPx, 280);
const mobileHeightPx: number = Math.max(
Math.min(Math.round(windowRef.innerHeight * 0.68), 520),
360,
);
this.document
.querySelectorAll<HTMLElement>('[id*="kofi-widget-overlay"]')
.forEach((overlay: HTMLElement): void => {
if (isMobileViewport) {
overlay.style.left = '12px';
overlay.style.right = '12px';
overlay.style.bottom = '12px';
overlay.style.width = 'auto';
overlay.style.maxWidth = `${mobileWidthPx}px`;
} else {
overlay.style.removeProperty('left');
overlay.style.removeProperty('right');
overlay.style.removeProperty('width');
overlay.style.removeProperty('max-width');
}
});
this.document
.querySelectorAll<HTMLElement>(
'.floatingchat-container-wrap, .floatingchat-container-wrap-mobi',
)
.forEach((container: HTMLElement): void => {
if (isMobileViewport) {
container.style.width = `${mobileWidthPx}px`;
container.style.maxWidth = 'calc(100vw - 24px)';
container.style.left = '0';
container.style.right = '0';
} else {
container.style.removeProperty('width');
container.style.removeProperty('max-width');
container.style.removeProperty('left');
container.style.removeProperty('right');
}
});
this.document
.querySelectorAll<HTMLIFrameElement>('iframe[id^="kofi-wo-container"]')
.forEach((iframe: HTMLIFrameElement): void => {
iframe.title = this.widgetIframeTitle;
if (isMobileViewport) {
iframe.style.width = `${mobileWidthPx}px`;
iframe.style.maxWidth = 'calc(100vw - 24px)';
iframe.style.height = `${mobileHeightPx}px`;
iframe.style.maxHeight = '68vh';
iframe.style.borderRadius = '18px';
} else {
iframe.style.removeProperty('width');
iframe.style.removeProperty('max-width');
iframe.style.removeProperty('height');
iframe.style.removeProperty('max-height');
iframe.style.removeProperty('border-radius');
}
});
}
private observeInjectedIframes(): void {
if (this.iframeObserver !== null) {
return;
@@ -208,6 +314,7 @@ export class KofiWidgetService {
this.iframeObserver = new MutationObserver((): void => {
this.ensureAccessibleIframeTitles();
this.applyResponsiveWidgetLayout();
});
this.iframeObserver.observe(this.document.body, {
@@ -215,4 +322,29 @@ export class KofiWidgetService {
subtree: true,
});
}
private observeViewportChanges(): void {
if (this.resizeListenerAbortController !== null) {
return;
}
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
this.resizeListenerAbortController = new AbortController();
const listenerOptions: AddEventListenerOptions = {
passive: true,
signal: this.resizeListenerAbortController.signal,
};
windowRef.addEventListener(
'resize',
(): void => {
this.applyResponsiveWidgetLayout();
},
listenerOptions,
);
}
}
+36 -6
View File
@@ -1,8 +1,36 @@
import { Injectable, computed, signal } from '@angular/core';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import type { LanguageCode, LanguagePack } from '../../languages/language.types';
import type { LanguageCode, LanguageShellPack } from '../../languages/language.types';
const enShellLanguage: LanguageShellPack = {
app: {
selectorTitle: 'Select language',
changeLanguage: 'Language',
changeLanguageAriaLabel: 'Change language',
closeSelector: 'Close language selection',
languageEnglish: 'English',
languageGerman: 'German',
},
footer: {
noscriptMessage: 'Please enable JavaScript for the full experience.',
imprintLink: 'Imprint',
},
};
const deShellLanguage: LanguageShellPack = {
app: {
selectorTitle: 'Sprache wählen',
changeLanguage: 'Sprache',
changeLanguageAriaLabel: 'Sprache wechseln',
closeSelector: 'Sprachauswahl schliessen',
languageEnglish: 'Englisch',
languageGerman: 'Deutsch',
},
footer: {
noscriptMessage: 'Bitte aktiviere JavaScript für die volle Darstellung.',
imprintLink: 'Impressum',
},
};
@Injectable({ providedIn: 'root' })
export class LanguageService {
@@ -12,7 +40,9 @@ export class LanguageService {
readonly languageCode = this.currentLanguageCode.asReadonly();
readonly isLanguageConfirmed = this.languageConfirmed.asReadonly();
readonly content = computed<LanguagePack>(() => this.getPack(this.currentLanguageCode()));
readonly content = computed<LanguageShellPack>(() =>
this.getShellPack(this.currentLanguageCode()),
);
initializeFromStorage(): LanguageCode {
if (typeof window === 'undefined') {
@@ -37,7 +67,7 @@ export class LanguageService {
}
}
getPack(code: LanguageCode): LanguagePack {
return code === 'de' ? deLanguage : enLanguage;
getShellPack(code: LanguageCode): LanguageShellPack {
return code === 'de' ? deShellLanguage : enShellLanguage;
}
}
+2
View File
@@ -93,3 +93,5 @@ export interface LanguagePack {
portraitHighlights: LanguagePortraitHighlight[];
projects: LanguageProject[];
}
export type LanguageShellPack = Pick<LanguagePack, 'app' | 'footer'>;
+114
View File
@@ -34,6 +34,8 @@
html {
scroll-behavior: smooth;
overflow-x: clip;
background-color: #08080b;
overscroll-behavior-x: none;
}
html.no-js {
@@ -60,11 +62,13 @@ body {
font-family: 'Outfit', Arial, sans-serif;
color: var(--text-primary);
overflow-x: clip;
background-color: #08080b;
background:
radial-gradient(circle at top left, rgba(217, 162, 140, 0.22), transparent 26%),
radial-gradient(circle at bottom right, rgba(127, 78, 63, 0.28), transparent 28%),
linear-gradient(180deg, rgba(9, 9, 12, 0.4), rgba(8, 8, 11, 0.82)),
url('assets/optimized/background.webp') center / cover fixed no-repeat;
-webkit-overflow-scrolling: touch;
}
a {
@@ -859,6 +863,8 @@ hr {
.site-footer {
padding: 0 0 var(--space-6);
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
.footer-shell {
@@ -932,6 +938,18 @@ hr {
}
@media (max-width: 700px) {
html {
scroll-behavior: auto;
}
body {
background:
radial-gradient(circle at top left, rgba(217, 162, 140, 0.18), transparent 28%),
radial-gradient(circle at bottom right, rgba(127, 78, 63, 0.24), transparent 32%),
linear-gradient(180deg, rgba(9, 9, 12, 0.72), rgba(8, 8, 11, 0.96)),
url('assets/optimized/background.webp') center top / cover no-repeat;
}
.page-shell,
.footer-inner {
width: min(calc(100% - 2rem), var(--page-max-width));
@@ -1069,6 +1087,102 @@ hr {
box-shadow: none;
justify-items: center;
}
.panel,
.language-overlay,
.language-switcher {
backdrop-filter: none;
}
.panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.025)), rgba(12, 12, 16, 0.88);
}
.project-entry,
.about-scroll-anim-enabled .story-copy,
.about-scroll-anim-enabled .story-detail {
opacity: 1;
transform: none;
will-change: auto;
}
.project-entry {
transition:
border-color var(--transition-fast),
background var(--transition-fast);
}
.projects-section,
.story-section,
.legal-section {
content-visibility: auto;
contain-intrinsic-size: 1px 720px;
}
}
@media (hover: none) and (pointer: coarse) {
.action-link:hover,
.detail-toggle:hover,
.back-link:hover,
.contact-area a:hover,
.project-entry.project-entry-from-right:hover,
.project-entry.project-entry-from-left:hover,
.language-close:hover,
.language-switcher:hover,
.portrait-highlight-button:hover {
transform: none;
box-shadow: none;
background: inherit;
border-color: inherit;
}
.action-link-pride:hover::before {
animation: none;
background: linear-gradient(
to right,
#e40303 0%,
#e40303 16.66%,
#ff8c00 16.66%,
#ff8c00 33.33%,
#ffed00 33.33%,
#ffed00 50%,
#008026 50%,
#008026 66.66%,
#004dff 66.66%,
#004dff 83.33%,
#750787 83.33%,
#750787 100%
)
0 50%;
background-size: 100% 100%;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.portrait-media-switching,
.action-link-pride:hover::before {
animation: none;
}
.project-entry,
.about-scroll-anim-enabled .story-copy,
.about-scroll-anim-enabled .story-detail,
.detail-panel,
.action-link,
.detail-toggle,
.back-link,
.contact-area a,
.language-close,
.language-switcher,
.portrait-highlight-button {
transition: none;
transform: none;
will-change: auto;
}
}
.language-overlay {
position: fixed;