foundation: project guide, tooling, design tokens, bilingual shell and routing contracts

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 17:06:51 +02:00
parent 1020ac4c68
commit 42e9f01253
89 changed files with 3615 additions and 297 deletions

View File

@@ -2,7 +2,7 @@ canvas {
position: fixed;
inset: 0;
z-index: -1;
background: #0a0a0f;
background: var(--color-surface);
width: 100vw;
height: 100vh;

View File

@@ -7,16 +7,21 @@ describe('DotBackground', () => {
let fixture: ComponentFixture<DotBackground>;
beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
await TestBed.configureTestingModule({
imports: [DotBackground]
})
.compileComponents();
imports: [DotBackground],
}).compileComponents();
fixture = TestBed.createComponent(DotBackground);
component = fixture.componentInstance;
await fixture.whenStable();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create', () => {
expect(component).toBeTruthy();
});

View File

@@ -1,6 +1,14 @@
import {afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild} from '@angular/core';
import {Dot} from '../../models/dot';
import {DeviceDetectionService} from '../../service/device-detection-service';
import {
afterNextRender,
Component,
ElementRef,
inject,
NgZone,
OnDestroy,
ViewChild,
} from '@angular/core';
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
import { Dot } from '../../models/dot';
@Component({
selector: 'app-dot-background',
@@ -11,7 +19,10 @@ import {DeviceDetectionService} from '../../service/device-detection-service';
export class DotBackground implements OnDestroy {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private readonly ngZone = inject(NgZone);
private readonly coarsePointer = prefersCoarsePointer();
private ctx: CanvasRenderingContext2D | undefined;
private dots: Dot[] = [];
private mouse = { x: -1000, y: -1000 };
private animationId = 0;
@@ -26,23 +37,41 @@ export class DotBackground implements OnDestroy {
private ballSpawnId = 0;
private ballSpawnNextColor = 0;
constructor(private ngZone: NgZone, private mobileService: DeviceDetectionService) {
constructor() {
afterNextRender(() => {
this.init();
});
}
ngOnDestroy() {
if (!this.initialized) return;
if (!this.initialized) {
return;
}
cancelAnimationFrame(this.animationId);
window.removeEventListener('resize', this.resize);
window.removeEventListener('mousemove', this.onMouseMove);
if (isBrowserPlatform()) {
window.removeEventListener('resize', this.resize);
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('click', this.onMouseClick);
}
}
private init() {
const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!;
let ctx: CanvasRenderingContext2D | null = null;
try {
ctx = canvas.getContext('2d');
} catch {
return;
}
if (!ctx) {
return;
}
this.ctx = ctx;
this.resize();
this.initDots();
@@ -62,8 +91,7 @@ export class DotBackground implements OnDestroy {
const dx = Math.abs(width - canvas.width) / width;
const dy = Math.abs(height - canvas.height) / height;
if (!this.mobileService.mobileCheck() || dy > 0.2 || dx > 0.05) {
//sync canvas size to screen size
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
canvas.width = width;
canvas.height = height;
@@ -74,13 +102,10 @@ export class DotBackground implements OnDestroy {
}
};
private onMouseMove = (e: MouseEvent) => {
const canvas = this.canvasRef.nativeElement;
// map real res to canvas res
this.mouse.x = e.clientX / window.innerWidth * canvas.width;
this.mouse.y = e.clientY / window.innerHeight * canvas.height;
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
};
private onMouseClick = () => {
@@ -91,16 +116,17 @@ export class DotBackground implements OnDestroy {
private spawnDot(): Dot {
const dotId = this.ballSpawnId++;
const max_count = this.mobileService.mobileCheck() ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
let dot;
if (dotId < max_count) {
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
let dot: Dot;
if (dotId < maxCount) {
dot = {
x: 0,
y: 0,
vx: 0,
vy: 0,
radius: 1,
color: "#000000",
color: '#000000',
};
this.dots.push(dot);
@@ -114,7 +140,7 @@ export class DotBackground implements OnDestroy {
}
private populateDot(dot: Dot) {
const {width, height} = this.canvasRef.nativeElement;
const { width, height } = this.canvasRef.nativeElement;
dot.x = Math.random() * width;
dot.y = Math.random() * height;
@@ -132,8 +158,14 @@ export class DotBackground implements OnDestroy {
private animate = () => {
const canvas = this.canvasRef.nativeElement;
const ctx = this.ctx;
if (!ctx) {
return;
}
const { width, height } = canvas;
this.ctx.clearRect(0, 0, width, height);
ctx.clearRect(0, 0, width, height);
for (const dot of this.dots) {
const dx = dot.x - this.mouse.x;
@@ -161,25 +193,24 @@ export class DotBackground implements OnDestroy {
dot.vy = Math.random() * 0.2 - 0.1;
}
// Bounce off edges (accounting for radius)
if (dot.x < dot.radius || dot.x > width - dot.radius) dot.vx *= -1;
if (dot.y < dot.radius || dot.y > height - dot.radius) dot.vy *= -1;
if (dot.x < dot.radius || dot.x > width - dot.radius) {
dot.vx *= -1;
}
if (dot.y < dot.radius || dot.y > height - dot.radius) {
dot.vy *= -1;
}
// Clamp to bounds
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));
const gradient = this.ctx.createRadialGradient(
dot.x, dot.y, 0,
dot.x, dot.y, dot.radius
);
const gradient = ctx.createRadialGradient(dot.x, dot.y, 0, dot.x, dot.y, dot.radius);
gradient.addColorStop(0, dot.color + '40');
gradient.addColorStop(1, 'transparent');
this.ctx.beginPath();
this.ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
this.ctx.fillStyle = gradient;
this.ctx.fill();
ctx.beginPath();
ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
}
this.animationId = requestAnimationFrame(this.animate);