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);

View File

@@ -1,10 +1,11 @@
<div class="card">
<div class="card glass-surface">
<h3>{{ title }}</h3>
<div class="icons">
@for (skill of skills; track skill.icon) {
<a
[href]="skill.url"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="skill.name"
>
<img

View File

@@ -1,43 +1,27 @@
// skill-card.component.scss
.card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
padding: 1.5rem;
border-radius: var(--radius-lg);
padding: var(--space-6);
height: 100%;
display: flex;
flex-direction: column;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
:host-context(.desktop) &:hover {
transform: translateY(-4px);
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
}
transition:
transform var(--duration-base) var(--ease-standard),
box-shadow var(--duration-base) var(--ease-standard),
border-color var(--duration-base) var(--ease-standard);
h3 {
margin: 0 0 1rem;
font-size: 0.875rem;
margin: 0 0 var(--space-4);
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.6);
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
}
.icons {
display: flex;
flex-wrap: wrap;
gap: 1rem;
gap: var(--space-4);
align-items: center;
flex: 1;
@@ -50,13 +34,23 @@
height: 40px;
object-fit: contain;
filter: grayscale(100%) brightness(0.8);
transition: filter 0.2s ease, transform 0.2s ease;
}
:host-context(.desktop) &:hover img {
filter: none;
transform: scale(1.1);
transition:
filter var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
}
}
}
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-raised);
border-color: var(--surface-glass-border-strong);
}
.card .icons a:hover img {
filter: none;
transform: scale(1.1);
}
}

View File

@@ -8,9 +8,8 @@ describe('SkillCard', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SkillCard]
})
.compileComponents();
imports: [SkillCard],
}).compileComponents();
fixture = TestBed.createComponent(SkillCard);
component = fixture.componentInstance;

View File

@@ -1,5 +1,5 @@
import {Component, Input} from '@angular/core';
import {Skill} from '../../../../models/skill';
import { Component, Input } from '@angular/core';
import { Skill } from '../../../../models/skill';
@Component({
selector: 'app-skill-card',

View File

@@ -2,5 +2,5 @@
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 10px;
gap: var(--space-3);
}

View File

@@ -8,9 +8,8 @@ describe('SkillsGrid', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SkillsGrid]
})
.compileComponents();
imports: [SkillsGrid],
}).compileComponents();
fixture = TestBed.createComponent(SkillsGrid);
component = fixture.componentInstance;

View File

@@ -1,12 +1,10 @@
import {Component} from '@angular/core';
import {SkillCategory} from '../../../../models/skill-category';
import {SkillCard} from '../skill-card/skill-card';
import { Component } from '@angular/core';
import { SkillCategory } from '../../../../models/skill-category';
import { SkillCard } from '../skill-card/skill-card';
@Component({
selector: 'app-skills-grid',
imports: [
SkillCard
],
imports: [SkillCard],
templateUrl: './skills-grid.html',
styleUrl: './skills-grid.scss',
})
@@ -17,15 +15,19 @@ export class SkillsGrid {
category: 'programming',
gridArea: 'prog',
skills: [
{name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/'},
{name: 'JavaScript', icon: 'javascript', url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript'},
{name: 'Angular', icon: 'angular', url: 'https://angular.dev/'},
{name: 'Java', icon: 'java', url: 'https://www.java.com/'},
{name: 'Spring', icon: 'java-spring', url: 'https://spring.io/'},
{name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/'},
{name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/'},
{name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/'},
{name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/'},
{ name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/' },
{
name: 'JavaScript',
icon: 'javascript',
url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript',
},
{ name: 'Angular', icon: 'angular', url: 'https://angular.dev/' },
{ name: 'Java', icon: 'java', url: 'https://www.java.com/' },
{ name: 'Spring', icon: 'java-spring', url: 'https://spring.io/' },
{ name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/' },
{ name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/' },
{ name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/' },
{ name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/' },
],
},
{
@@ -33,10 +35,14 @@ export class SkillsGrid {
category: 'db',
gridArea: 'db',
skills: [
{name: 'SQL Server', icon: 'microsoftsqlserver', url: 'https://www.microsoft.com/en-us/sql-server'},
{name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/'},
{name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/'},
{name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/'},
{
name: 'SQL Server',
icon: 'microsoftsqlserver',
url: 'https://www.microsoft.com/en-us/sql-server',
},
{ name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/' },
{ name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/' },
{ name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/' },
],
},
{
@@ -44,13 +50,17 @@ export class SkillsGrid {
category: 'devops',
gridArea: 'devops',
skills: [
{name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/'},
{name: 'Docker', icon: 'docker', url: 'https://www.docker.com/'},
{name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/'},
{name: 'Azure DevOps', icon: 'devops', url: 'https://azure.microsoft.com/en-us/products/devops'},
{name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/'},
{name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/'},
{name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/'},
{ name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/' },
{ name: 'Docker', icon: 'docker', url: 'https://www.docker.com/' },
{ name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/' },
{
name: 'Azure DevOps',
icon: 'devops',
url: 'https://azure.microsoft.com/en-us/products/devops',
},
{ name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/' },
{ name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/' },
{ name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/' },
],
},
{
@@ -58,10 +68,10 @@ export class SkillsGrid {
category: 'os',
gridArea: 'os',
skills: [
{name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/'},
{name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/'},
{name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/'},
{name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows'},
{ name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/' },
{ name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/' },
{ name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/' },
{ name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows' },
],
},
{
@@ -69,8 +79,8 @@ export class SkillsGrid {
category: 'iac',
gridArea: 'iac',
skills: [
{name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/'},
{name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/'},
{ name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/' },
{ name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/' },
],
},
{
@@ -78,8 +88,12 @@ export class SkillsGrid {
category: 'hyperviser',
gridArea: 'hyper',
skills: [
{name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/'},
{name: 'Hyper-V', icon: 'hyperv', url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/'},
{ name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/' },
{
name: 'Hyper-V',
icon: 'hyperv',
url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/',
},
],
},
{
@@ -87,9 +101,13 @@ export class SkillsGrid {
category: 'tools',
gridArea: 'tools',
skills: [
{name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/'},
{name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/'},
{name: 'Microsoft Office', icon: 'office', url: 'https://www.microsoft.com/microsoft-365'},
{ name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/' },
{ name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/' },
{
name: 'Microsoft Office',
icon: 'office',
url: 'https://www.microsoft.com/microsoft-365',
},
],
},
];

View File

@@ -1,5 +1,7 @@
<div class="main">
<h1>Antonio Ledebuhr</h1>
<p>Software Engineering & DevOps</p>
@if (page(); as copy) {
<h1>{{ copy.title }}</h1>
<p>{{ copy.description }}</p>
}
<app-skills-grid></app-skills-grid>
</div>

View File

@@ -4,17 +4,19 @@
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
font-family: system-ui, sans-serif;
color: var(--color-text);
font-family: var(--font-sans);
padding: var(--space-8) var(--content-gutter);
}
h1 {
font-size: 3rem;
font-size: var(--text-3xl);
font-weight: 600;
line-height: var(--leading-tight);
margin: 0;
}
p {
color: rgba(255, 255, 255, 0.5);
margin: 0.5rem 0 3rem;
color: var(--color-text-muted);
margin: var(--space-2) 0 var(--space-9);
}

View File

@@ -1,5 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PLACEHOLDER_CONTENT } from '../../../core/content/placeholder-content';
import { SITE_CONTENT } from '../../../core/content/content.token';
import { SITE_CONFIG } from '../../../core/content/site-config';
import { Skills } from './skills';
describe('Skills', () => {
@@ -8,9 +10,9 @@ describe('Skills', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Skills]
})
.compileComponents();
imports: [Skills],
providers: [{ provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
}).compileComponents();
fixture = TestBed.createComponent(Skills);
component = fixture.componentInstance;
@@ -20,4 +22,10 @@ describe('Skills', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('renders the localized stack title instead of the person name', () => {
const heading = (fixture.nativeElement as HTMLElement).querySelector('h1');
expect(heading?.textContent?.trim()).toBe('Stack');
expect(heading?.textContent).not.toContain(SITE_CONFIG.personName);
});
});

View File

@@ -1,14 +1,14 @@
import { Component } from '@angular/core';
import {SkillsGrid} from './skills-grid/skills-grid';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../../core/content/content.service';
import { SkillsGrid } from './skills-grid/skills-grid';
@Component({
selector: 'app-skills',
imports: [
SkillsGrid
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkillsGrid],
templateUrl: './skills.html',
styleUrl: './skills.scss',
})
export class Skills {
protected readonly page = inject(ContentService).page('stack');
}