signature: add systems map, command palette and motion primitives

Ship the Signature UX as standalone, SSR-safe pieces: bilingual copy, a dual SVG/list systems map, a Map-backed command palette in the shell, opt-in reveal and metric primitives, and a leak-free decorative canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 17:37:25 +02:00
parent 9ea2885c7b
commit 7813499ee3
25 changed files with 2902 additions and 73 deletions

View File

@@ -1,13 +1,18 @@
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
inject,
NgZone,
OnDestroy,
ViewChild,
} from '@angular/core';
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
import {
isBrowserPlatform,
prefersCoarsePointer,
prefersReducedMotion,
} from '../../core/platform/browser';
import { Dot } from '../../models/dot';
@Component({
@@ -15,21 +20,30 @@ import { Dot } from '../../models/dot';
imports: [],
templateUrl: './dot-background.html',
styleUrl: './dot-background.scss',
host: {
'aria-hidden': 'true',
},
})
export class DotBackground implements OnDestroy {
export class DotBackground {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private readonly document = inject(DOCUMENT);
private readonly ngZone = inject(NgZone);
private readonly coarsePointer = prefersCoarsePointer();
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isBrowserPlatform();
private readonly coarsePointer = prefersCoarsePointer();
private readonly reducedMotion = prefersReducedMotion();
private ctx: CanvasRenderingContext2D | undefined;
private dots: Dot[] = [];
private mouse = { x: -1000, y: -1000 };
private animationId = 0;
private initialized = false;
private resizeFrameId = 0;
private loopActive = false;
private tornDown = false;
private readonly teardowns: Array<() => void> = [];
private readonly INIT_DOT_COUNT = 12;
private readonly MIN_DOT_COUNT = 8;
private readonly MAX_DOT_COUNT = 100;
private readonly MAX_DOT_COUNT_MOBILE = 40;
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
@@ -39,27 +53,34 @@ export class DotBackground implements OnDestroy {
private ballSpawnNextColor = 0;
constructor() {
this.destroyRef.onDestroy(() => this.teardown());
afterNextRender(() => {
this.init();
});
}
ngOnDestroy() {
if (!this.initialized) {
private view(): Window | null {
return this.document.defaultView;
}
private init(): void {
if (this.tornDown || !this.isBrowser) {
return;
}
cancelAnimationFrame(this.animationId);
const view = this.view();
if (this.isBrowser) {
window.removeEventListener('resize', this.resize);
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('click', this.onMouseClick);
if (!view) {
return;
}
const canvas = this.canvasRef?.nativeElement;
if (!canvas) {
return;
}
}
private init() {
const canvas = this.canvasRef.nativeElement;
let ctx: CanvasRenderingContext2D | null = null;
try {
@@ -76,21 +97,113 @@ export class DotBackground implements OnDestroy {
this.resize();
this.initDots();
window.addEventListener('resize', this.resize);
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('click', this.onMouseClick);
this.listen(view, 'resize', this.onResize);
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
this.initialized = true;
this.ngZone.runOutsideAngular(() => this.animate());
if (!this.reducedMotion && !this.coarsePointer) {
this.listen(view, 'mousemove', this.onMouseMove);
this.listen(view, 'click', this.onMouseClick);
}
if (this.reducedMotion) {
this.drawFrame();
return;
}
this.ngZone.runOutsideAngular(() => this.startLoop());
}
private resize = () => {
const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
private listen(target: EventTarget, type: string, handler: EventListener): void {
target.addEventListener(type, handler);
this.teardowns.push(() => target.removeEventListener(type, handler));
}
const dx = Math.abs(width - canvas.width) / width;
const dy = Math.abs(height - canvas.height) / height;
private requestFrame(callback: FrameRequestCallback): number {
const view = this.view();
return view ? view.requestAnimationFrame(callback) : 0;
}
private cancelFrame(id: number): void {
const view = this.view();
if (!view || id === 0) {
return;
}
view.cancelAnimationFrame(id);
}
private startLoop(): void {
if (this.loopActive || this.reducedMotion || this.tornDown) {
return;
}
this.loopActive = true;
this.scheduleAnimate();
}
private stopLoop(): void {
this.loopActive = false;
this.cancelFrame(this.animationId);
this.animationId = 0;
}
private scheduleAnimate(): void {
this.animationId = this.requestFrame(this.animate);
}
private teardown(): void {
if (this.tornDown) {
return;
}
this.tornDown = true;
this.stopLoop();
this.cancelFrame(this.resizeFrameId);
this.resizeFrameId = 0;
for (const dispose of this.teardowns) {
dispose();
}
this.teardowns.length = 0;
}
private onResize = (): void => {
if (this.resizeFrameId !== 0) {
return;
}
this.resizeFrameId = this.requestFrame(() => {
this.resizeFrameId = 0;
this.resize();
});
};
private onVisibilityChange = (): void => {
if (this.document.hidden) {
this.stopLoop();
return;
}
if (!this.reducedMotion) {
this.ngZone.runOutsideAngular(() => this.startLoop());
}
};
private resize = (): void => {
const view = this.view();
const canvas = this.canvasRef?.nativeElement;
if (!view || !canvas) {
return;
}
const width = view.innerWidth;
const height = view.innerHeight;
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
canvas.width = width;
@@ -103,18 +216,35 @@ export class DotBackground implements OnDestroy {
}
};
private onMouseMove = (e: MouseEvent) => {
const canvas = this.canvasRef.nativeElement;
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
private onMouseMove = (event: Event): void => {
if (!(event instanceof MouseEvent)) {
return;
}
const view = this.view();
const canvas = this.canvasRef?.nativeElement;
if (!view || !canvas) {
return;
}
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
};
private onMouseClick = () => {
private onMouseClick = (): void => {
const dot = this.spawnDot();
dot.x = this.mouse.x;
dot.y = this.mouse.y;
};
private targetDotCount(width: number, height: number): number {
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
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));
}
private spawnDot(): Dot {
const dotId = this.ballSpawnId++;
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
@@ -140,7 +270,7 @@ export class DotBackground implements OnDestroy {
return dot;
}
private populateDot(dot: Dot) {
private populateDot(dot: Dot): void {
const { width, height } = this.canvasRef.nativeElement;
dot.x = Math.random() * width;
@@ -151,17 +281,29 @@ export class DotBackground implements OnDestroy {
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
}
private initDots() {
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
private initDots(): void {
const { width, height } = this.canvasRef.nativeElement;
const count = this.targetDotCount(width, height);
for (let i = 0; i < count; i++) {
this.spawnDot();
}
}
private animate = () => {
const canvas = this.canvasRef.nativeElement;
private animate = (): void => {
this.animationId = 0;
this.drawFrame();
if (this.loopActive && !this.tornDown) {
this.scheduleAnimate();
}
};
private drawFrame(): void {
const canvas = this.canvasRef?.nativeElement;
const ctx = this.ctx;
if (!ctx) {
if (!canvas || !ctx) {
return;
}
@@ -213,7 +355,5 @@ export class DotBackground implements OnDestroy {
ctx.fillStyle = gradient;
ctx.fill();
}
this.animationId = requestAnimationFrame(this.animate);
};
}
}