diff --git a/src/app/components/dot-background/dot-background.spec.ts b/src/app/components/dot-background/dot-background.spec.ts index a3e3729..d429c94 100644 --- a/src/app/components/dot-background/dot-background.spec.ts +++ b/src/app/components/dot-background/dot-background.spec.ts @@ -209,4 +209,73 @@ describe('DotBackground', () => { expect(raf).toHaveBeenCalled(); }); + + it('starts with a bounded initial dot count on a large desktop viewport', async () => { + const context = mockContext(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + const widthStub = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(2560); + const heightStub = vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1440); + const scheduled: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + scheduled.push(callback); + return scheduled.length; + }); + + try { + const fixture = await createFixture(); + const pending = scheduled.splice(0); + for (const callback of pending) { + callback(0); + } + + const arcCalls = vi.mocked(context.arc).mock.calls.length; + expect(arcCalls).toBeGreaterThanOrEqual(6); + expect(arcCalls).toBeLessThanOrEqual(24); + fixture.destroy(); + } finally { + widthStub.mockRestore(); + heightStub.mockRestore(); + } + }); + + it('redraws the static frame after resize under reduced motion without starting a loop', async () => { + mockMatchMedia((query) => query.includes('prefers-reduced-motion')); + const context = mockContext(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context); + const scheduled: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + scheduled.push(callback); + return scheduled.length; + }); + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [DotBackground], + }).compileComponents(); + + const fixture = TestBed.createComponent(DotBackground); + fixture.detectChanges(); + await fixture.whenStable(); + TestBed.inject(ApplicationRef).tick(); + + const pending = scheduled.splice(0); + for (const callback of pending) { + callback(0); + } + + vi.mocked(context.clearRect).mockClear(); + vi.mocked(context.arc).mockClear(); + + window.dispatchEvent(new Event('resize')); + + const resizeFrames = scheduled.splice(0); + for (const callback of resizeFrames) { + callback(0); + } + + expect(context.clearRect).toHaveBeenCalled(); + expect(context.arc).toHaveBeenCalled(); + expect(scheduled).toEqual([]); + fixture.destroy(); + }); }); diff --git a/src/app/components/dot-background/dot-background.ts b/src/app/components/dot-background/dot-background.ts index 291ff8a..53b154e 100644 --- a/src/app/components/dot-background/dot-background.ts +++ b/src/app/components/dot-background/dot-background.ts @@ -43,7 +43,10 @@ export class DotBackground { private tornDown = false; private readonly teardowns: Array<() => void> = []; - private readonly MIN_DOT_COUNT = 8; + private readonly INITIAL_DOT_COUNT_MIN = 6; + private readonly INITIAL_DOT_COUNT_MAX = 24; + private readonly INITIAL_DOT_COUNT_MAX_COARSE = 12; + private readonly INITIAL_DOT_AREA_DIVISOR = 130_000; private readonly MAX_DOT_COUNT = 100; private readonly MAX_DOT_COUNT_MOBILE = 40; private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6']; @@ -213,6 +216,10 @@ export class DotBackground { dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x)); dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y)); } + + if (this.reducedMotion && this.ctx) { + this.drawFrame(); + } } }; @@ -239,10 +246,12 @@ export class DotBackground { }; private targetDotCount(width: number, height: number): number { - const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT; + const maxInitial = this.coarsePointer + ? this.INITIAL_DOT_COUNT_MAX_COARSE + : this.INITIAL_DOT_COUNT_MAX; const area = Math.max(0, width) * Math.max(0, height); - const fromArea = Math.round(area / 20_000); - return Math.min(maxCount, Math.max(this.MIN_DOT_COUNT, fromArea)); + const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR); + return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea)); } private spawnDot(): Dot { diff --git a/src/app/shared/motion/reveal.directive.spec.ts b/src/app/shared/motion/reveal.directive.spec.ts index b455495..ad87f71 100644 --- a/src/app/shared/motion/reveal.directive.spec.ts +++ b/src/app/shared/motion/reveal.directive.spec.ts @@ -124,10 +124,16 @@ describe('RevealDirective', () => { const element = hostElement(fixture); const observer = MockIntersectionObserver.instances[0]; - expect(element.classList.contains('reveal-pending')).toBe(true); - expect(element.classList.contains('is-revealed')).toBe(false); expect(observer).toBeTruthy(); expect(observer.observe).toHaveBeenCalled(); + expect(element.classList.contains('reveal-pending')).toBe(false); + expect(element.classList.contains('is-revealed')).toBe(false); + + observer.trigger(false); + fixture.detectChanges(); + + expect(element.classList.contains('reveal-pending')).toBe(true); + expect(element.classList.contains('is-revealed')).toBe(false); observer.trigger(true); fixture.detectChanges(); @@ -137,6 +143,31 @@ describe('RevealDirective', () => { expect(observer.disconnect).toHaveBeenCalled(); }); + it('reveals on a first intersecting callback without ever adding reveal-pending', async () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + + const fixture = await createHost(); + const element = hostElement(fixture); + const observer = MockIntersectionObserver.instances[0]; + const addedTokens: string[] = []; + const add = element.classList.add.bind(element.classList); + vi.spyOn(element.classList, 'add').mockImplementation((...tokens: string[]) => { + addedTokens.push(...tokens); + add(...tokens); + }); + + expect(observer).toBeTruthy(); + expect(element.classList.contains('reveal-pending')).toBe(false); + + observer.trigger(true); + fixture.detectChanges(); + + expect(addedTokens).not.toContain('reveal-pending'); + expect(element.classList.contains('reveal-pending')).toBe(false); + expect(element.classList.contains('is-revealed')).toBe(true); + expect(observer.disconnect).toHaveBeenCalled(); + }); + it('disconnects when destroyed before intersection', async () => { vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); diff --git a/src/app/shared/motion/reveal.directive.ts b/src/app/shared/motion/reveal.directive.ts index 1628671..574e1d6 100644 --- a/src/app/shared/motion/reveal.directive.ts +++ b/src/app/shared/motion/reveal.directive.ts @@ -47,14 +47,16 @@ export class RevealDirective { return; } - this.host.nativeElement.classList.add('reveal-pending'); this.observer = new view.IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { this.host.nativeElement.classList.remove('reveal-pending'); this.host.nativeElement.classList.add('is-revealed'); this.disconnect(); + return; } + + this.host.nativeElement.classList.add('reveal-pending'); }, { rootMargin: '0px 0px -8% 0px', diff --git a/src/app/shared/systems-map/systems-map.scss b/src/app/shared/systems-map/systems-map.scss index f210913..8eaa1a0 100644 --- a/src/app/shared/systems-map/systems-map.scss +++ b/src/app/shared/systems-map/systems-map.scss @@ -62,7 +62,6 @@ .systems-map-node { color: var(--color-text); - outline: none; } .systems-map-node circle, @@ -126,6 +125,18 @@ text-decoration: none; } +.systems-map-node:focus-visible, +.systems-map-cards a:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +.systems-map-node:focus-visible circle, +.systems-map-node:focus-visible rect { + stroke: var(--focus-ring-color); + stroke-width: 3; +} + .systems-map-card-label { font-weight: 600; }