diff --git a/src/app/app.html b/src/app/app.html index c2ab832..a2f466b 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -47,6 +47,7 @@
+ gradient), + } as unknown as CanvasRenderingContext2D; +} + +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('DotBackground', () => { - let component: DotBackground; - let fixture: ComponentFixture; - - beforeEach(async () => { - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + afterEach(() => { + vi.restoreAllMocks(); + Reflect.deleteProperty(window, 'matchMedia'); + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }); + }); + async function createFixture(): Promise> { + TestBed.resetTestingModule(); await TestBed.configureTestingModule({ imports: [DotBackground], }).compileComponents(); - fixture = TestBed.createComponent(DotBackground); - component = fixture.componentInstance; + const fixture = TestBed.createComponent(DotBackground); + fixture.detectChanges(); await fixture.whenStable(); - }); + TestBed.inject(ApplicationRef).tick(); + fixture.detectChanges(); + return fixture; + } - afterEach(() => { - vi.restoreAllMocks(); - }); + it('should create', async () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); - it('should create', () => { - expect(component).toBeTruthy(); + const fixture = await createFixture(); + expect(fixture.componentInstance).toBeTruthy(); }); it('does not throw when destroyed after browser initialization', async () => { - const gradient = { addColorStop: vi.fn() }; - const context = { - clearRect: vi.fn(), - beginPath: vi.fn(), - arc: vi.fn(), - fill: vi.fn(), - createRadialGradient: vi.fn(() => gradient), - }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext()); + vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( - context as unknown as CanvasRenderingContext2D, + 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 animationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0); + const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange'); - const initializedFixture = TestBed.createComponent(DotBackground); - initializedFixture.detectChanges(); - await initializedFixture.whenStable(); + expect(windowAdds.length).toBeGreaterThan(0); + expect(documentAdds.length).toBeGreaterThan(0); - expect(() => initializedFixture.destroy()).not.toThrow(); + fixture.destroy(); - animationFrameSpy.mockRestore(); - vi.restoreAllMocks(); + 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(); }); }); diff --git a/src/app/components/dot-background/dot-background.ts b/src/app/components/dot-background/dot-background.ts index f15136b..291ff8a 100644 --- a/src/app/components/dot-background/dot-background.ts +++ b/src/app/components/dot-background/dot-background.ts @@ -1,13 +1,18 @@ +import { DOCUMENT } from '@angular/common'; import { afterNextRender, Component, + DestroyRef, ElementRef, inject, NgZone, - OnDestroy, ViewChild, } from '@angular/core'; -import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser'; +import { + isBrowserPlatform, + prefersCoarsePointer, + prefersReducedMotion, +} from '../../core/platform/browser'; import { Dot } from '../../models/dot'; @Component({ @@ -15,21 +20,30 @@ import { Dot } from '../../models/dot'; imports: [], templateUrl: './dot-background.html', styleUrl: './dot-background.scss', + host: { + 'aria-hidden': 'true', + }, }) -export class DotBackground implements OnDestroy { +export class DotBackground { @ViewChild('canvas') canvasRef!: ElementRef; + private readonly document = inject(DOCUMENT); private readonly ngZone = inject(NgZone); - private readonly coarsePointer = prefersCoarsePointer(); + private readonly destroyRef = inject(DestroyRef); private readonly isBrowser = isBrowserPlatform(); + private readonly coarsePointer = prefersCoarsePointer(); + private readonly reducedMotion = prefersReducedMotion(); private ctx: CanvasRenderingContext2D | undefined; private dots: Dot[] = []; private mouse = { x: -1000, y: -1000 }; private animationId = 0; - private initialized = false; + private resizeFrameId = 0; + private loopActive = false; + private tornDown = false; + private readonly teardowns: Array<() => void> = []; - private readonly INIT_DOT_COUNT = 12; + private readonly MIN_DOT_COUNT = 8; private readonly MAX_DOT_COUNT = 100; private readonly MAX_DOT_COUNT_MOBILE = 40; private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6']; @@ -39,27 +53,34 @@ export class DotBackground implements OnDestroy { private ballSpawnNextColor = 0; constructor() { + this.destroyRef.onDestroy(() => this.teardown()); + afterNextRender(() => { this.init(); }); } - ngOnDestroy() { - if (!this.initialized) { + private view(): Window | null { + return this.document.defaultView; + } + + private init(): void { + if (this.tornDown || !this.isBrowser) { return; } - cancelAnimationFrame(this.animationId); + const view = this.view(); - if (this.isBrowser) { - window.removeEventListener('resize', this.resize); - window.removeEventListener('mousemove', this.onMouseMove); - window.removeEventListener('click', this.onMouseClick); + if (!view) { + return; + } + + const canvas = this.canvasRef?.nativeElement; + + if (!canvas) { + return; } - } - private init() { - const canvas = this.canvasRef.nativeElement; let ctx: CanvasRenderingContext2D | null = null; try { @@ -76,21 +97,113 @@ export class DotBackground implements OnDestroy { this.resize(); this.initDots(); - window.addEventListener('resize', this.resize); - window.addEventListener('mousemove', this.onMouseMove); - window.addEventListener('click', this.onMouseClick); + this.listen(view, 'resize', this.onResize); + this.listen(this.document, 'visibilitychange', this.onVisibilityChange); - this.initialized = true; - this.ngZone.runOutsideAngular(() => this.animate()); + if (!this.reducedMotion && !this.coarsePointer) { + this.listen(view, 'mousemove', this.onMouseMove); + this.listen(view, 'click', this.onMouseClick); + } + + if (this.reducedMotion) { + this.drawFrame(); + return; + } + + this.ngZone.runOutsideAngular(() => this.startLoop()); } - private resize = () => { - const canvas = this.canvasRef.nativeElement; - const width = window.innerWidth; - const height = window.innerHeight; + private listen(target: EventTarget, type: string, handler: EventListener): void { + target.addEventListener(type, handler); + this.teardowns.push(() => target.removeEventListener(type, handler)); + } - const dx = Math.abs(width - canvas.width) / width; - const dy = Math.abs(height - canvas.height) / height; + private requestFrame(callback: FrameRequestCallback): number { + const view = this.view(); + return view ? view.requestAnimationFrame(callback) : 0; + } + + private cancelFrame(id: number): void { + const view = this.view(); + + if (!view || id === 0) { + return; + } + + view.cancelAnimationFrame(id); + } + + private startLoop(): void { + if (this.loopActive || this.reducedMotion || this.tornDown) { + return; + } + + this.loopActive = true; + this.scheduleAnimate(); + } + + private stopLoop(): void { + this.loopActive = false; + this.cancelFrame(this.animationId); + this.animationId = 0; + } + + private scheduleAnimate(): void { + this.animationId = this.requestFrame(this.animate); + } + + private teardown(): void { + if (this.tornDown) { + return; + } + + this.tornDown = true; + this.stopLoop(); + this.cancelFrame(this.resizeFrameId); + this.resizeFrameId = 0; + + for (const dispose of this.teardowns) { + dispose(); + } + + this.teardowns.length = 0; + } + + private onResize = (): void => { + if (this.resizeFrameId !== 0) { + return; + } + + this.resizeFrameId = this.requestFrame(() => { + this.resizeFrameId = 0; + this.resize(); + }); + }; + + private onVisibilityChange = (): void => { + if (this.document.hidden) { + this.stopLoop(); + return; + } + + if (!this.reducedMotion) { + this.ngZone.runOutsideAngular(() => this.startLoop()); + } + }; + + private resize = (): void => { + const view = this.view(); + const canvas = this.canvasRef?.nativeElement; + + if (!view || !canvas) { + return; + } + + const width = view.innerWidth; + const height = view.innerHeight; + + const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width; + const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height; if (!this.coarsePointer || dy > 0.2 || dx > 0.05) { canvas.width = width; @@ -103,18 +216,35 @@ export class DotBackground implements OnDestroy { } }; - private onMouseMove = (e: MouseEvent) => { - const canvas = this.canvasRef.nativeElement; - this.mouse.x = (e.clientX / window.innerWidth) * canvas.width; - this.mouse.y = (e.clientY / window.innerHeight) * canvas.height; + private onMouseMove = (event: Event): void => { + if (!(event instanceof MouseEvent)) { + return; + } + + const view = this.view(); + const canvas = this.canvasRef?.nativeElement; + + if (!view || !canvas) { + return; + } + + this.mouse.x = (event.clientX / view.innerWidth) * canvas.width; + this.mouse.y = (event.clientY / view.innerHeight) * canvas.height; }; - private onMouseClick = () => { + private onMouseClick = (): void => { const dot = this.spawnDot(); dot.x = this.mouse.x; dot.y = this.mouse.y; }; + private targetDotCount(width: number, height: number): number { + const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT; + const area = Math.max(0, width) * Math.max(0, height); + const fromArea = Math.round(area / 20_000); + return Math.min(maxCount, Math.max(this.MIN_DOT_COUNT, fromArea)); + } + private spawnDot(): Dot { const dotId = this.ballSpawnId++; const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT; @@ -140,7 +270,7 @@ export class DotBackground implements OnDestroy { return dot; } - private populateDot(dot: Dot) { + private populateDot(dot: Dot): void { const { width, height } = this.canvasRef.nativeElement; dot.x = Math.random() * width; @@ -151,17 +281,29 @@ export class DotBackground implements OnDestroy { dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length]; } - private initDots() { - for (let i = 0; i < this.INIT_DOT_COUNT; i++) { + private initDots(): void { + const { width, height } = this.canvasRef.nativeElement; + const count = this.targetDotCount(width, height); + + for (let i = 0; i < count; i++) { this.spawnDot(); } } - private animate = () => { - const canvas = this.canvasRef.nativeElement; + private animate = (): void => { + this.animationId = 0; + this.drawFrame(); + + if (this.loopActive && !this.tornDown) { + this.scheduleAnimate(); + } + }; + + private drawFrame(): void { + const canvas = this.canvasRef?.nativeElement; const ctx = this.ctx; - if (!ctx) { + if (!canvas || !ctx) { return; } @@ -213,7 +355,5 @@ export class DotBackground implements OnDestroy { ctx.fillStyle = gradient; ctx.fill(); } - - this.animationId = requestAnimationFrame(this.animate); - }; + } } diff --git a/src/app/core/content/signature-copy.spec.ts b/src/app/core/content/signature-copy.spec.ts new file mode 100644 index 0000000..f72a929 --- /dev/null +++ b/src/app/core/content/signature-copy.spec.ts @@ -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)[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); + } + } + }); +}); diff --git a/src/app/core/content/signature-copy.ts b/src/app/core/content/signature-copy.ts new file mode 100644 index 0000000..1a87bc1 --- /dev/null +++ b/src/app/core/content/signature-copy.ts @@ -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; + 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 = { + 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', + }, + }, + }, +}; diff --git a/src/app/shared/command-palette/command-palette.html b/src/app/shared/command-palette/command-palette.html new file mode 100644 index 0000000..6eccd81 --- /dev/null +++ b/src/app/shared/command-palette/command-palette.html @@ -0,0 +1,86 @@ + + +@if (open()) { +
+ + +
+} diff --git a/src/app/shared/command-palette/command-palette.scss b/src/app/shared/command-palette/command-palette.scss new file mode 100644 index 0000000..16bec26 --- /dev/null +++ b/src/app/shared/command-palette/command-palette.scss @@ -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; + } +} diff --git a/src/app/shared/command-palette/command-palette.spec.ts b/src/app/shared/command-palette/command-palette.spec.ts new file mode 100644 index 0000000..f1511d5 --- /dev/null +++ b/src/app/shared/command-palette/command-palette.spec.ts @@ -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> { + 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): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + TestBed.inject(ApplicationRef).tick(); + fixture.detectChanges(); + await fixture.whenStable(); + } + + function trigger(fixture: ComponentFixture): HTMLButtonElement { + return fixture.nativeElement.querySelector('.command-palette-trigger'); + } + + function dialog(fixture: ComponentFixture): HTMLElement | null { + return fixture.nativeElement.querySelector('[role="dialog"]'); + } + + async function openViaTrigger(fixture: ComponentFixture): Promise { + trigger(fixture).click(); + await flush(fixture); + } + + async function submitQuery( + fixture: ComponentFixture, + value: string, + ): Promise { + 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): string { + return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? ''; + } + + function focusable(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll( + '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); + }); +}); diff --git a/src/app/shared/command-palette/command-palette.ts b/src/app/shared/command-palette/command-palette.ts new file mode 100644 index 0000000..2a26bf6 --- /dev/null +++ b/src/app/shared/command-palette/command-palette.ts @@ -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>('trigger'); + private readonly inputRef = viewChild>('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([]); + + 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( + '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(); + }; +} diff --git a/src/app/shared/command-palette/commands.spec.ts b/src/app/shared/command-palette/commands.spec.ts new file mode 100644 index 0000000..8ed8d7f --- /dev/null +++ b/src/app/shared/command-palette/commands.spec.ts @@ -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()', + '', + '__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')); + }); +}); diff --git a/src/app/shared/command-palette/commands.ts b/src/app/shared/command-palette/commands.ts new file mode 100644 index 0000000..e38674a --- /dev/null +++ b/src/app/shared/command-palette/commands.ts @@ -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; + 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 { + const lookup = new Map(); + + 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), + ); + }); +} diff --git a/src/app/shared/motion/_reveal.scss b/src/app/shared/motion/_reveal.scss new file mode 100644 index 0000000..902e1a9 --- /dev/null +++ b/src/app/shared/motion/_reveal.scss @@ -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; + } + } +} diff --git a/src/app/shared/motion/metric-bar/metric-bar.html b/src/app/shared/motion/metric-bar/metric-bar.html new file mode 100644 index 0000000..54e5de5 --- /dev/null +++ b/src/app/shared/motion/metric-bar/metric-bar.html @@ -0,0 +1,20 @@ +
+
+ {{ label() }} + {{ displayValue() }} +
+ @if (description(); as descriptionText) { +

{{ descriptionText }}

+ } +
+
+
+
diff --git a/src/app/shared/motion/metric-bar/metric-bar.scss b/src/app/shared/motion/metric-bar/metric-bar.scss new file mode 100644 index 0000000..4d86e91 --- /dev/null +++ b/src/app/shared/motion/metric-bar/metric-bar.scss @@ -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; + } +} diff --git a/src/app/shared/motion/metric-bar/metric-bar.spec.ts b/src/app/shared/motion/metric-bar/metric-bar.spec.ts new file mode 100644 index 0000000..2708436 --- /dev/null +++ b/src/app/shared/motion/metric-bar/metric-bar.spec.ts @@ -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> { + 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'); + }); +}); diff --git a/src/app/shared/motion/metric-bar/metric-bar.ts b/src/app/shared/motion/metric-bar/metric-bar.ts new file mode 100644 index 0000000..1462774 --- /dev/null +++ b/src/app/shared/motion/metric-bar/metric-bar.ts @@ -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(); + readonly value = input.required(); + readonly max = input(100); + readonly valueText = input(undefined); + readonly description = input(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())); +} diff --git a/src/app/shared/motion/reveal.directive.spec.ts b/src/app/shared/motion/reveal.directive.spec.ts new file mode 100644 index 0000000..b455495 --- /dev/null +++ b/src/app/shared/motion/reveal.directive.spec.ts @@ -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: `
Reveal host
`, +}) +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> { + 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): 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(); + }); +}); diff --git a/src/app/shared/motion/reveal.directive.ts b/src/app/shared/motion/reveal.directive.ts new file mode 100644 index 0000000..1628671 --- /dev/null +++ b/src/app/shared/motion/reveal.directive.ts @@ -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); + 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; + } +} diff --git a/src/app/shared/systems-map/systems-map.html b/src/app/shared/systems-map/systems-map.html new file mode 100644 index 0000000..bd8bc77 --- /dev/null +++ b/src/app/shared/systems-map/systems-map.html @@ -0,0 +1,87 @@ +
+ @if (headingLevel() === 3) { +

{{ headingText() }}

+ } @else { +

{{ headingText() }}

+ } +

{{ introText() }}

+ +
+ + {{ headingText() }} + {{ copy().diagramDescription }} + @for (edge of edges(); track edge.from + '-' + edge.to) { + + } + @for (node of nodes(); track node.id) { + + @if (node.kind === 'ai' || node.kind === 'cluster') { + + } @else { + + } + + + } + +
+ +
+ +

{{ copy().legendHeading }}

+ + + +
diff --git a/src/app/shared/systems-map/systems-map.model.ts b/src/app/shared/systems-map/systems-map.model.ts new file mode 100644 index 0000000..1ff8498 --- /dev/null +++ b/src/app/shared/systems-map/systems-map.model.ts @@ -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; + readonly summary: Record; + 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; +} diff --git a/src/app/shared/systems-map/systems-map.scss b/src/app/shared/systems-map/systems-map.scss new file mode 100644 index 0000000..f210913 --- /dev/null +++ b/src/app/shared/systems-map/systems-map.scss @@ -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; + } +} diff --git a/src/app/shared/systems-map/systems-map.spec.ts b/src/app/shared/systems-map/systems-map.spec.ts new file mode 100644 index 0000000..299a5f3 --- /dev/null +++ b/src/app/shared/systems-map/systems-map.spec.ts @@ -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> { + 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); + }); + }); +}); diff --git a/src/app/shared/systems-map/systems-map.ts b/src/app/shared/systems-map/systems-map.ts new file mode 100644 index 0000000..82048ac --- /dev/null +++ b/src/app/shared/systems-map/systems-map.ts @@ -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(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 { + return new Set(connectedNodeIds(id)); + } +}