signature: restore lean dots, reduced-motion redraw, above-fold reveal and map focus rings

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 17:49:57 +02:00
parent 7813499ee3
commit 6c1572bf3c
5 changed files with 130 additions and 8 deletions

View File

@@ -209,4 +209,73 @@ describe('DotBackground', () => {
expect(raf).toHaveBeenCalled(); 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();
});
}); });

View File

@@ -43,7 +43,10 @@ export class DotBackground {
private tornDown = false; private tornDown = false;
private readonly teardowns: Array<() => void> = []; 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 = 100;
private readonly MAX_DOT_COUNT_MOBILE = 40; private readonly MAX_DOT_COUNT_MOBILE = 40;
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6']; 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.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)); 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 { 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 area = Math.max(0, width) * Math.max(0, height);
const fromArea = Math.round(area / 20_000); const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR);
return Math.min(maxCount, Math.max(this.MIN_DOT_COUNT, fromArea)); return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea));
} }
private spawnDot(): Dot { private spawnDot(): Dot {

View File

@@ -124,10 +124,16 @@ describe('RevealDirective', () => {
const element = hostElement(fixture); const element = hostElement(fixture);
const observer = MockIntersectionObserver.instances[0]; 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).toBeTruthy();
expect(observer.observe).toHaveBeenCalled(); 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); observer.trigger(true);
fixture.detectChanges(); fixture.detectChanges();
@@ -137,6 +143,31 @@ describe('RevealDirective', () => {
expect(observer.disconnect).toHaveBeenCalled(); 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 () => { it('disconnects when destroyed before intersection', async () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);

View File

@@ -47,14 +47,16 @@ export class RevealDirective {
return; return;
} }
this.host.nativeElement.classList.add('reveal-pending');
this.observer = new view.IntersectionObserver( this.observer = new view.IntersectionObserver(
(entries) => { (entries) => {
if (entries.some((entry) => entry.isIntersecting)) { if (entries.some((entry) => entry.isIntersecting)) {
this.host.nativeElement.classList.remove('reveal-pending'); this.host.nativeElement.classList.remove('reveal-pending');
this.host.nativeElement.classList.add('is-revealed'); this.host.nativeElement.classList.add('is-revealed');
this.disconnect(); this.disconnect();
return;
} }
this.host.nativeElement.classList.add('reveal-pending');
}, },
{ {
rootMargin: '0px 0px -8% 0px', rootMargin: '0px 0px -8% 0px',

View File

@@ -62,7 +62,6 @@
.systems-map-node { .systems-map-node {
color: var(--color-text); color: var(--color-text);
outline: none;
} }
.systems-map-node circle, .systems-map-node circle,
@@ -126,6 +125,18 @@
text-decoration: none; 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 { .systems-map-card-label {
font-weight: 600; font-weight: 600;
} }