diff --git a/projects/v3/src/app/app.component.html b/projects/v3/src/app/app.component.html index e715b846d..f4a254cdf 100644 --- a/projects/v3/src/app/app.component.html +++ b/projects/v3/src/app/app.component.html @@ -1,4 +1,5 @@ + diff --git a/projects/v3/src/app/app.component.ts b/projects/v3/src/app/app.component.ts index e0ca7dd45..edc45a32c 100644 --- a/projects/v3/src/app/app.component.ts +++ b/projects/v3/src/app/app.component.ts @@ -29,7 +29,10 @@ export class AppComponent implements OnInit, OnDestroy { $unsubscribe = new Subject(); lastVisitedUrl: string; + isSnowEnabled = environment.snowAnimation?.enabled ?? false; + // urls that should not be cached for last visited tracking + // list of urls that should not be cached noneCachedUrl = [ 'devtool', 'registration', @@ -80,7 +83,7 @@ export class AppComponent implements OnInit, OnDestroy { ngOnInit() { this.configVerification(); this.sharedService.onPageLoad(); - + // Set initial lang attribute based on current locale (WCAG 3.1.1) this.utils.setPageLanguage(); diff --git a/projects/v3/src/app/app.module.ts b/projects/v3/src/app/app.module.ts index cf1e55c9e..a27e5b97d 100644 --- a/projects/v3/src/app/app.module.ts +++ b/projects/v3/src/app/app.module.ts @@ -11,6 +11,7 @@ import { AppComponent } from './app.component'; import { ApolloModule } from 'apollo-angular'; import { ApolloService } from './services/apollo.service'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SnowOverlayComponent } from './components/snow-overlay/snow-overlay.component'; @NgModule({ declarations: [ @@ -28,6 +29,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; prefixUrl: environment.APIEndpoint, }), ApolloModule, + SnowOverlayComponent, ], providers: [ { diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.html b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.html new file mode 100644 index 000000000..49e89157b --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.html @@ -0,0 +1,13 @@ + diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.scss b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.scss new file mode 100644 index 000000000..88142a1e5 --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.scss @@ -0,0 +1,49 @@ +/** + * snow overlay styles + * creates a transparent layer of falling snowflakes + */ + +.snow-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; // don't block user interactions + z-index: 9000; // below ionic modals (10000+) + overflow: hidden; +} + +.snowflake { + position: absolute; + top: -10px; + left: var(--left); + width: var(--size); + height: var(--size); + background: white; + border-radius: 50%; + opacity: var(--opacity); + box-shadow: + 0 0 2px rgba(0, 0, 0, 0.15), + 0 1px 3px rgba(0, 0, 0, 0.1); + animation: snowfall var(--duration) linear infinite; + animation-delay: var(--delay); + will-change: transform; // gpu acceleration hint +} + +@keyframes snowfall { + 0% { + transform: translateY(-10vh); + } + 100% { + transform: translateY(110vh); + } +} + +// wcag: respect user's motion preferences +@media (prefers-reduced-motion: reduce) { + .snowflake { + animation: none; + opacity: 0.3; + } +} diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.spec.ts b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.spec.ts new file mode 100644 index 000000000..4d14b7096 --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SnowOverlayComponent } from './snow-overlay.component'; + +describe('SnowOverlayComponent', () => { + let component: SnowOverlayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SnowOverlayComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SnowOverlayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should generate snowflakes on init', () => { + expect(component.snowflakes.length).toBeGreaterThan(0); + }); + + it('should have snowflakes with valid properties', () => { + const flake = component.snowflakes[0]; + expect(flake.id).toBeDefined(); + expect(flake.size).toBeGreaterThanOrEqual(4); + expect(flake.size).toBeLessThanOrEqual(10); + expect(flake.left).toBeGreaterThanOrEqual(0); + expect(flake.left).toBeLessThanOrEqual(100); + expect(flake.delay).toBeGreaterThanOrEqual(0); + expect(flake.delay).toBeLessThanOrEqual(10); + expect(flake.duration).toBeGreaterThanOrEqual(8); + expect(flake.duration).toBeLessThanOrEqual(15); + expect(flake.opacity).toBeGreaterThanOrEqual(0.4); + expect(flake.opacity).toBeLessThanOrEqual(1); + }); + + it('should have aria-hidden on overlay container', () => { + const overlay = fixture.nativeElement.querySelector('.snow-overlay'); + expect(overlay.getAttribute('aria-hidden')).toBe('true'); + }); + + it('should have pointer-events none for non-blocking interaction', () => { + const overlay = fixture.nativeElement.querySelector('.snow-overlay'); + const styles = getComputedStyle(overlay); + expect(styles.pointerEvents).toBe('none'); + }); + + it('should render correct number of snowflake elements', () => { + const snowflakeElements = fixture.nativeElement.querySelectorAll('.snowflake'); + expect(snowflakeElements.length).toBe(component.snowflakes.length); + }); + + it('trackByFlakeId should return flake id', () => { + const flake = { id: 5 }; + expect(component.trackByFlakeId(0, flake)).toBe(5); + }); +}); diff --git a/projects/v3/src/app/components/snow-overlay/snow-overlay.component.ts b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.ts new file mode 100644 index 000000000..9260e785b --- /dev/null +++ b/projects/v3/src/app/components/snow-overlay/snow-overlay.component.ts @@ -0,0 +1,63 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { environment } from '@v3/environments/environment'; + +/** + * snow overlay component that renders animated snowflakes + * as a transparent layer over the app content. + * uses pointer-events: none to allow interaction with underlying elements. + */ +@Component({ + selector: 'app-snow-overlay', + standalone: true, + imports: [CommonModule], + templateUrl: './snow-overlay.component.html', + styleUrls: ['./snow-overlay.component.scss'], +}) +export class SnowOverlayComponent implements OnInit { + snowflakes: Array<{ + id: number; + size: number; + left: number; + delay: number; + duration: number; + opacity: number; + }> = []; + + ngOnInit(): void { + this.generateSnowflakes(); + } + + /** + * generates snowflake configurations with randomized properties + * for natural variation in the animation. + */ + private generateSnowflakes(): void { + const count = environment.snowAnimation?.snowflakeCount ?? 30; + + for (let i = 0; i < count; i++) { + this.snowflakes.push({ + id: i, + size: this.randomBetween(4, 10), + left: this.randomBetween(0, 100), + delay: this.randomBetween(0, 10), + duration: this.randomBetween(8, 15), + opacity: this.randomBetween(0.4, 1), + }); + } + } + + /** + * returns a random number between min and max (inclusive). + */ + private randomBetween(min: number, max: number): number { + return Math.random() * (max - min) + min; + } + + /** + * trackby function for ngfor performance optimization. + */ + trackByFlakeId(index: number, flake: { id: number }): number { + return flake.id; + } +} diff --git a/projects/v3/src/environments/environment.custom.ts b/projects/v3/src/environments/environment.custom.ts index f62a43158..084e58180 100644 --- a/projects/v3/src/environments/environment.custom.ts +++ b/projects/v3/src/environments/environment.custom.ts @@ -62,4 +62,8 @@ export const environment = { featureToggles: { assessmentPagination: , }, + snowAnimation: { + enabled: true, + snowflakeCount: 30, + }, }; diff --git a/projects/v3/src/environments/environment.local.ts b/projects/v3/src/environments/environment.local.ts index 740bc3801..9cee4bbcd 100644 --- a/projects/v3/src/environments/environment.local.ts +++ b/projects/v3/src/environments/environment.local.ts @@ -66,6 +66,10 @@ export const environment = { featureToggles: { assessmentPagination: true, }, + snowAnimation: { + enabled: true, + snowflakeCount: 30, + }, }; /*