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

@@ -47,6 +47,7 @@
</ul> </ul>
</nav> </nav>
<div class="site-actions cluster"> <div class="site-actions cluster">
<app-command-palette></app-command-palette>
<a <a
class="language-switch" class="language-switch"
[routerLink]="navigation.alternateLocaleLink()" [routerLink]="navigation.alternateLocaleLink()"

View File

@@ -29,6 +29,10 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.site-actions app-command-palette {
flex: 0 0 auto;
}
.site-identity, .site-identity,
.site-nav a, .site-nav a,
.site-actions a, .site-actions a,

View File

@@ -6,11 +6,12 @@ import { SITE_CONFIG } from './core/content/site-config';
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale'; import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
import { LocaleService } from './core/i18n/locale.service'; import { LocaleService } from './core/i18n/locale.service';
import { NavigationService } from './core/navigation/navigation.service'; import { NavigationService } from './core/navigation/navigation.service';
import { CommandPalette } from './shared/command-palette/command-palette';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground], imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, CommandPalette],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
}) })

View File

@@ -1,53 +1,212 @@
import { ApplicationRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DotBackground } from './dot-background'; import { DotBackground } from './dot-background';
describe('DotBackground', () => { function mockContext(): CanvasRenderingContext2D {
let component: DotBackground;
let fixture: ComponentFixture<DotBackground>;
beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
fixture = TestBed.createComponent(DotBackground);
component = fixture.componentInstance;
await fixture.whenStable();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('does not throw when destroyed after browser initialization', async () => {
const gradient = { addColorStop: vi.fn() }; const gradient = { addColorStop: vi.fn() };
const context = {
return {
clearRect: vi.fn(), clearRect: vi.fn(),
beginPath: vi.fn(), beginPath: vi.fn(),
arc: vi.fn(), arc: vi.fn(),
fill: vi.fn(), fill: vi.fn(),
createRadialGradient: vi.fn(() => gradient), createRadialGradient: vi.fn(() => gradient),
}; } as unknown as CanvasRenderingContext2D;
}
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
context as unknown as CanvasRenderingContext2D, Object.defineProperty(window, 'matchMedia', {
); configurable: true,
const animationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0); writable: true,
value: (query: string): MediaQueryList =>
({
matches: matchesQuery(query),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
const initializedFixture = TestBed.createComponent(DotBackground); describe('DotBackground', () => {
initializedFixture.detectChanges(); afterEach(() => {
await initializedFixture.whenStable();
expect(() => initializedFixture.destroy()).not.toThrow();
animationFrameSpy.mockRestore();
vi.restoreAllMocks(); vi.restoreAllMocks();
Reflect.deleteProperty(window, 'matchMedia');
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
});
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
return fixture;
}
it('should create', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const fixture = await createFixture();
expect(fixture.componentInstance).toBeTruthy();
});
it('does not throw when destroyed after browser initialization', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
const fixture = await createFixture();
expect(() => fixture.destroy()).not.toThrow();
});
it('adds no listener and schedules no animation frame when the 2D context is null', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
const windowAdd = vi.spyOn(window, 'addEventListener');
const documentAdd = vi.spyOn(document, 'addEventListener');
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);
}
expect(scheduled).toEqual([]);
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
expect(documentAdd.mock.calls.some((call) => call[0] === 'visibilitychange')).toBe(false);
expect(() => fixture.destroy()).not.toThrow();
});
it('cancels the scheduled frame and removes every listener on destroy', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(17);
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
const windowAdd = vi.spyOn(window, 'addEventListener');
const windowRemove = vi.spyOn(window, 'removeEventListener');
const documentAdd = vi.spyOn(document, 'addEventListener');
const documentRemove = vi.spyOn(document, 'removeEventListener');
const fixture = await createFixture();
expect(raf).toHaveBeenCalled();
const windowAdds = windowAdd.mock.calls.filter((call) =>
['resize', 'mousemove', 'click'].includes(String(call[0])),
);
const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange');
expect(windowAdds.length).toBeGreaterThan(0);
expect(documentAdds.length).toBeGreaterThan(0);
fixture.destroy();
expect(cancel).toHaveBeenCalled();
for (const [type, handler] of windowAdds) {
expect(windowRemove).toHaveBeenCalledWith(type, handler);
}
for (const [type, handler] of documentAdds) {
expect(documentRemove).toHaveBeenCalledWith(type, handler);
}
});
it('draws a single static frame under reduced motion and skips pointer listeners', 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;
});
const windowAdd = vi.spyOn(window, 'addEventListener');
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);
}
expect(context.clearRect).toHaveBeenCalled();
expect(scheduled).toEqual([]);
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
fixture.destroy();
});
it('registers no pointer listeners for a coarse pointer', async () => {
mockMatchMedia((query) => query.includes('pointer: coarse'));
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
const windowAdd = vi.spyOn(window, 'addEventListener');
await createFixture();
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(true);
});
it('pauses the loop when the document is hidden and resumes when it is visible', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(21);
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
await createFixture();
expect(raf).toHaveBeenCalled();
raf.mockClear();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => true,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(cancel).toHaveBeenCalled();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(raf).toHaveBeenCalled();
}); });
}); });

View File

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

View File

@@ -0,0 +1,60 @@
import { APP_LOCALES } from '../i18n/locale';
import { COMMAND_IDS } from '../../shared/command-palette/commands';
import { SIGNATURE_COPY } from './signature-copy';
function leafPaths(value: unknown, prefix = ''): string[] {
if (typeof value === 'string') {
return [prefix];
}
if (value !== null && typeof value === 'object') {
return Object.keys(value).flatMap((key) => {
const next = prefix.length > 0 ? `${prefix}.${key}` : key;
return leafPaths((value as Record<string, unknown>)[key], next);
});
}
return [prefix];
}
function collectStrings(value: unknown): string[] {
if (typeof value === 'string') {
return [value];
}
if (value !== null && typeof value === 'object') {
return Object.values(value).flatMap((entry) => collectStrings(entry));
}
return [];
}
describe('SIGNATURE_COPY', () => {
it('exposes the same key structure in both locales', () => {
const [first, ...rest] = APP_LOCALES.map((locale) => leafPaths(SIGNATURE_COPY[locale]));
for (const keys of rest) {
expect(keys).toEqual(first);
}
});
it('keeps every string non-empty', () => {
for (const locale of APP_LOCALES) {
for (const value of collectStrings(SIGNATURE_COPY[locale])) {
expect(value.trim().length).toBeGreaterThan(0);
}
}
});
it('describes every CommandId in both locales', () => {
for (const locale of APP_LOCALES) {
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
for (const id of COMMAND_IDS) {
expect(descriptions[id].trim().length).toBeGreaterThan(0);
}
}
});
});

View File

@@ -0,0 +1,150 @@
import { type AppLocale } from '../i18n/locale';
import { type CommandId } from '../../shared/command-palette/commands';
export interface SignatureCopy {
readonly palette: {
readonly triggerLabel: string;
readonly shortcutHint: string;
readonly dialogTitle: string;
readonly dialogDescription: string;
readonly inputLabel: string;
readonly inputPlaceholder: string;
readonly closeLabel: string;
readonly suggestionsLabel: string;
readonly outputLabel: string;
readonly emptySuggestions: string;
readonly unknownCommand: string;
readonly helpIntro: string;
readonly clearedMessage: string;
readonly cvOpened: string;
readonly navigating: string;
readonly commandDescriptions: Record<CommandId, string>;
readonly responses: {
readonly brew: string;
readonly ignite: string;
readonly rev: string;
};
};
readonly systemsMap: {
readonly heading: string;
readonly intro: string;
readonly diagramDescription: string;
readonly listHeading: string;
readonly legendHeading: string;
readonly relationshipLabel: string;
readonly legend: {
readonly ai: string;
readonly cluster: string;
readonly hardware: string;
readonly software: string;
readonly project: string;
};
};
}
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
de: {
palette: {
triggerLabel: 'Befehle öffnen',
shortcutHint: 'Strg+K',
dialogTitle: 'Befehle',
dialogDescription:
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
inputLabel: 'Befehl',
inputPlaceholder: 'Befehl eingeben',
closeLabel: 'Schließen',
suggestionsLabel: 'Vorschläge',
outputLabel: 'Ausgabe',
emptySuggestions: 'Keine passenden Befehle.',
unknownCommand: 'Unbekannter Befehl: {command}',
helpIntro: 'Verfügbare Befehle:',
clearedMessage: 'Ausgabe geleert.',
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
navigating: 'Wechsel zu {target}.',
commandDescriptions: {
help: 'Listet die verfügbaren Befehle.',
projects: 'Öffnet die Projektübersicht.',
servicesAi: 'Öffnet die Seite zur KI-Integration.',
cv: 'Öffnet den Lebenslauf als PDF.',
contact: 'Öffnet die Kontaktseite.',
brew: 'Eine kurze, spielerische Rückmeldung.',
ignite: 'Eine kurze, spielerische Rückmeldung.',
rev: 'Eine kurze, spielerische Rückmeldung.',
clear: 'Leert die Ausgabe.',
close: 'Schließt die Befehlsübersicht.',
},
responses: {
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
ignite: 'Zündung frei. Die Systeme laufen warm.',
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
},
},
systemsMap: {
heading: 'Systemkarte',
intro:
'Wie die technischen Schichten zusammenhängen — von Hardware bis zu den Projektfeldern.',
diagramDescription: 'Diagramm der technischen Schichten und ihrer Verbindungen.',
listHeading: 'Knoten als Liste',
legendHeading: 'Legende',
relationshipLabel: 'Verbunden mit {targets}.',
legend: {
ai: 'KI-Integration',
cluster: 'Cluster',
hardware: 'Hardware und Netz',
software: 'Software',
project: 'Projektfeld',
},
},
},
en: {
palette: {
triggerLabel: 'Open commands',
shortcutHint: 'Ctrl+K',
dialogTitle: 'Commands',
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
inputLabel: 'Command',
inputPlaceholder: 'Type a command',
closeLabel: 'Close',
suggestionsLabel: 'Suggestions',
outputLabel: 'Output',
emptySuggestions: 'No matching commands.',
unknownCommand: 'Unknown command: {command}',
helpIntro: 'Available commands:',
clearedMessage: 'Output cleared.',
cvOpened: 'Opened the CV in a new tab.',
navigating: 'Going to {target}.',
commandDescriptions: {
help: 'Lists the available commands.',
projects: 'Opens the projects overview.',
servicesAi: 'Opens the AI integration page.',
cv: 'Opens the CV as a PDF.',
contact: 'Opens the contact page.',
brew: 'A short playful acknowledgement.',
ignite: 'A short playful acknowledgement.',
rev: 'A short playful acknowledgement.',
clear: 'Clears the output.',
close: 'Closes the command palette.',
},
responses: {
brew: 'Freshly brewed. Automation that runs before the first coffee.',
ignite: 'Ignition on. Systems are warming up.',
rev: 'Revs climbing, the content stays calm.',
},
},
systemsMap: {
heading: 'Systems map',
intro: 'How the technical layers connect — from hardware through to the project fields.',
diagramDescription: 'Diagram of the technical layers and their connections.',
listHeading: 'Nodes as a list',
legendHeading: 'Legend',
relationshipLabel: 'Connected to {targets}.',
legend: {
ai: 'AI integration',
cluster: 'Cluster',
hardware: 'Hardware and network',
software: 'Software',
project: 'Project field',
},
},
},
};

View File

@@ -0,0 +1,86 @@
<button
#trigger
type="button"
class="command-palette-trigger"
aria-haspopup="dialog"
[attr.aria-expanded]="open()"
[attr.aria-controls]="dialogId"
[attr.aria-label]="copy().triggerLabel"
(click)="onTriggerClick()"
>
<span>{{ copy().triggerLabel }}</span>
<kbd>{{ copy().shortcutHint }}</kbd>
</button>
@if (open()) {
<div class="command-palette-overlay">
<div class="command-palette-scrim" aria-hidden="true"></div>
<div
#dialog
class="command-palette-dialog glass-surface"
role="dialog"
aria-modal="true"
[id]="dialogId"
[attr.aria-labelledby]="titleId"
[attr.aria-describedby]="descriptionId"
(keydown)="onDialogKeydown($event, dialog)"
>
<div class="command-palette-header">
<div>
<h2 [id]="titleId" class="command-palette-title">{{ copy().dialogTitle }}</h2>
<p [id]="descriptionId" class="command-palette-description">
{{ copy().dialogDescription }}
</p>
</div>
<button type="button" class="command-palette-close" (click)="closePalette()">
{{ copy().closeLabel }}
</button>
</div>
<form class="command-palette-form" (submit)="onSubmit($event)">
<label [attr.for]="inputId">{{ copy().inputLabel }}</label>
<input
#commandInput
[id]="inputId"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
[attr.placeholder]="copy().inputPlaceholder"
[value]="query()"
(input)="onQueryInput($event)"
/>
<p class="command-palette-kicker" [id]="suggestionsId">{{ copy().suggestionsLabel }}</p>
@if (suggestions().length === 0) {
<p class="command-palette-empty">{{ copy().emptySuggestions }}</p>
} @else {
<ul class="command-palette-suggestions" [attr.aria-labelledby]="suggestionsId">
@for (command of suggestions(); track command.id) {
<li>
<button type="button" (click)="runDefinition(command)">
<span>{{ command.input }}</span>
<span>{{ copy().commandDescriptions[command.id] }}</span>
</button>
</li>
}
</ul>
}
</form>
<div
class="command-palette-output"
role="log"
aria-live="polite"
aria-atomic="false"
[attr.aria-label]="copy().outputLabel"
>
<ul>
@for (line of output(); track line.id) {
<li>{{ line.text }}</li>
}
</ul>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,168 @@
:host {
display: inline-flex;
align-items: center;
}
.command-palette-trigger {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 2.75rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
}
.command-palette-trigger kbd {
padding: 0.1rem var(--space-2);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.command-palette-overlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: var(--space-4);
}
.command-palette-scrim {
position: absolute;
inset: 0;
background: var(--color-surface);
opacity: 0.72;
}
.command-palette-dialog {
position: relative;
z-index: 1;
display: grid;
gap: var(--space-4);
width: min(100%, 40rem);
max-height: min(36rem, 90vh);
overflow: auto;
padding: var(--space-5);
border-radius: var(--radius-lg);
}
.command-palette-header {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: start;
}
.command-palette-title,
.command-palette-description,
.command-palette-kicker,
.command-palette-empty,
.command-palette-output ul,
.command-palette-suggestions {
margin: 0;
}
.command-palette-title {
font-size: var(--text-lg);
line-height: var(--leading-tight);
}
.command-palette-description,
.command-palette-empty,
.command-palette-output {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.command-palette-close {
min-height: 2.75rem;
padding-inline: var(--space-3);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
}
.command-palette-form {
display: grid;
gap: var(--space-2);
}
.command-palette-form input {
min-height: 2.75rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text);
font: inherit;
}
.command-palette-kicker {
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-text-subtle);
}
.command-palette-suggestions,
.command-palette-output ul {
list-style: none;
padding: 0;
}
.command-palette-suggestions {
display: grid;
gap: var(--space-2);
}
.command-palette-suggestions button {
display: grid;
gap: var(--space-1);
width: 100%;
min-height: 2.75rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text);
font: inherit;
text-align: start;
}
.command-palette-suggestions button span:first-child {
font-family: var(--font-mono);
}
.command-palette-suggestions button span:last-child,
.command-palette-output {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.command-palette-output {
white-space: pre-wrap;
}
@media (hover: hover) and (pointer: fine) {
.command-palette-trigger:hover,
.command-palette-close:hover,
.command-palette-suggestions button:hover {
border-color: var(--surface-glass-border-strong);
}
}
@media (prefers-reduced-motion: reduce) {
.command-palette-overlay,
.command-palette-dialog {
transition: none;
}
}

View File

@@ -0,0 +1,277 @@
import { DOCUMENT } from '@angular/common';
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
import { SITE_CONFIG } from '../../core/content/site-config';
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
import { CommandPalette } from './command-palette';
import { COMMAND_IDS, COMMANDS } from './commands';
describe('CommandPalette', () => {
afterEach(() => {
vi.restoreAllMocks();
});
async function createFixture(
locale: AppLocale = 'de',
extraProviders: { provide: unknown; useValue: unknown }[] = [],
): Promise<ComponentFixture<CommandPalette>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [CommandPalette],
providers: [provideRouter([]), ...extraProviders],
}).compileComponents();
TestBed.inject(LocaleService).setLocale(locale);
const fixture = TestBed.createComponent(CommandPalette);
fixture.detectChanges();
await fixture.whenStable();
return fixture;
}
async function flush(fixture: ComponentFixture<CommandPalette>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
await fixture.whenStable();
}
function trigger(fixture: ComponentFixture<CommandPalette>): HTMLButtonElement {
return fixture.nativeElement.querySelector('.command-palette-trigger');
}
function dialog(fixture: ComponentFixture<CommandPalette>): HTMLElement | null {
return fixture.nativeElement.querySelector('[role="dialog"]');
}
async function openViaTrigger(fixture: ComponentFixture<CommandPalette>): Promise<void> {
trigger(fixture).click();
await flush(fixture);
}
async function submitQuery(
fixture: ComponentFixture<CommandPalette>,
value: string,
): Promise<void> {
const input = fixture.nativeElement.querySelector('input');
expect(input).toBeTruthy();
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
fixture.nativeElement
.querySelector('form')
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await flush(fixture);
}
function outputText(fixture: ComponentFixture<CommandPalette>): string {
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
}
function focusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((element) => element.tabIndex >= 0);
}
it.each(APP_LOCALES)(
'renders the trigger and not the dialog on the server (%s)',
async (locale) => {
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
expect(trigger(fixture)).toBeTruthy();
expect(dialog(fixture)).toBeNull();
},
);
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const input = fixture.nativeElement.querySelector('input');
expect(dialog(fixture)).toBeTruthy();
expect(document.activeElement).toBe(input);
});
it.each(APP_LOCALES)(
'opens from Ctrl+K and Meta+K and prevents the default (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await flush(fixture);
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
const openDialog = dialog(fixture);
if (openDialog) {
openDialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
}
const event = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
...modifier,
});
const prevent = vi.spyOn(event, 'preventDefault');
document.dispatchEvent(event);
await flush(fixture);
expect(prevent).toHaveBeenCalled();
expect(dialog(fixture)).toBeTruthy();
}
},
);
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const openDialog = dialog(fixture);
expect(openDialog).toBeTruthy();
openDialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
expect(dialog(fixture)).toBeNull();
expect(document.activeElement).toBe(trigger(fixture));
});
it.each(APP_LOCALES)('closes from the close button and restores focus (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const closeButton = fixture.nativeElement.querySelector('.command-palette-close');
closeButton.click();
await flush(fixture);
expect(dialog(fixture)).toBeNull();
expect(document.activeElement).toBe(trigger(fixture));
});
it.each(APP_LOCALES)('wraps Tab and Shift+Tab inside the dialog (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const openDialog = dialog(fixture);
expect(openDialog).toBeTruthy();
const items = focusable(openDialog as HTMLElement);
const first = items[0];
const last = items[items.length - 1];
last.focus();
openDialog?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(first);
first.focus();
openDialog?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(last);
});
it.each(APP_LOCALES)('writes localized help, playful replies and clear (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const copy = SIGNATURE_COPY[locale].palette;
await submitQuery(fixture, 'help');
const helpText = outputText(fixture);
expect(helpText).toContain(copy.helpIntro);
for (const command of COMMANDS) {
expect(helpText).toContain(command.input);
expect(helpText).toContain(copy.commandDescriptions[command.id]);
}
expect(COMMAND_IDS.every((id) => helpText.includes(copy.commandDescriptions[id]))).toBe(true);
await submitQuery(fixture, 'brew');
expect(outputText(fixture)).toContain(copy.responses.brew);
await submitQuery(fixture, 'ignite');
expect(outputText(fixture)).toContain(copy.responses.ignite);
await submitQuery(fixture, 'rev');
expect(outputText(fixture)).toContain(copy.responses.rev);
await submitQuery(fixture, 'clear');
expect(outputText(fixture).trim()).toBe(copy.clearedMessage);
});
it.each(APP_LOCALES)(
'navigates projects, services ai and contact through NavigationService (%s)',
async (locale) => {
const fixture = await createFixture(locale);
const router = TestBed.inject(Router);
const navigation = TestBed.inject(NavigationService);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
await openViaTrigger(fixture);
await submitQuery(fixture, 'projects');
expect(navigate).toHaveBeenCalledWith(navigation.link('projects'));
await openViaTrigger(fixture);
await submitQuery(fixture, 'services ai');
expect(navigate).toHaveBeenCalledWith(navigation.link('servicesAi'));
await openViaTrigger(fixture);
await submitQuery(fixture, 'contact');
expect(navigate).toHaveBeenCalledWith(navigation.link('contact'));
},
);
it.each(APP_LOCALES)('opens the CV in a new tab and never navigates (%s)', async (locale) => {
const fixture = await createFixture(locale);
const router = TestBed.inject(Router);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const view = TestBed.inject(DOCUMENT).defaultView;
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
await openViaTrigger(fixture);
await submitQuery(fixture, 'cv');
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
expect(navigate).not.toHaveBeenCalled();
expect(outputText(fixture)).toContain(SIGNATURE_COPY[locale].palette.cvOpened);
});
it.each(APP_LOCALES)(
'reports unknown input without navigating or opening a window (%s)',
async (locale) => {
const fixture = await createFixture(locale);
const router = TestBed.inject(Router);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const view = TestBed.inject(DOCUMENT).defaultView;
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
const raw = 'rm -rf /';
await openViaTrigger(fixture);
await submitQuery(fixture, raw);
expect(outputText(fixture)).toContain(
SIGNATURE_COPY[locale].palette.unknownCommand.replace('{command}', raw),
);
expect(navigate).not.toHaveBeenCalled();
expect(open).not.toHaveBeenCalled();
},
);
it('removes the document keydown listener on destroy', async () => {
const add = vi.spyOn(document, 'addEventListener');
const fixture = await createFixture();
await flush(fixture);
const added = add.mock.calls.find((call) => call[0] === 'keydown');
expect(added).toBeTruthy();
const remove = vi.spyOn(document, 'removeEventListener');
fixture.destroy();
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
});
it('registers no document listener on the server', async () => {
const add = vi.spyOn(document, 'addEventListener');
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
});
});

View File

@@ -0,0 +1,272 @@
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
inject,
Injector,
signal,
viewChild,
} from '@angular/core';
import { Router } from '@angular/router';
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
import { SITE_CONFIG } from '../../core/content/site-config';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
import { isBrowserPlatform } from '../../core/platform/browser';
import {
COMMAND_IDS,
COMMANDS,
parseCommand,
suggestCommands,
type CommandDefinition,
} from './commands';
let commandPaletteInstanceId = 0;
let commandPaletteOutputId = 0;
@Component({
selector: 'app-command-palette',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './command-palette.html',
styleUrl: './command-palette.scss',
})
export class CommandPalette {
private readonly document = inject(DOCUMENT);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly navigation = inject(NavigationService);
private readonly localeService = inject(LocaleService);
private readonly isBrowser = isBrowserPlatform();
private readonly instanceId = commandPaletteInstanceId++;
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
protected readonly dialogId = `command-palette-dialog-${this.instanceId}`;
protected readonly titleId = `command-palette-title-${this.instanceId}`;
protected readonly descriptionId = `command-palette-description-${this.instanceId}`;
protected readonly inputId = `command-palette-input-${this.instanceId}`;
protected readonly suggestionsId = `command-palette-suggestions-${this.instanceId}`;
protected readonly open = signal(false);
protected readonly query = signal('');
protected readonly output = signal<readonly { id: number; text: string }[]>([]);
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
protected readonly suggestions = computed(() =>
suggestCommands(this.query(), this.localeService.locale()),
);
private opener: HTMLElement | null = null;
constructor() {
afterNextRender(
() => {
if (!this.isBrowser) {
return;
}
this.document.addEventListener('keydown', this.onDocumentKeydown);
},
{ injector: this.injector },
);
this.destroyRef.onDestroy(() => {
this.document.removeEventListener('keydown', this.onDocumentKeydown);
});
}
protected onTriggerClick(): void {
this.triggerRef()?.nativeElement.focus();
this.openPalette();
}
protected onQueryInput(event: Event): void {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
this.query.set(target.value);
}
protected onSubmit(event: Event): void {
event.preventDefault();
this.runRaw(this.query());
}
protected onDialogKeydown(event: KeyboardEvent, dialog: HTMLElement): void {
if (event.key === 'Escape') {
event.preventDefault();
this.closePalette();
return;
}
if (event.key !== 'Tab') {
return;
}
const focusable = this.focusableElements(dialog);
if (focusable.length === 0) {
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = this.document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
protected runDefinition(definition: CommandDefinition): void {
const copy = this.copy();
switch (definition.action.kind) {
case 'navigate': {
void this.router.navigate(this.navigation.link(definition.action.routeId));
this.append(copy.navigating.replace('{target}', definition.input));
this.closePalette();
return;
}
case 'openCv': {
if (this.isBrowser) {
this.document.defaultView?.open(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
}
this.append(copy.cvOpened);
return;
}
case 'message': {
this.appendMessage(definition.action.messageKey);
return;
}
case 'clear': {
this.output.set([]);
this.append(copy.clearedMessage);
return;
}
case 'close': {
this.closePalette();
return;
}
}
}
private runRaw(raw: string): void {
const match = parseCommand(raw);
if (match.kind === 'empty') {
return;
}
if (match.kind === 'unknown') {
this.append(this.copy().unknownCommand.replace('{command}', match.input));
return;
}
this.runDefinition(match.definition);
}
private appendMessage(key: 'help' | 'brew' | 'ignite' | 'rev'): void {
const copy = this.copy();
if (key === 'help') {
const lines = [
copy.helpIntro,
...COMMAND_IDS.map((id) => {
const command = COMMANDS.find((entry) => entry.id === id);
const input = command?.input ?? id;
return `${input}${copy.commandDescriptions[id]}`;
}),
];
this.append(lines.join('\n'));
return;
}
this.append(copy.responses[key]);
}
private append(text: string): void {
this.output.update((lines) => [...lines, { id: commandPaletteOutputId++, text }]);
}
private openPalette(): void {
if (this.open()) {
this.focusInput();
return;
}
const active = this.document.activeElement;
this.opener = active instanceof HTMLElement ? active : null;
this.open.set(true);
this.focusInput();
}
protected closePalette(): void {
if (!this.open()) {
return;
}
this.open.set(false);
this.restoreFocus();
}
private focusInput(): void {
afterNextRender(
() => {
this.inputRef()?.nativeElement.focus();
},
{ injector: this.injector },
);
}
private restoreFocus(): void {
const opener = this.opener;
this.opener = null;
afterNextRender(
() => {
if (opener instanceof HTMLElement && opener.isConnected) {
opener.focus();
return;
}
this.triggerRef()?.nativeElement.focus();
},
{ injector: this.injector },
);
}
private focusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0);
}
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
return;
}
event.preventDefault();
this.openPalette();
};
}

View File

@@ -0,0 +1,79 @@
import { APP_LOCALES } from '../../core/i18n/locale';
import {
COMMAND_IDS,
COMMANDS,
normalizeCommandInput,
parseCommand,
suggestCommands,
type CommandId,
} from './commands';
describe('command parsing', () => {
it('collapses whitespace and lowercases input', () => {
expect(normalizeCommandInput(' Services AI ')).toBe('services ai');
expect(normalizeCommandInput('HELP')).toBe('help');
});
it('resolves every CommandId from its canonical input and aliases in both locales', () => {
for (const command of COMMANDS) {
const canonical = parseCommand(command.input);
expect(canonical).toEqual({ kind: 'command', definition: command });
for (const locale of APP_LOCALES) {
for (const alias of command.aliases[locale]) {
expect(parseCommand(alias)).toEqual({ kind: 'command', definition: command });
}
}
}
expect(COMMANDS.map((command) => command.id)).toEqual([...COMMAND_IDS]);
});
it('treats hostile and prototype inputs as unknown without side effects', () => {
const prototypeNames = Object.getOwnPropertyNames(Object.prototype);
const hostile = [
'rm -rf /',
'eval(1+1)',
'new Function()',
'<script>alert(1)</script>',
'__proto__',
'constructor',
'toString',
'hasOwnProperty',
];
for (const input of hostile) {
expect(parseCommand(input)).toEqual({ kind: 'unknown', input });
}
expect(parseCommand('')).toEqual({ kind: 'empty' });
expect(parseCommand(' ')).toEqual({ kind: 'empty' });
expect(Object.getOwnPropertyNames(Object.prototype)).toEqual(prototypeNames);
expect(Object.prototype).not.toHaveProperty('polluted');
});
it('returns an equal result for the same input across repeated calls', () => {
const samples = ['help', 'services ai', ' ', 'unknown-token', '__proto__'];
for (const sample of samples) {
expect(parseCommand(sample)).toEqual(parseCommand(sample));
}
});
it('suggests commands in a stable COMMAND_IDS order and filters by prefix', () => {
const emptyDe = suggestCommands('', 'de').map((command) => command.id);
const emptyEn = suggestCommands('', 'en').map((command) => command.id);
const expectedIds: CommandId[] = [...COMMAND_IDS];
expect(emptyDe).toEqual(expectedIds);
expect(emptyEn).toEqual(expectedIds);
expect(suggestCommands('c', 'en').map((command) => command.id)).toEqual([
'cv',
'contact',
'clear',
'close',
]);
expect(suggestCommands('lei', 'de').map((command) => command.id)).toEqual(['servicesAi']);
expect(suggestCommands('c', 'de')).toEqual(suggestCommands('c', 'de'));
});
});

View File

@@ -0,0 +1,167 @@
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
import { type RouteId } from '../../core/routing/route-ids';
export type CommandId =
| 'help'
| 'projects'
| 'servicesAi'
| 'cv'
| 'contact'
| 'brew'
| 'ignite'
| 'rev'
| 'clear'
| 'close';
export const COMMAND_IDS: readonly CommandId[] = [
'help',
'projects',
'servicesAi',
'cv',
'contact',
'brew',
'ignite',
'rev',
'clear',
'close',
];
export type CommandAction =
| { readonly kind: 'navigate'; readonly routeId: RouteId }
| { readonly kind: 'openCv' }
| { readonly kind: 'message'; readonly messageKey: 'help' | 'brew' | 'ignite' | 'rev' }
| { readonly kind: 'clear' }
| { readonly kind: 'close' };
export interface CommandDefinition {
readonly id: CommandId;
readonly input: string;
readonly aliases: Record<AppLocale, readonly string[]>;
readonly action: CommandAction;
}
export type CommandMatch =
| { readonly kind: 'command'; readonly definition: CommandDefinition }
| { readonly kind: 'empty' }
| { readonly kind: 'unknown'; readonly input: string };
export const COMMANDS: readonly CommandDefinition[] = [
{
id: 'help',
input: 'help',
aliases: { de: ['hilfe'], en: [] },
action: { kind: 'message', messageKey: 'help' },
},
{
id: 'projects',
input: 'projects',
aliases: { de: ['projekte'], en: [] },
action: { kind: 'navigate', routeId: 'projects' },
},
{
id: 'servicesAi',
input: 'services ai',
aliases: { de: ['leistungen ai'], en: [] },
action: { kind: 'navigate', routeId: 'servicesAi' },
},
{
id: 'cv',
input: 'cv',
aliases: { de: ['lebenslauf'], en: ['resume'] },
action: { kind: 'openCv' },
},
{
id: 'contact',
input: 'contact',
aliases: { de: ['kontakt'], en: [] },
action: { kind: 'navigate', routeId: 'contact' },
},
{
id: 'brew',
input: 'brew',
aliases: { de: ['brauen'], en: [] },
action: { kind: 'message', messageKey: 'brew' },
},
{
id: 'ignite',
input: 'ignite',
aliases: { de: ['zuenden', 'zünden'], en: [] },
action: { kind: 'message', messageKey: 'ignite' },
},
{
id: 'rev',
input: 'rev',
aliases: { de: ['drehzahl'], en: [] },
action: { kind: 'message', messageKey: 'rev' },
},
{
id: 'clear',
input: 'clear',
aliases: { de: ['leeren'], en: [] },
action: { kind: 'clear' },
},
{
id: 'close',
input: 'close',
aliases: { de: ['schliessen', 'schließen'], en: [] },
action: { kind: 'close' },
},
];
const COMMAND_LOOKUP = buildCommandLookup(COMMANDS);
function buildCommandLookup(
commands: readonly CommandDefinition[],
): ReadonlyMap<string, CommandDefinition> {
const lookup = new Map<string, CommandDefinition>();
for (const command of commands) {
lookup.set(normalizeCommandInput(command.input), command);
for (const locale of APP_LOCALES) {
for (const alias of command.aliases[locale]) {
lookup.set(normalizeCommandInput(alias), command);
}
}
}
return lookup;
}
export function normalizeCommandInput(raw: string): string {
return raw.trim().replace(/\s+/g, ' ').toLowerCase();
}
export function parseCommand(raw: string): CommandMatch {
const normalized = normalizeCommandInput(raw);
if (normalized.length === 0) {
return { kind: 'empty' };
}
const definition = COMMAND_LOOKUP.get(normalized);
if (!definition) {
return { kind: 'unknown', input: raw };
}
return { kind: 'command', definition };
}
export function suggestCommands(raw: string, locale: AppLocale): readonly CommandDefinition[] {
const normalized = normalizeCommandInput(raw);
return COMMANDS.filter((command) => {
if (normalized.length === 0) {
return true;
}
if (command.input.startsWith(normalized)) {
return true;
}
return command.aliases[locale].some((alias) =>
normalizeCommandInput(alias).startsWith(normalized),
);
});
}

View File

@@ -0,0 +1,23 @@
@mixin reveal-target {
&.reveal-pending {
opacity: 0;
transform: translateY(0.5rem);
transition:
opacity var(--duration-base) var(--ease-standard),
transform var(--duration-base) var(--ease-standard);
}
&.is-revealed {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
&.reveal-pending,
&.is-revealed {
opacity: 1;
transform: none;
transition: none;
}
}
}

View File

@@ -0,0 +1,20 @@
<div class="metric-bar">
<div class="metric-bar-header">
<span [id]="labelId">{{ label() }}</span>
<span>{{ displayValue() }}</span>
</div>
@if (description(); as descriptionText) {
<p class="metric-bar-description">{{ descriptionText }}</p>
}
<div
class="metric-bar-track"
role="progressbar"
[attr.aria-valuenow]="clampedValue()"
aria-valuemin="0"
[attr.aria-valuemax]="max()"
[attr.aria-valuetext]="displayValue()"
[attr.aria-labelledby]="labelId"
>
<div class="metric-bar-fill" [style.width.%]="percent()"></div>
</div>
</div>

View File

@@ -0,0 +1,44 @@
@use 'app/shared/motion/reveal' as reveal;
:host {
display: block;
}
.metric-bar {
display: grid;
gap: var(--space-2);
@include reveal.reveal-target;
}
.metric-bar-header {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-sm);
}
.metric-bar-description {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.metric-bar-track {
height: 0.5rem;
overflow: hidden;
border-radius: var(--radius-pill);
background: var(--color-surface-muted);
}
.metric-bar-fill {
height: 100%;
border-radius: inherit;
background: var(--color-accent);
transition: width var(--duration-base) var(--ease-standard);
}
@media (prefers-reduced-motion: reduce) {
.metric-bar-fill {
transition: none;
}
}

View File

@@ -0,0 +1,95 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MetricBar } from './metric-bar';
describe('MetricBar', () => {
async function createFixture(inputs: {
label: string;
value: number;
max?: number;
valueText?: string;
description?: string;
}): Promise<ComponentFixture<MetricBar>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [MetricBar],
}).compileComponents();
const fixture = TestBed.createComponent(MetricBar);
fixture.componentRef.setInput('label', inputs.label);
fixture.componentRef.setInput('value', inputs.value);
if (inputs.max !== undefined) {
fixture.componentRef.setInput('max', inputs.max);
}
if (inputs.valueText !== undefined) {
fixture.componentRef.setInput('valueText', inputs.valueText);
}
if (inputs.description !== undefined) {
fixture.componentRef.setInput('description', inputs.description);
}
fixture.detectChanges();
await fixture.whenStable();
return fixture;
}
it('renders the label and value as visible text', async () => {
const fixture = await createFixture({
label: 'Coverage',
value: 40,
valueText: '40 of 80',
});
const text = fixture.nativeElement.textContent ?? '';
expect(text).toContain('Coverage');
expect(text).toContain('40 of 80');
});
it('exposes progressbar semantics and an accessible name through aria-labelledby', async () => {
const fixture = await createFixture({
label: 'Latency',
value: 25,
max: 50,
valueText: '25 ms',
});
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const labelId = bar.getAttribute('aria-labelledby');
const label = fixture.nativeElement.querySelector(`#${labelId}`);
expect(bar.getAttribute('aria-valuenow')).toBe('25');
expect(bar.getAttribute('aria-valuemin')).toBe('0');
expect(bar.getAttribute('aria-valuemax')).toBe('50');
expect(bar.getAttribute('aria-valuetext')).toBe('25 ms');
expect(label?.textContent?.trim()).toBe('Latency');
});
it('clamps values below 0 and above max', async () => {
const low = await createFixture({ label: 'Low', value: -12, max: 10 });
const lowBar = low.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const lowFill = low.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
expect(lowBar.getAttribute('aria-valuenow')).toBe('0');
expect(lowFill.style.width).toBe('0%');
const high = await createFixture({ label: 'High', value: 140, max: 50 });
const highBar = high.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const highFill = high.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
expect(highBar.getAttribute('aria-valuenow')).toBe('50');
expect(highFill.style.width).toBe('100%');
});
it('does not produce NaN when max is 0', async () => {
const fixture = await createFixture({ label: 'Empty', value: 8, max: 0 });
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const fill = fixture.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
expect(bar.getAttribute('aria-valuenow')).toBe('0');
expect(fill.style.width).toBe('0%');
expect(fill.style.width).not.toContain('NaN');
expect(fixture.nativeElement.textContent).toContain('Empty');
expect(fixture.nativeElement.textContent).toContain('0');
});
});

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
let metricBarInstanceId = 0;
@Component({
selector: 'app-metric-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './metric-bar.html',
styleUrl: './metric-bar.scss',
})
export class MetricBar {
readonly label = input.required<string>();
readonly value = input.required<number>();
readonly max = input(100);
readonly valueText = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
private readonly instanceId = metricBarInstanceId++;
protected readonly labelId = `metric-bar-label-${this.instanceId}`;
protected readonly clampedValue = computed(() => {
const max = this.max();
const value = this.value();
if (!Number.isFinite(max) || max <= 0) {
return 0;
}
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(max, Math.max(0, value));
});
protected readonly percent = computed(() => {
const max = this.max();
if (!Number.isFinite(max) || max <= 0) {
return 0;
}
return (this.clampedValue() / max) * 100;
});
protected readonly displayValue = computed(() => this.valueText() ?? String(this.clampedValue()));
}

View File

@@ -0,0 +1,150 @@
import { ApplicationRef, Component, PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RevealDirective } from './reveal.directive';
@Component({
imports: [RevealDirective],
template: `<div appReveal [appRevealThreshold]="0.2">Reveal host</div>`,
})
class RevealHost {}
class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = [];
readonly observe = vi.fn();
readonly unobserve = vi.fn();
readonly disconnect = vi.fn();
constructor(
private readonly callback: IntersectionObserverCallback,
readonly options?: IntersectionObserverInit,
) {
MockIntersectionObserver.instances.push(this);
}
trigger(isIntersecting: boolean): void {
this.callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
}
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: matchesQuery(query),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
describe('RevealDirective', () => {
afterEach(() => {
MockIntersectionObserver.instances = [];
vi.restoreAllMocks();
vi.unstubAllGlobals();
Reflect.deleteProperty(window, 'matchMedia');
});
async function createHost(
providers: { provide: unknown; useValue: unknown }[] = [],
): Promise<ComponentFixture<RevealHost>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [RevealHost],
providers,
}).compileComponents();
const fixture = TestBed.createComponent(RevealHost);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
return fixture;
}
function hostElement(fixture: ComponentFixture<RevealHost>): HTMLElement {
return fixture.nativeElement.querySelector('[appReveal]');
}
it('reveals immediately on the server without constructing an observer', async () => {
const Observer = vi.fn();
vi.stubGlobal('IntersectionObserver', Observer);
const fixture = await createHost([{ provide: PLATFORM_ID, useValue: 'server' }]);
const element = hostElement(fixture);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(element.classList.contains('reveal-pending')).toBe(false);
expect(Observer).not.toHaveBeenCalled();
});
it('reveals immediately when IntersectionObserver is missing', async () => {
const original = window.IntersectionObserver;
Reflect.deleteProperty(window, 'IntersectionObserver');
try {
const fixture = await createHost();
const element = hostElement(fixture);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(element.classList.contains('reveal-pending')).toBe(false);
} finally {
window.IntersectionObserver = original;
}
});
it('reveals immediately when reduced motion is requested', async () => {
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
const Observer = vi.fn();
vi.stubGlobal('IntersectionObserver', Observer);
const fixture = await createHost();
const element = hostElement(fixture);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(element.classList.contains('reveal-pending')).toBe(false);
expect(Observer).not.toHaveBeenCalled();
});
it('marks the element pending, then revealed, and disconnects on intersection', async () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
const fixture = await createHost();
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();
observer.trigger(true);
fixture.detectChanges();
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);
const fixture = await createHost();
const observer = MockIntersectionObserver.instances[0];
expect(observer).toBeTruthy();
fixture.destroy();
expect(observer.disconnect).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
DestroyRef,
Directive,
ElementRef,
inject,
Injector,
input,
} from '@angular/core';
import { isBrowserPlatform, prefersReducedMotion } from '../../core/platform/browser';
@Directive({
selector: '[appReveal]',
})
export class RevealDirective {
readonly appRevealThreshold = input(0.12);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly document = inject(DOCUMENT);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isBrowserPlatform();
private readonly reducedMotion = prefersReducedMotion();
private observer: IntersectionObserver | null = null;
constructor() {
if (!this.isBrowser || this.reducedMotion || !this.canObserve()) {
this.revealNow();
return;
}
afterNextRender(() => this.observe(), { injector: this.injector });
this.destroyRef.onDestroy(() => this.disconnect());
}
private canObserve(): boolean {
const view = this.document.defaultView;
return !!view && typeof view.IntersectionObserver === 'function';
}
private observe(): void {
const view = this.document.defaultView;
if (!view || typeof view.IntersectionObserver !== 'function') {
this.revealNow();
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();
}
},
{
rootMargin: '0px 0px -8% 0px',
threshold: this.appRevealThreshold(),
},
);
this.observer.observe(this.host.nativeElement);
}
private revealNow(): void {
this.host.nativeElement.classList.add('is-revealed');
}
private disconnect(): void {
this.observer?.disconnect();
this.observer = null;
}
}

View File

@@ -0,0 +1,87 @@
<section class="systems-map" [attr.aria-labelledby]="headingId">
@if (headingLevel() === 3) {
<h3 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h3>
} @else {
<h2 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h2>
}
<p class="systems-map-intro">{{ introText() }}</p>
<div class="systems-map-figure">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 560"
preserveAspectRatio="xMidYMid meet"
role="group"
[attr.aria-labelledby]="svgTitleId"
[attr.aria-describedby]="svgDescId"
>
<title [id]="svgTitleId">{{ headingText() }}</title>
<desc [id]="svgDescId">{{ copy().diagramDescription }}</desc>
@for (edge of edges(); track edge.from + '-' + edge.to) {
<line
[attr.x1]="edge.x1"
[attr.y1]="edge.y1"
[attr.x2]="edge.x2"
[attr.y2]="edge.y2"
class="systems-map-edge"
[class.is-connected]="isConnectedEdge(edge)"
[class.is-dimmed]="isDimmedEdge(edge)"
aria-hidden="true"
/>
}
@for (node of nodes(); track node.id) {
<a
[attr.href]="node.href"
[attr.aria-label]="node.accessibleName"
class="systems-map-node"
[attr.data-kind]="node.kind"
[class.is-connected]="isConnectedNode(node.id)"
[class.is-dimmed]="isDimmedNode(node.id)"
(click)="onNodeActivate($event, node)"
(focusin)="onNodeEnter(node.id)"
(focusout)="onNodeLeave(node.id)"
(mouseenter)="onNodeEnter(node.id)"
(mouseleave)="onNodeLeave(node.id)"
>
@if (node.kind === 'ai' || node.kind === 'cluster') {
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" aria-hidden="true" />
} @else {
<rect
[attr.x]="node.x - 70"
[attr.y]="node.y - 20"
width="140"
height="40"
rx="8"
aria-hidden="true"
/>
}
<text [attr.x]="node.x" [attr.y]="node.y + 4" aria-hidden="true">{{ node.label }}</text>
</a>
}
</svg>
</div>
<div class="systems-map-list">
<p class="systems-map-kicker" [id]="listHeadingId">{{ copy().listHeading }}</p>
<ul class="systems-map-cards" [attr.aria-labelledby]="listHeadingId">
@for (node of nodes(); track node.id) {
<li>
<a [routerLink]="node.link" [attr.aria-label]="node.accessibleName">
<span class="systems-map-card-label">{{ node.label }}</span>
<span class="systems-map-card-summary">{{ node.summary }}</span>
<span class="systems-map-card-relation">{{ node.relationship }}</span>
</a>
</li>
}
</ul>
</div>
<p class="systems-map-kicker" [id]="legendHeadingId">{{ copy().legendHeading }}</p>
<ul class="systems-map-legend" [attr.aria-labelledby]="legendHeadingId">
@for (item of legendItems(); track item.kind) {
<li [attr.data-kind]="item.kind">{{ item.label }}</li>
}
</ul>
<p class="systems-map-readout" aria-hidden="true">{{ activeReadout() }}</p>
</section>

View File

@@ -0,0 +1,162 @@
import { type AppLocale } from '../../core/i18n/locale';
import { type RouteId } from '../../core/routing/route-ids';
export type SystemsMapNodeKind = 'ai' | 'cluster' | 'hardware' | 'software' | 'project';
export type SystemsMapNodeId =
| 'ai'
| 'clusters'
| 'hardware'
| 'software'
| 'stack'
| 'caseMigration'
| 'casePlatform'
| 'caseAutomation';
export const SYSTEMS_MAP_NODE_KINDS: readonly SystemsMapNodeKind[] = [
'ai',
'cluster',
'hardware',
'software',
'project',
];
export interface SystemsMapNode {
readonly id: SystemsMapNodeId;
readonly kind: SystemsMapNodeKind;
readonly routeId: RouteId;
readonly label: Record<AppLocale, string>;
readonly summary: Record<AppLocale, string>;
readonly x: number;
readonly y: number;
}
export interface SystemsMapEdge {
readonly from: SystemsMapNodeId;
readonly to: SystemsMapNodeId;
}
export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
{
id: 'hardware',
kind: 'hardware',
routeId: 'servicesHardwareNetwork',
label: { de: 'Hardware', en: 'Hardware' },
summary: {
de: 'Geräte, Netz und physische Schicht',
en: 'Devices, network and the physical layer',
},
x: 140,
y: 300,
},
{
id: 'clusters',
kind: 'cluster',
routeId: 'servicesClusters',
label: { de: 'Cluster', en: 'Clusters' },
summary: {
de: 'Orchestrierung und Betrieb von Cluster-Umgebungen',
en: 'Orchestration and cluster operations',
},
x: 340,
y: 140,
},
{
id: 'software',
kind: 'software',
routeId: 'servicesSoftware',
label: { de: 'Software', en: 'Software' },
summary: {
de: 'Anwendungen und Schnittstellen',
en: 'Applications and interfaces',
},
x: 520,
y: 300,
},
{
id: 'ai',
kind: 'ai',
routeId: 'servicesAi',
label: { de: 'KI', en: 'AI' },
summary: {
de: 'Lokale Modelle und Integrationsarbeit',
en: 'Local models and integration work',
},
x: 720,
y: 140,
},
{
id: 'stack',
kind: 'software',
routeId: 'stack',
label: { de: 'Stack', en: 'Stack' },
summary: {
de: 'Werkzeuge und Laufzeitumgebung',
en: 'Tools and runtime environment',
},
x: 340,
y: 460,
},
{
id: 'caseMigration',
kind: 'project',
routeId: 'projects',
label: { de: 'Datenmigration', en: 'Data migration' },
summary: {
de: 'Datenbestände strukturiert überführen',
en: 'Moving data stores in a structured way',
},
x: 860,
y: 460,
},
{
id: 'casePlatform',
kind: 'project',
routeId: 'projects',
label: { de: 'Plattform und Betrieb', en: 'Platform and operations' },
summary: {
de: 'Plattformen betreiben und weiterentwickeln',
en: 'Operating and evolving platforms',
},
x: 860,
y: 300,
},
{
id: 'caseAutomation',
kind: 'project',
routeId: 'projects',
label: { de: 'Automatisierung und AI', en: 'Automation and AI' },
summary: {
de: 'Abläufe automatisieren und Modelle anbinden',
en: 'Automating workflows and connecting models',
},
x: 900,
y: 140,
},
];
export const SYSTEMS_MAP_EDGES: readonly SystemsMapEdge[] = [
{ from: 'hardware', to: 'clusters' },
{ from: 'clusters', to: 'software' },
{ from: 'software', to: 'ai' },
{ from: 'ai', to: 'clusters' },
{ from: 'hardware', to: 'software' },
{ from: 'stack', to: 'software' },
{ from: 'ai', to: 'caseAutomation' },
{ from: 'software', to: 'casePlatform' },
{ from: 'software', to: 'caseMigration' },
];
export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeId[] {
const connected: SystemsMapNodeId[] = [];
for (const edge of SYSTEMS_MAP_EDGES) {
if (edge.from === id) {
connected.push(edge.to);
} else if (edge.to === id) {
connected.push(edge.from);
}
}
return connected;
}

View File

@@ -0,0 +1,177 @@
@use 'breakpoints' as bp;
:host {
display: block;
}
.systems-map {
display: grid;
gap: var(--space-4);
}
.systems-map-heading,
.systems-map-intro,
.systems-map-kicker,
.systems-map-readout {
margin: 0;
}
.systems-map-heading {
font-size: var(--text-xl);
line-height: var(--leading-tight);
}
.systems-map-intro,
.systems-map-card-summary,
.systems-map-card-relation,
.systems-map-readout {
color: var(--color-text-muted);
}
.systems-map-kicker {
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-text-subtle);
}
.systems-map-figure {
display: none;
}
.systems-map-figure svg {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-md);
background-color: var(--color-surface-raised);
background-image:
linear-gradient(var(--color-surface-muted) 1px, transparent 1px),
linear-gradient(90deg, var(--color-surface-muted) 1px, transparent 1px);
background-size: 1.5rem 1.5rem;
}
.systems-map-edge {
fill: none;
stroke: var(--color-accent-cool);
stroke-width: 1.25;
opacity: 0.7;
transition: opacity var(--duration-base) var(--ease-standard);
}
.systems-map-node {
color: var(--color-text);
outline: none;
}
.systems-map-node circle,
.systems-map-node rect {
fill: var(--color-surface-overlay);
stroke: var(--color-accent);
stroke-width: 1.25;
transition:
opacity var(--duration-base) var(--ease-standard),
stroke var(--duration-fast) var(--ease-standard);
}
.systems-map-node[data-kind='project'] rect,
.systems-map-legend [data-kind='project'] {
stroke: var(--color-accent-soft);
}
.systems-map-node[data-kind='ai'] circle,
.systems-map-legend [data-kind='ai'] {
stroke: var(--color-accent-strong);
}
.systems-map-node text {
fill: currentColor;
font-size: 0.75rem;
text-anchor: middle;
}
.systems-map-node.is-connected circle,
.systems-map-node.is-connected rect,
.systems-map-edge.is-connected {
stroke: var(--color-accent-strong);
opacity: 1;
}
.systems-map-node.is-dimmed,
.systems-map-edge.is-dimmed {
opacity: 0.55;
}
.systems-map-cards,
.systems-map-legend {
list-style: none;
margin: 0;
padding: 0;
}
.systems-map-cards {
display: grid;
gap: var(--space-3);
}
.systems-map-cards a {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-text);
text-decoration: none;
}
.systems-map-card-label {
font-weight: 600;
}
.systems-map-legend {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
font-size: var(--text-sm);
}
.systems-map-legend li {
padding-inline-start: var(--space-3);
border-inline-start: 2px solid var(--color-accent);
}
.systems-map-readout {
min-height: var(--text-md);
font-size: var(--text-sm);
}
@media (hover: hover) and (pointer: fine) {
.systems-map-node:hover circle,
.systems-map-node:hover rect {
stroke: var(--color-accent-strong);
}
.systems-map-cards a:hover {
border-color: var(--surface-glass-border-strong);
}
}
@media (prefers-reduced-motion: reduce) {
.systems-map-edge,
.systems-map-node circle,
.systems-map-node rect {
transition: none;
}
}
@include bp.respond-to(lg) {
.systems-map-figure {
display: block;
}
.systems-map-list {
display: none;
}
}

View File

@@ -0,0 +1,171 @@
import { PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
import { type AppLocale } from '../../core/i18n/locale';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
import { routePath } from '../../core/routing/route-paths';
import { SystemsMap } from './systems-map';
import { connectedNodeIds, SYSTEMS_MAP_NODES } from './systems-map.model';
describe('SystemsMap', () => {
async function createFixture(
providers: { provide: unknown; useValue: unknown }[] = [],
): Promise<ComponentFixture<SystemsMap>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [SystemsMap],
providers: [provideRouter([]), ...providers],
}).compileComponents();
const fixture = TestBed.createComponent(SystemsMap);
fixture.detectChanges();
await fixture.whenStable();
return fixture;
}
function hrefsOf(root: ParentNode, selector: string): string[] {
return Array.from(root.querySelectorAll(selector)).map((element) => {
return element.getAttribute('href') ?? '';
});
}
function expectedNodes(locale: AppLocale) {
const relationshipLabel = SIGNATURE_COPY[locale].systemsMap.relationshipLabel;
return SYSTEMS_MAP_NODES.map((node) => {
const connected = new Set(connectedNodeIds(node.id));
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
(entry) => entry.label[locale],
);
return {
...node,
label: node.label[locale],
summary: node.summary[locale],
href: routePath(node.routeId, locale),
connectedLabels,
relationship: relationshipLabel.replace('{targets}', connectedLabels.join(', ')),
};
});
}
it('exposes the same ordered hrefs in the SVG and the card list from the routing contract', async () => {
const fixture = await createFixture();
const locale = TestBed.inject(LocaleService).locale();
const expected = SYSTEMS_MAP_NODES.map((node) => routePath(node.routeId, locale));
const compiled = fixture.nativeElement as HTMLElement;
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
expect(hrefsOf(compiled, '.systems-map-cards a')).toEqual(expected);
});
it('gives every node a non-empty accessible name with label, summary and neighbors', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const nodes = expectedNodes(TestBed.inject(LocaleService).locale());
const svgAnchors = compiled.querySelectorAll('svg a');
const listAnchors = compiled.querySelectorAll('.systems-map-cards a');
expect(svgAnchors.length).toBe(nodes.length);
expect(listAnchors.length).toBe(nodes.length);
nodes.forEach((node, index) => {
const svgName = svgAnchors.item(index).getAttribute('aria-label') ?? '';
const listName = listAnchors.item(index).getAttribute('aria-label') ?? '';
expect(svgName.length).toBeGreaterThan(0);
expect(listName.length).toBeGreaterThan(0);
expect(svgName).toContain(node.label);
expect(svgName).toContain(node.summary);
expect(listName).toContain(node.label);
expect(listName).toContain(node.summary);
for (const label of node.connectedLabels) {
expect(svgName).toContain(label);
expect(listName).toContain(label);
}
});
});
it('keeps every node keyboard reachable and navigates on a plain left click', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const router = TestBed.inject(Router);
const navigation = TestBed.inject(NavigationService);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const anchors = compiled.querySelectorAll('svg a, .systems-map-cards a');
anchors.forEach((anchor) => {
expect(anchor.getAttribute('tabindex')).not.toBe('-1');
});
const first = SYSTEMS_MAP_NODES[0];
const svgAnchor = compiled.querySelector('svg a');
expect(svgAnchor).toBeTruthy();
svgAnchor?.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
fixture.detectChanges();
expect(navigate).toHaveBeenCalledWith(navigation.link(first.routeId));
});
it('rewrites every href when the locale switches to English', async () => {
const fixture = await createFixture();
const locale = TestBed.inject(LocaleService);
locale.setLocale('en');
fixture.detectChanges();
await fixture.whenStable();
const expected = SYSTEMS_MAP_NODES.map((node) => routePath(node.routeId, 'en'));
const compiled = fixture.nativeElement as HTMLElement;
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
expect(hrefsOf(compiled, '.systems-map-cards a')).toEqual(expected);
});
it('uses heading and intro inputs and falls back to signature copy when they are empty', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const defaults = SIGNATURE_COPY.de.systemsMap;
expect(compiled.querySelector('.systems-map-heading')?.textContent?.trim()).toBe(
defaults.heading,
);
expect(compiled.querySelector('.systems-map-intro')?.textContent?.trim()).toBe(defaults.intro);
fixture.componentRef.setInput('heading', 'Eigene Karte');
fixture.componentRef.setInput('intro', 'Eigene Einleitung');
fixture.detectChanges();
expect(compiled.querySelector('.systems-map-heading')?.textContent?.trim()).toBe(
'Eigene Karte',
);
expect(compiled.querySelector('.systems-map-intro')?.textContent?.trim()).toBe(
'Eigene Einleitung',
);
fixture.componentRef.setInput('heading', '');
fixture.componentRef.setInput('intro', ' ');
fixture.detectChanges();
expect(compiled.querySelector('.systems-map-heading')?.textContent?.trim()).toBe(
defaults.heading,
);
expect(compiled.querySelector('.systems-map-intro')?.textContent?.trim()).toBe(defaults.intro);
});
it('still renders the SVG, card list, links and summaries on the server', async () => {
const fixture = await createFixture([{ provide: PLATFORM_ID, useValue: 'server' }]);
const compiled = fixture.nativeElement as HTMLElement;
const svgAnchors = compiled.querySelectorAll('svg a');
const listAnchors = compiled.querySelectorAll('.systems-map-cards a');
expect(svgAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
expect(listAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
expectedNodes(TestBed.inject(LocaleService).locale()).forEach((node, index) => {
expect(svgAnchors.item(index).getAttribute('href')).toBe(node.href);
expect(listAnchors.item(index).textContent).toContain(node.summary);
});
});
});

View File

@@ -0,0 +1,214 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
import { type AppLocale } from '../../core/i18n/locale';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
import { routePath } from '../../core/routing/route-paths';
import {
connectedNodeIds,
SYSTEMS_MAP_EDGES,
SYSTEMS_MAP_NODE_KINDS,
SYSTEMS_MAP_NODES,
type SystemsMapNode,
type SystemsMapNodeId,
} from './systems-map.model';
let systemsMapInstanceId = 0;
export interface SystemsMapNodeView {
readonly id: SystemsMapNodeId;
readonly kind: SystemsMapNode['kind'];
readonly routeId: SystemsMapNode['routeId'];
readonly x: number;
readonly y: number;
readonly label: string;
readonly summary: string;
readonly href: string;
readonly link: unknown[];
readonly relationship: string;
readonly accessibleName: string;
}
export interface SystemsMapEdgeView {
readonly from: SystemsMapNodeId;
readonly to: SystemsMapNodeId;
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
}
@Component({
selector: 'app-systems-map',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
templateUrl: './systems-map.html',
styleUrl: './systems-map.scss',
})
export class SystemsMap {
readonly heading = input('');
readonly intro = input('');
readonly headingLevel = input<2 | 3>(2);
private readonly navigation = inject(NavigationService);
private readonly localeService = inject(LocaleService);
private readonly router = inject(Router);
private readonly instanceId = systemsMapInstanceId++;
protected readonly headingId = `systems-map-heading-${this.instanceId}`;
protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`;
protected readonly svgDescId = `systems-map-svg-desc-${this.instanceId}`;
protected readonly listHeadingId = `systems-map-list-${this.instanceId}`;
protected readonly legendHeadingId = `systems-map-legend-${this.instanceId}`;
protected readonly activeNodeId = signal<SystemsMapNodeId | null>(null);
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].systemsMap);
protected readonly headingText = computed(() => {
const override = this.heading().trim();
return override.length > 0 ? override : this.copy().heading;
});
protected readonly introText = computed(() => {
const override = this.intro().trim();
return override.length > 0 ? override : this.copy().intro;
});
protected readonly nodes = computed(() => {
const locale = this.localeService.locale();
const copy = this.copy();
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale, copy.relationshipLabel));
});
protected readonly edges = computed((): readonly SystemsMapEdgeView[] => {
const byId = new Map(SYSTEMS_MAP_NODES.map((node) => [node.id, node]));
return SYSTEMS_MAP_EDGES.map((edge) => {
const from = byId.get(edge.from);
const to = byId.get(edge.to);
return {
from: edge.from,
to: edge.to,
x1: from?.x ?? 0,
y1: from?.y ?? 0,
x2: to?.x ?? 0,
y2: to?.y ?? 0,
};
});
});
protected readonly legendItems = computed(() => {
const legend = this.copy().legend;
return SYSTEMS_MAP_NODE_KINDS.map((kind) => ({
kind,
label: legend[kind],
}));
});
protected readonly activeReadout = computed(() => {
const activeId = this.activeNodeId();
if (!activeId) {
return '';
}
const node = this.nodes().find((entry) => entry.id === activeId);
return node ? `${node.label}: ${node.relationship}` : '';
});
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
void this.router.navigate(this.navigation.link(node.routeId));
}
protected onNodeEnter(id: SystemsMapNodeId): void {
this.activeNodeId.set(id);
}
protected onNodeLeave(id: SystemsMapNodeId): void {
if (this.activeNodeId() === id) {
this.activeNodeId.set(null);
}
}
protected isConnectedNode(id: SystemsMapNodeId): boolean {
const active = this.activeNodeId();
if (!active) {
return false;
}
return active === id || this.neighborSet(active).has(id);
}
protected isDimmedNode(id: SystemsMapNodeId): boolean {
const active = this.activeNodeId();
if (!active) {
return false;
}
return active !== id && !this.neighborSet(active).has(id);
}
protected isConnectedEdge(edge: SystemsMapEdgeView): boolean {
const active = this.activeNodeId();
if (!active) {
return false;
}
return edge.from === active || edge.to === active;
}
protected isDimmedEdge(edge: SystemsMapEdgeView): boolean {
const active = this.activeNodeId();
if (!active) {
return false;
}
return edge.from !== active && edge.to !== active;
}
private toNodeView(
node: SystemsMapNode,
locale: AppLocale,
relationshipLabel: string,
): SystemsMapNodeView {
const connected = new Set(connectedNodeIds(node.id));
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
(entry) => entry.label[locale],
);
const relationship = relationshipLabel.replace('{targets}', connectedLabels.join(', '));
const label = node.label[locale];
const summary = node.summary[locale];
return {
id: node.id,
kind: node.kind,
routeId: node.routeId,
x: node.x,
y: node.y,
label,
summary,
href: routePath(node.routeId, locale),
link: this.navigation.link(node.routeId),
relationship,
accessibleName: `${label}. ${summary} ${relationship}`,
};
}
private neighborSet(id: SystemsMapNodeId): ReadonlySet<SystemsMapNodeId> {
return new Set(connectedNodeIds(id));
}
}