diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7a96d2c --- /dev/null +++ b/AGENTS.md @@ -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`. diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 99fb9cc..5ce8fc5 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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 { diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index bd46931..a735c8e 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -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(() => + 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, }); diff --git a/src/app/imprint/imprint.component.ts b/src/app/imprint/imprint.component.ts index 70b6f8b..b9a9795 100644 --- a/src/app/imprint/imprint.component.ts +++ b/src/app/imprint/imprint.component.ts @@ -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(() => + this.languageService.languageCode() === 'de' ? deLanguage : enLanguage, + ); protected readonly global = global; protected readonly faArrowLeft: IconDefinition = faArrowLeft; protected readonly contactMailHref = `mailto:${global.contactMail}`; diff --git a/src/app/services/kofi-widget.service.ts b/src/app/services/kofi-widget.service.ts index 3ff2292..a98a8c1 100644 --- a/src/app/services/kofi-widget.service.ts +++ b/src/app/services/kofi-widget.service.ts @@ -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 { @@ -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('[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( + '.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('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, + ); + } } diff --git a/src/app/services/language.service.ts b/src/app/services/language.service.ts index c12e670..7e38b4d 100644 --- a/src/app/services/language.service.ts +++ b/src/app/services/language.service.ts @@ -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(() => this.getPack(this.currentLanguageCode())); + readonly content = computed(() => + 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; } } diff --git a/src/languages/language.types.ts b/src/languages/language.types.ts index 058a710..65a4c39 100644 --- a/src/languages/language.types.ts +++ b/src/languages/language.types.ts @@ -93,3 +93,5 @@ export interface LanguagePack { portraitHighlights: LanguagePortraitHighlight[]; projects: LanguageProject[]; } + +export type LanguageShellPack = Pick; diff --git a/src/styles.scss b/src/styles.scss index a8e2249..c1fcc0f 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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;