diff --git a/e2e/keyboard.e2e.ts b/e2e/keyboard.e2e.ts index 3570c90..836e36c 100644 --- a/e2e/keyboard.e2e.ts +++ b/e2e/keyboard.e2e.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { pagePath } from './helpers'; -test.describe('keyboard and palette', () => { +test.describe('keyboard and terminal dock', () => { test('skip link is first and moves focus to main', async ({ page }) => { await page.goto('/'); await page.keyboard.press('Tab'); @@ -87,37 +87,32 @@ test.describe('keyboard and palette', () => { const headerBox = await page.locator('.site-header').boundingBox(); expect(headerBox, 'header while submenu is open').toBeTruthy(); expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200); + + const background = await firstChild.evaluate((element) => { + const submenu = element.closest('.submenu'); + return submenu ? getComputedStyle(submenu).backgroundColor : ''; + }); + expect(background, 'submenu background must be fully opaque').toMatch( + /^rgb\(\d+,\s*\d+,\s*\d+\)$/, + ); }); - test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({ + test('terminal dock opens with Control+K without locking the page and restores on Escape', async ({ page, }) => { await page.goto('/'); - const trigger = page.locator('.command-palette-trigger'); + const trigger = page.locator('.terminal-dock-trigger'); await trigger.focus(); await page.keyboard.press('Control+k'); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - await expect(page.locator('.site')).toHaveAttribute('inert', ''); - expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden'); - - const focusable = dialog.locator( - 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])', - ); - const count = await focusable.count(); - const first = focusable.first(); - const last = focusable.nth(count - 1); - await last.focus(); - await page.keyboard.press('Tab'); - await expect(first).toBeFocused(); - await page.keyboard.press('Shift+Tab'); - await expect(last).toBeFocused(); + const panel = page.locator('.terminal-dock-panel'); + await expect(panel).toBeVisible(); + await expect(page.locator('.site')).not.toHaveAttribute('inert'); + expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden'); + await expect(page.locator('[role="dialog"]')).toHaveCount(0); await page.keyboard.press('Escape'); - await expect(dialog).toHaveCount(0); - await expect(page.locator('.site')).not.toHaveAttribute('inert'); - expect(await page.evaluate(() => document.body.style.overflow)).toBe(''); + await expect(panel).toHaveCount(0); await expect(trigger).toBeFocused(); }); }); diff --git a/e2e/terminal.e2e.ts b/e2e/terminal.e2e.ts new file mode 100644 index 0000000..b27a68f --- /dev/null +++ b/e2e/terminal.e2e.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy'; + +test.describe('terminal dock', () => { + test('walks collapsed, expanded and maximized and runs the grammar', async ({ + page, + }, testInfo) => { + const copy = SIGNATURE_COPY.de.terminal; + await page.goto('/'); + + const trigger = page.locator('.terminal-dock-trigger'); + const panel = page.locator('.terminal-dock-panel'); + const input = page.locator('#terminal-dock-input'); + const log = page.locator('.terminal-dock-log'); + const maximize = page.locator('.terminal-dock-control[aria-pressed]'); + + await expect(trigger).toBeVisible(); + await expect(panel).toHaveCount(0); + + await trigger.click(); + await expect(panel).toBeVisible(); + await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt); + + if (testInfo.project.name === 'mobile') { + const box = await panel.boundingBox(); + expect(box, 'bottom-sheet panel').toBeTruthy(); + expect(box!.width).toBeGreaterThan(300); + } else { + await maximize.click(); + await expect(maximize).toHaveAttribute('aria-pressed', 'true'); + await maximize.click(); + await expect(maximize).toHaveAttribute('aria-pressed', 'false'); + } + + await input.fill('navigate pitch'); + await input.press('Enter'); + await page.waitForURL('**/pitch'); + await expect(page).toHaveURL(/\/pitch$/); + await expect(log).toContainText(`${copy.prompt} navigate pitch`); + + await input.press('ArrowUp'); + await expect(input).toHaveValue('navigate pitch'); + + await input.fill('history'); + await input.press('Enter'); + await expect(log).toContainText(copy.historyIntro); + await expect(log).toContainText('navigate pitch'); + + await input.fill('clear'); + await input.press('Enter'); + await expect(log).toHaveText(copy.clearedMessage); + + await input.fill('navigate p'); + await input.press('Tab'); + await expect(log).toContainText('pitch'); + await expect(log).toContainText('projects'); + + await input.fill('navigate nowhere'); + await input.press('Enter'); + await expect(log).toContainText(copy.validTargetsLabel); + await expect(log).toContainText('pitch'); + + await input.fill('nav'); + await input.press('Tab'); + await expect(input).toHaveValue('navigate'); + }); +}); diff --git a/src/app/core/commands/command-ids.ts b/src/app/core/commands/command-ids.ts index 6e28ab2..95abb39 100644 --- a/src/app/core/commands/command-ids.ts +++ b/src/app/core/commands/command-ids.ts @@ -1,24 +1,10 @@ -export type CommandId = - | 'help' - | 'projects' - | 'servicesAi' - | 'cv' - | 'contact' - | 'brew' - | 'ignite' - | 'rev' - | 'clear' - | 'close'; +export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate'; export const COMMAND_IDS: readonly CommandId[] = [ 'help', - 'projects', - 'servicesAi', - 'cv', - 'contact', - 'brew', - 'ignite', - 'rev', + 'history', 'clear', - 'close', + 'brew', + 'rev', + 'navigate', ]; diff --git a/src/app/core/content/signature-copy.spec.ts b/src/app/core/content/signature-copy.spec.ts index d7d1fcd..fa059a2 100644 --- a/src/app/core/content/signature-copy.spec.ts +++ b/src/app/core/content/signature-copy.spec.ts @@ -58,7 +58,7 @@ describe('SIGNATURE_COPY', () => { it('describes every CommandId in both locales', () => { for (const locale of APP_LOCALES) { - const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions; + const descriptions = SIGNATURE_COPY[locale].terminal.commandDescriptions; expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort()); diff --git a/src/app/core/content/signature-copy.ts b/src/app/core/content/signature-copy.ts index 763163e..c0a6129 100644 --- a/src/app/core/content/signature-copy.ts +++ b/src/app/core/content/signature-copy.ts @@ -2,27 +2,34 @@ import { type CommandId } from '../commands/command-ids'; import { type AppLocale } from '../i18n/locale'; export interface SignatureCopy { - readonly palette: { + readonly terminal: { readonly triggerLabel: string; readonly shortcutHint: string; readonly shortcutHintApple: string; - readonly dialogTitle: string; - readonly dialogDescription: string; + readonly prompt: string; + readonly panelLabel: string; readonly inputLabel: string; readonly inputPlaceholder: string; - readonly closeLabel: string; - readonly suggestionsLabel: string; + readonly collapseLabel: string; + readonly expandLabel: string; + readonly restoreLabel: string; + readonly maximizeLabel: string; readonly outputLabel: string; readonly emptySuggestions: string; readonly unknownCommand: string; + readonly unknownTarget: string; + readonly validTargetsLabel: string; readonly helpIntro: string; + readonly helpGrammar: string; + readonly historyEmpty: string; + readonly historyIntro: string; readonly clearedMessage: string; readonly cvOpened: string; readonly navigating: string; + readonly incompleteHint: string; readonly commandDescriptions: Record; readonly responses: { readonly brew: string; - readonly ignite: string; readonly rev: string; }; }; @@ -45,39 +52,41 @@ export interface SignatureCopy { export const SIGNATURE_COPY: Record = { de: { - palette: { - triggerLabel: 'Befehle öffnen', + terminal: { + triggerLabel: 'Terminal', shortcutHint: 'Strg+K', shortcutHintApple: '⌘K', - dialogTitle: 'Befehle', - dialogDescription: - 'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.', + prompt: 'visitor@antoniolede:~$', + panelLabel: 'Terminal', inputLabel: 'Befehl', inputPlaceholder: 'Befehl eingeben', - closeLabel: 'Schließen', - suggestionsLabel: 'Vorschläge', + collapseLabel: 'Terminal einklappen', + expandLabel: 'Terminal öffnen', + restoreLabel: 'Terminal verkleinern', + maximizeLabel: 'Terminal vergrößern', outputLabel: 'Ausgabe', emptySuggestions: 'Keine passenden Befehle.', unknownCommand: 'Unbekannter Befehl: {command}', + unknownTarget: 'Unbekanntes Ziel: {target}', + validTargetsLabel: 'Gültige Ziele:', helpIntro: 'Verfügbare Befehle:', + helpGrammar: 'Grammatik: help | history | clear | brew | rev | navigate []', + historyEmpty: 'In dieser Sitzung wurde noch kein Befehl eingegeben.', + historyIntro: 'Eingegebene Befehle:', clearedMessage: 'Ausgabe geleert.', cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.', navigating: 'Wechsel zu {target}.', + incompleteHint: 'Unvollständiger Befehl. Mögliche Ziele:', 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.', + help: 'Listet die Grammatik und jeden Befehl.', + history: 'Listet die in dieser Sitzung eingegebenen Befehle.', + clear: 'Leert das Ausgabebuch.', 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.', + navigate: 'Wechselt zu einer bekannten Seite oder öffnet den Lebenslauf.', }, 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.', }, }, @@ -99,38 +108,41 @@ export const SIGNATURE_COPY: Record = { }, }, en: { - palette: { - triggerLabel: 'Open commands', + terminal: { + triggerLabel: 'Terminal', shortcutHint: 'Ctrl+K', shortcutHintApple: '⌘K', - dialogTitle: 'Commands', - dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.', + prompt: 'visitor@antoniolede:~$', + panelLabel: 'Terminal', inputLabel: 'Command', inputPlaceholder: 'Type a command', - closeLabel: 'Close', - suggestionsLabel: 'Suggestions', + collapseLabel: 'Collapse the terminal', + expandLabel: 'Open the terminal', + restoreLabel: 'Restore the terminal', + maximizeLabel: 'Maximise the terminal', outputLabel: 'Output', emptySuggestions: 'No matching commands.', unknownCommand: 'Unknown command: {command}', + unknownTarget: 'Unknown target: {target}', + validTargetsLabel: 'Valid targets:', helpIntro: 'Available commands:', + helpGrammar: 'Grammar: help | history | clear | brew | rev | navigate []', + historyEmpty: 'No commands have been entered in this session.', + historyIntro: 'Entered commands:', clearedMessage: 'Output cleared.', cvOpened: 'Opened the CV in a new tab.', navigating: 'Going to {target}.', + incompleteHint: 'Incomplete command. Possible targets:', 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.', + help: 'Lists the grammar and every command.', + history: 'Lists the commands entered in this session.', + clear: 'Clears the output log.', brew: 'A short playful acknowledgement.', - ignite: 'A short playful acknowledgement.', rev: 'A short playful acknowledgement.', - clear: 'Clears the output.', - close: 'Closes the command palette.', + navigate: 'Goes to a known page or opens the curriculum vitae.', }, 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.', }, }, diff --git a/src/app/core/platform/browser.ts b/src/app/core/platform/browser.ts index 9200c02..3c7a44f 100644 --- a/src/app/core/platform/browser.ts +++ b/src/app/core/platform/browser.ts @@ -19,7 +19,7 @@ export function viewportMatches(query: string): boolean { /** * Keyboard-labelling exception: there is no CSS media query for the Command key. - * Used only to swap the palette shortcut hint after hydration. + * Used only to swap the terminal shortcut hint after hydration. */ export function isApplePlatform(): boolean { if (!isBrowserPlatform()) { diff --git a/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.html b/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.html deleted file mode 100644 index d70949d..0000000 --- a/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.html +++ /dev/null @@ -1,13 +0,0 @@ - diff --git a/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.scss b/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.scss deleted file mode 100644 index 2b70abe..0000000 --- a/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.scss +++ /dev/null @@ -1,32 +0,0 @@ -: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); -} - -@media (hover: hover) and (pointer: fine) { - .command-palette-trigger:hover { - border-color: var(--surface-glass-border-strong); - } -} diff --git a/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.ts b/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.ts deleted file mode 100644 index a9828a6..0000000 --- a/src/app/shared/command-palette/command-palette-trigger/command-palette-trigger.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - afterNextRender, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - inject, - Injector, - signal, - viewChild, -} from '@angular/core'; -import { SIGNATURE_COPY } from '../../../core/content/signature-copy'; -import { LocaleService } from '../../../core/i18n/locale.service'; -import { isApplePlatform, isBrowserPlatform } from '../../../core/platform/browser'; -import { CommandPaletteService } from '../command-palette.service'; - -@Component({ - selector: 'app-command-palette-trigger', - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './command-palette-trigger.html', - styleUrl: './command-palette-trigger.scss', -}) -export class CommandPaletteTrigger { - private readonly palette = inject(CommandPaletteService); - private readonly localeService = inject(LocaleService); - private readonly injector = inject(Injector); - private readonly isBrowser = isBrowserPlatform(); - private readonly applePlatform = isApplePlatform(); - - protected readonly triggerRef = viewChild>('trigger'); - protected readonly useAppleHint = signal(false); - protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette); - protected readonly shortcutHint = computed(() => - this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint, - ); - protected readonly open = this.palette.open; - protected readonly dialogId = this.palette.dialogId; - - constructor() { - afterNextRender( - () => { - if (this.isBrowser) { - this.useAppleHint.set(this.applePlatform); - } - }, - { injector: this.injector }, - ); - } - - protected onTriggerClick(): void { - const trigger = this.triggerRef()?.nativeElement; - trigger?.focus(); - this.palette.openPalette(trigger ?? null); - } -} diff --git a/src/app/shared/command-palette/command-palette.html b/src/app/shared/command-palette/command-palette.html deleted file mode 100644 index 935d1d0..0000000 --- a/src/app/shared/command-palette/command-palette.html +++ /dev/null @@ -1,72 +0,0 @@ -@if (open()) { -
- - -
-} diff --git a/src/app/shared/command-palette/command-palette.scss b/src/app/shared/command-palette/command-palette.scss deleted file mode 100644 index b80a8f4..0000000 --- a/src/app/shared/command-palette/command-palette.scss +++ /dev/null @@ -1,144 +0,0 @@ -:host { - display: contents; -} - -.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-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.service.ts b/src/app/shared/command-palette/command-palette.service.ts deleted file mode 100644 index e757c85..0000000 --- a/src/app/shared/command-palette/command-palette.service.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { DOCUMENT, isPlatformBrowser } from '@angular/common'; -import { - afterNextRender, - DestroyRef, - inject, - Injectable, - Injector, - PLATFORM_ID, - signal, -} from '@angular/core'; - -@Injectable({ providedIn: 'root' }) -export class CommandPaletteService { - private readonly document = inject(DOCUMENT); - private readonly destroyRef = inject(DestroyRef); - private readonly injector = inject(Injector); - private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - - readonly open = signal(false); - readonly dialogId = 'command-palette-dialog'; - readonly titleId = 'command-palette-title'; - readonly descriptionId = 'command-palette-description'; - readonly inputId = 'command-palette-input'; - readonly suggestionsId = 'command-palette-suggestions'; - - private opener: HTMLElement | null = null; - private previousOverflow = ''; - private scrollLocked = false; - - constructor() { - afterNextRender( - () => { - if (!this.isBrowser) { - return; - } - - this.document.addEventListener('keydown', this.onDocumentKeydown); - }, - { injector: this.injector }, - ); - - this.destroyRef.onDestroy(() => this.teardown()); - } - - openPalette(opener?: HTMLElement | null): void { - if (this.open()) { - return; - } - - const active = opener ?? this.document.activeElement; - this.opener = active instanceof HTMLElement ? active : null; - this.lockScroll(); - this.open.set(true); - } - - close(): void { - if (!this.open()) { - return; - } - - this.open.set(false); - this.unlockScroll(); - this.restoreFocus(); - } - - private restoreFocus(): void { - const opener = this.opener; - this.opener = null; - - afterNextRender( - () => { - if (opener instanceof HTMLElement && opener.isConnected) { - opener.focus(); - } - }, - { injector: this.injector }, - ); - } - - private lockScroll(): void { - if (!this.isBrowser || this.scrollLocked) { - return; - } - - this.previousOverflow = this.document.body.style.overflow; - this.document.body.style.overflow = 'hidden'; - this.scrollLocked = true; - } - - private unlockScroll(): void { - if (!this.isBrowser || !this.scrollLocked) { - return; - } - - this.document.body.style.overflow = this.previousOverflow; - this.previousOverflow = ''; - this.scrollLocked = false; - } - - private teardown(): void { - this.document.removeEventListener('keydown', this.onDocumentKeydown); - this.unlockScroll(); - this.open.set(false); - this.opener = null; - } - - 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/command-palette.spec.ts b/src/app/shared/command-palette/command-palette.spec.ts deleted file mode 100644 index 97d1800..0000000 --- a/src/app/shared/command-palette/command-palette.spec.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { DOCUMENT } from '@angular/common'; -import { ApplicationRef, Component, inject, 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 { CommandPaletteTrigger } from './command-palette-trigger/command-palette-trigger'; -import { CommandPaletteService } from './command-palette.service'; -import { COMMAND_IDS, COMMANDS } from './commands'; - -@Component({ - selector: 'app-palette-host', - imports: [CommandPaletteTrigger, CommandPalette], - template: ` -
- -
- - `, -}) -class PaletteHost { - readonly palette = inject(CommandPaletteService); -} - -describe('CommandPalette', () => { - afterEach(() => { - document.body.style.overflow = ''; - vi.restoreAllMocks(); - }); - - async function createFixture( - locale: AppLocale = 'de', - extraProviders: { provide: unknown; useValue: unknown }[] = [], - ): Promise> { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [PaletteHost], - providers: [provideRouter([]), ...extraProviders], - }).compileComponents(); - - TestBed.inject(LocaleService).setLocale(locale); - const fixture = TestBed.createComponent(PaletteHost); - 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"]'); - } - - function site(fixture: ComponentFixture): HTMLElement { - return fixture.nativeElement.querySelector('.site'); - } - - 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)( - 'marks .site inert while open and removes it on close (%s)', - async (locale) => { - const fixture = await createFixture(locale); - expect(site(fixture).hasAttribute('inert')).toBe(false); - - await openViaTrigger(fixture); - expect(site(fixture).hasAttribute('inert')).toBe(true); - - fixture.nativeElement.querySelector('.command-palette-close').click(); - await flush(fixture); - expect(site(fixture).hasAttribute('inert')).toBe(false); - }, - ); - - it.each(APP_LOCALES)( - 'locks body overflow while open and restores it on close (%s)', - async (locale) => { - document.body.style.overflow = 'auto'; - const fixture = await createFixture(locale); - await openViaTrigger(fixture); - expect(document.body.style.overflow).toBe('hidden'); - - fixture.nativeElement.querySelector('.command-palette-close').click(); - await flush(fixture); - expect(document.body.style.overflow).toBe('auto'); - document.body.style.overflow = ''; - }, - ); - - 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')); - expect(dialog(fixture)).toBeNull(); - - 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(); - TestBed.resetTestingModule(); - - 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); - }); - - it('keeps the trigger aria-label stable and shows the Ctrl hint before hydration', async () => { - const fixture = await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]); - const button = trigger(fixture); - expect(button.getAttribute('aria-label')).toBe(SIGNATURE_COPY.de.palette.triggerLabel); - expect(button.querySelector('kbd')?.textContent).toBe(SIGNATURE_COPY.de.palette.shortcutHint); - }); -}); diff --git a/src/app/shared/command-palette/command-palette.ts b/src/app/shared/command-palette/command-palette.ts deleted file mode 100644 index 4618adb..0000000 --- a/src/app/shared/command-palette/command-palette.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { DOCUMENT } from '@angular/common'; -import { - afterNextRender, - ChangeDetectionStrategy, - Component, - computed, - effect, - 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 { CommandPaletteService } from './command-palette.service'; -import { - COMMAND_IDS, - COMMANDS, - parseCommand, - suggestCommands, - type CommandDefinition, -} from './commands'; - -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 router = inject(Router); - private readonly navigation = inject(NavigationService); - private readonly localeService = inject(LocaleService); - private readonly palette = inject(CommandPaletteService); - private readonly isBrowser = isBrowserPlatform(); - - private readonly inputRef = viewChild>('commandInput'); - - protected readonly dialogId = this.palette.dialogId; - protected readonly titleId = this.palette.titleId; - protected readonly descriptionId = this.palette.descriptionId; - protected readonly inputId = this.palette.inputId; - protected readonly suggestionsId = this.palette.suggestionsId; - protected readonly open = this.palette.open; - - 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()), - ); - - constructor() { - effect(() => { - if (!this.open()) { - return; - } - - afterNextRender( - () => { - this.inputRef()?.nativeElement.focus(); - }, - { injector: this.injector }, - ); - }); - } - - 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; - } - } - } - - protected closePalette(): void { - this.palette.close(); - } - - 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 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); - } -} diff --git a/src/app/shared/command-palette/commands.spec.ts b/src/app/shared/command-palette/commands.spec.ts deleted file mode 100644 index 8ed8d7f..0000000 --- a/src/app/shared/command-palette/commands.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index fad7a7f..0000000 --- a/src/app/shared/command-palette/commands.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { type CommandId } from '../../core/commands/command-ids'; -import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale'; -import { type RouteId } from '../../core/routing/route-ids'; - -export { COMMAND_IDS, type CommandId } from '../../core/commands/command-ids'; - -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/terminal/terminal-commands.spec.ts b/src/app/shared/terminal/terminal-commands.spec.ts new file mode 100644 index 0000000..5a0e197 --- /dev/null +++ b/src/app/shared/terminal/terminal-commands.spec.ts @@ -0,0 +1,91 @@ +import { + applyCompletion, + isUnsafeNavigateTarget, + navigateTargetKeys, + parseCommand, + suggestCompletions, +} from './terminal-commands'; + +describe('terminal command grammar', () => { + it('parses help, history, clear, brew and rev', () => { + expect(parseCommand('help')).toEqual({ kind: 'help' }); + expect(parseCommand('history')).toEqual({ kind: 'history' }); + expect(parseCommand('clear')).toEqual({ kind: 'clear' }); + expect(parseCommand('brew')).toEqual({ kind: 'brew' }); + expect(parseCommand('rev')).toEqual({ kind: 'rev' }); + expect(parseCommand(' Hilfe ')).toEqual({ kind: 'help' }); + expect(parseCommand('')).toEqual({ kind: 'empty' }); + }); + + it('resolves navigate targets including the services ai sub-target', () => { + expect(parseCommand('navigate home')).toEqual({ + kind: 'navigate', + resolution: { kind: 'route', routeId: 'home' }, + targetKey: 'home', + }); + expect(parseCommand('navigate pitch')).toEqual({ + kind: 'navigate', + resolution: { kind: 'route', routeId: 'pitch' }, + targetKey: 'pitch', + }); + expect(parseCommand('navigate services ai')).toEqual({ + kind: 'navigate', + resolution: { kind: 'route', routeId: 'servicesAi' }, + targetKey: 'services ai', + }); + expect(parseCommand('navigate leistungen software')).toEqual({ + kind: 'navigate', + resolution: { kind: 'route', routeId: 'servicesSoftware' }, + targetKey: 'services software', + }); + expect(parseCommand('navigate cv')).toEqual({ + kind: 'navigate', + resolution: { kind: 'cv' }, + targetKey: 'cv', + }); + }); + + it('suggests valid targets for incomplete navigate input', () => { + const parsed = parseCommand('navigate'); + expect(parsed.kind).toBe('incomplete'); + if (parsed.kind === 'incomplete') { + expect(parsed.suggestions).toEqual([...navigateTargetKeys()]); + } + + const unknown = parseCommand('navigate nowhere'); + expect(unknown.kind).toBe('unknown-target'); + if (unknown.kind === 'unknown-target') { + expect(unknown.target).toBe('nowhere'); + } + }); + + it('completes the current token against commands and navigate targets', () => { + expect(suggestCompletions('he')).toEqual(['help']); + expect(applyCompletion('he')).toEqual({ value: 'help', candidates: ['help'] }); + expect(suggestCompletions('navigate p')).toEqual(['pitch', 'projects', 'projekte']); + expect(applyCompletion('navigate pit')).toEqual({ + value: 'navigate pitch', + candidates: ['pitch'], + }); + expect(suggestCompletions('navigate services a')).toEqual(['services ai']); + }); + + it('rejects unsafe navigate targets without treating them as routes', () => { + const hostile = [ + 'navigate https://example.com', + 'navigate ../secret', + 'navigate /etc/passwd', + 'navigate javascript:alert(1)', + ]; + + for (const input of hostile) { + const target = input.slice('navigate '.length); + expect(isUnsafeNavigateTarget(target), input).toBe(true); + expect(parseCommand(input)).toEqual({ + kind: 'unknown-target', + input, + target, + }); + } + }); +}); diff --git a/src/app/shared/terminal/terminal-commands.ts b/src/app/shared/terminal/terminal-commands.ts new file mode 100644 index 0000000..1578b59 --- /dev/null +++ b/src/app/shared/terminal/terminal-commands.ts @@ -0,0 +1,234 @@ +import { COMMAND_IDS, type CommandId } from '../../core/commands/command-ids'; +import { type RouteId } from '../../core/routing/route-ids'; + +export { COMMAND_IDS, type CommandId }; + +export type NavigateResolution = + { readonly kind: 'route'; readonly routeId: RouteId } | { readonly kind: 'cv' }; + +export interface NavigateTarget { + readonly key: string; + readonly aliases: readonly string[]; + readonly resolution: NavigateResolution; +} + +export type ParsedCommand = + | { readonly kind: 'empty' } + | { readonly kind: 'help' } + | { readonly kind: 'history' } + | { readonly kind: 'clear' } + | { readonly kind: 'brew' } + | { readonly kind: 'rev' } + | { + readonly kind: 'navigate'; + readonly resolution: NavigateResolution; + readonly targetKey: string; + } + | { readonly kind: 'unknown-command'; readonly input: string } + | { readonly kind: 'unknown-target'; readonly input: string; readonly target: string } + | { + readonly kind: 'incomplete'; + readonly input: string; + readonly suggestions: readonly string[]; + }; + +export const NAVIGATE_TARGETS: readonly NavigateTarget[] = [ + { key: 'home', aliases: ['start', 'startseite'], resolution: { kind: 'route', routeId: 'home' } }, + { key: 'pitch', aliases: [], resolution: { kind: 'route', routeId: 'pitch' } }, + { key: 'services', aliases: ['leistungen'], resolution: { kind: 'route', routeId: 'services' } }, + { + key: 'services ai', + aliases: ['leistungen ai'], + resolution: { kind: 'route', routeId: 'servicesAi' }, + }, + { + key: 'services software', + aliases: ['leistungen software'], + resolution: { kind: 'route', routeId: 'servicesSoftware' }, + }, + { + key: 'services network', + aliases: ['leistungen network', 'leistungen netzwerk', 'services hardware'], + resolution: { kind: 'route', routeId: 'servicesHardwareNetwork' }, + }, + { + key: 'services clusters', + aliases: ['leistungen clusters', 'leistungen cluster'], + resolution: { kind: 'route', routeId: 'servicesClusters' }, + }, + { + key: 'projects', + aliases: ['projekte'], + resolution: { kind: 'route', routeId: 'projects' }, + }, + { key: 'stack', aliases: [], resolution: { kind: 'route', routeId: 'stack' } }, + { + key: 'about', + aliases: ['ueber-mich', 'über-mich'], + resolution: { kind: 'route', routeId: 'about' }, + }, + { + key: 'contact', + aliases: ['kontakt'], + resolution: { kind: 'route', routeId: 'contact' }, + }, + { key: 'cv', aliases: ['lebenslauf'], resolution: { kind: 'cv' } }, +]; + +const COMMAND_TOKENS: readonly string[] = [ + 'help', + 'hilfe', + 'history', + 'verlauf', + 'clear', + 'leeren', + 'brew', + 'brauen', + 'rev', + 'drehzahl', + 'navigate', + 'gehe', +]; + +const COMMAND_BY_TOKEN: ReadonlyMap> = new Map([ + ['help', 'help'], + ['hilfe', 'help'], + ['history', 'history'], + ['verlauf', 'history'], + ['clear', 'clear'], + ['leeren', 'clear'], + ['brew', 'brew'], + ['brauen', 'brew'], + ['rev', 'rev'], + ['drehzahl', 'rev'], +]); + +const UNSAFE_TARGET = /[:/\\]|\.\.|^\.|javascript\s*:|^https?$|^file$|^data$/i; + +export function normalizeCommandInput(raw: string): string { + return raw.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +export function navigateTargetKeys(): readonly string[] { + return NAVIGATE_TARGETS.map((target) => target.key); +} + +export function allNavigateTokens(): readonly string[] { + return NAVIGATE_TARGETS.flatMap((target) => [target.key, ...target.aliases]); +} + +function resolveNavigateTarget(raw: string): NavigateTarget | undefined { + const normalized = normalizeCommandInput(raw); + return NAVIGATE_TARGETS.find( + (target) => target.key === normalized || target.aliases.includes(normalized), + ); +} + +export function isUnsafeNavigateTarget(raw: string): boolean { + const normalized = normalizeCommandInput(raw); + if (normalized.length === 0) { + return false; + } + + return ( + UNSAFE_TARGET.test(normalized) || + normalized.split(/\s+/).some((token) => UNSAFE_TARGET.test(token)) + ); +} + +export function parseCommand(raw: string): ParsedCommand { + const normalized = normalizeCommandInput(raw); + + if (normalized.length === 0) { + return { kind: 'empty' }; + } + + const [verb, ...rest] = normalized.split(' '); + const remainder = rest.join(' '); + + if (verb === 'navigate' || verb === 'gehe') { + if (isUnsafeNavigateTarget(remainder)) { + return { kind: 'unknown-target', input: raw, target: remainder }; + } + + if (remainder.length === 0) { + return { kind: 'incomplete', input: raw, suggestions: [...navigateTargetKeys()] }; + } + + const target = resolveNavigateTarget(remainder); + if (!target) { + return { kind: 'unknown-target', input: raw, target: remainder }; + } + + return { kind: 'navigate', resolution: target.resolution, targetKey: target.key }; + } + + if (rest.length === 0) { + const command = COMMAND_BY_TOKEN.get(verb); + if (command) { + return { kind: command }; + } + } + + return { kind: 'unknown-command', input: raw }; +} + +export function suggestCompletions(raw: string): readonly string[] { + const trimmedEnd = raw.replace(/\s+$/, ''); + const normalized = normalizeCommandInput(trimmedEnd); + const trailingSpace = raw.length > 0 && /\s$/.test(raw); + + if (normalized.length === 0) { + return [...COMMAND_IDS]; + } + + const tokens = normalized.split(' '); + const verb = tokens[0] ?? ''; + + if (verb === 'navigate' || verb === 'gehe') { + const targetSoFar = tokens.slice(1).join(' '); + const prefix = trailingSpace ? `${targetSoFar} `.trimStart() : targetSoFar; + const candidates = allNavigateTokens().filter((token) => + prefix.length === 0 ? true : token.startsWith(prefix), + ); + return [...new Set(candidates)]; + } + + if (tokens.length === 1 && !trailingSpace) { + const commandHits = COMMAND_TOKENS.filter((token) => token.startsWith(verb)); + const unique = new Set(); + for (const token of commandHits) { + if (token === 'navigate' || token === 'gehe') { + unique.add('navigate'); + } else { + const id = COMMAND_BY_TOKEN.get(token); + if (id) { + unique.add(id); + } + } + } + return [...unique]; + } + + return []; +} + +export function applyCompletion(raw: string): { + readonly value: string; + readonly candidates: readonly string[]; +} { + const candidates = suggestCompletions(raw); + if (candidates.length === 1) { + const normalized = normalizeCommandInput(raw); + const tokens = normalized.split(' ').filter((token) => token.length > 0); + const verb = tokens[0] ?? ''; + + if (verb === 'navigate' || verb === 'gehe') { + return { value: `navigate ${candidates[0]}`, candidates }; + } + + return { value: candidates[0], candidates }; + } + + return { value: raw, candidates }; +} diff --git a/src/app/shared/terminal/terminal-dock.html b/src/app/shared/terminal/terminal-dock.html new file mode 100644 index 0000000..f4e7faf --- /dev/null +++ b/src/app/shared/terminal/terminal-dock.html @@ -0,0 +1,71 @@ +
+ + + @if (open()) { +
+
+

{{ copy().panelLabel }}

+ + +
+ +
+ @for (line of log(); track line.id) { +

{{ line.text }}

+ } +
+ +
+ + +
+
+ } +
diff --git a/src/app/shared/terminal/terminal-dock.scss b/src/app/shared/terminal/terminal-dock.scss new file mode 100644 index 0000000..492a04a --- /dev/null +++ b/src/app/shared/terminal/terminal-dock.scss @@ -0,0 +1,161 @@ +@use 'breakpoints' as bp; + +:host { + display: contents; +} + +.terminal-dock { + position: fixed; + z-index: 40; + inset-inline-end: var(--space-4); + inset-block-end: var(--space-4); + display: grid; + justify-items: end; + gap: var(--space-2); +} + +.terminal-dock-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: var(--color-surface-raised); + color: var(--color-text); + font: inherit; +} + +.terminal-dock-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); +} + +.terminal-dock-panel { + display: grid; + grid-template-rows: auto 1fr auto; + gap: var(--space-3); + width: 100%; + max-height: min(70vh, 32rem); + padding: var(--space-4); + border: 1px solid var(--surface-glass-border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-surface-raised); + box-shadow: var(--shadow-raised); + color: var(--color-text); +} + +.terminal-dock.is-open { + inset-inline: 0; + inset-block-end: 0; + justify-items: stretch; +} + +.terminal-dock.is-open .terminal-dock-trigger { + display: none; +} + +.is-maximized .terminal-dock-panel { + max-height: 90vh; +} + +.terminal-dock-title, +.terminal-dock-log, +.terminal-dock-log p, +.terminal-dock-form { + margin: 0; +} + +.terminal-dock-title { + font-size: var(--text-sm); + font-weight: 600; +} + +.terminal-dock-control { + 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; +} + +.terminal-dock-log { + overflow: auto; + min-height: 6rem; + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--color-text-muted); + white-space: pre-wrap; +} + +.terminal-dock-log .echo { + color: var(--color-text); +} + +.terminal-dock-form { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-2); +} + +.terminal-dock-prompt { + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--color-accent-cool); +} + +.terminal-dock-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); + color: var(--color-text); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +@media (hover: hover) and (pointer: fine) { + .terminal-dock-trigger:hover, + .terminal-dock-control:hover { + border-color: var(--surface-glass-border-strong); + } +} + +@media (prefers-reduced-motion: reduce) { + .terminal-dock, + .terminal-dock-panel { + transition: none; + } +} + +@include bp.respond-to(md) { + .terminal-dock.is-open { + inset-inline: auto var(--space-4); + inset-block-end: var(--space-4); + justify-items: end; + } + + .terminal-dock.is-open .terminal-dock-trigger { + display: inline-flex; + } + + .terminal-dock-panel { + width: min(28rem, calc(100vw - var(--space-8))); + max-height: min(22rem, 60vh); + border-radius: var(--radius-md); + } + + .is-maximized .terminal-dock-panel { + width: min(48rem, calc(100vw - var(--space-8))); + max-height: min(36rem, 80vh); + } +} diff --git a/src/app/shared/terminal/terminal-dock.service.ts b/src/app/shared/terminal/terminal-dock.service.ts new file mode 100644 index 0000000..a254431 --- /dev/null +++ b/src/app/shared/terminal/terminal-dock.service.ts @@ -0,0 +1,114 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + afterNextRender, + DestroyRef, + inject, + Injectable, + Injector, + PLATFORM_ID, + signal, +} from '@angular/core'; + +export type TerminalDockState = 'collapsed' | 'expanded' | 'maximized'; + +@Injectable({ providedIn: 'root' }) +export class TerminalDockService { + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + private readonly injector = inject(Injector); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + readonly state = signal('collapsed'); + readonly panelId = 'terminal-dock-panel'; + readonly inputId = 'terminal-dock-input'; + readonly logId = 'terminal-dock-log'; + + private trigger: HTMLElement | null = null; + + constructor() { + afterNextRender( + () => { + if (!this.isBrowser) { + return; + } + + this.document.addEventListener('keydown', this.onDocumentKeydown); + }, + { injector: this.injector }, + ); + + this.destroyRef.onDestroy(() => this.teardown()); + } + + registerTrigger(element: HTMLElement | null): void { + this.trigger = element; + } + + open(opener?: HTMLElement | null): void { + if (this.state() !== 'collapsed') { + return; + } + + if (opener instanceof HTMLElement) { + this.trigger = opener; + } + + this.state.set('expanded'); + } + + collapse(): void { + if (this.state() === 'collapsed') { + return; + } + + this.state.set('collapsed'); + this.restoreFocus(); + } + + toggle(opener?: HTMLElement | null): void { + if (this.state() === 'collapsed') { + this.open(opener); + return; + } + + this.collapse(); + } + + toggleMaximized(): void { + if (this.state() === 'collapsed') { + this.open(); + this.state.set('maximized'); + return; + } + + this.state.update((current) => (current === 'maximized' ? 'expanded' : 'maximized')); + } + + private restoreFocus(): void { + const trigger = this.trigger; + + afterNextRender( + () => { + if (trigger instanceof HTMLElement && trigger.isConnected) { + trigger.focus(); + } + }, + { injector: this.injector }, + ); + } + + private teardown(): void { + this.document.removeEventListener('keydown', this.onDocumentKeydown); + this.state.set('collapsed'); + this.trigger = null; + } + + private readonly onDocumentKeydown = (event: KeyboardEvent): void => { + if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) { + return; + } + + event.preventDefault(); + this.toggle(); + }; +} diff --git a/src/app/shared/terminal/terminal-dock.spec.ts b/src/app/shared/terminal/terminal-dock.spec.ts new file mode 100644 index 0000000..d034f71 --- /dev/null +++ b/src/app/shared/terminal/terminal-dock.spec.ts @@ -0,0 +1,232 @@ +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 { TerminalDock } from './terminal-dock'; +import { TerminalDockService } from './terminal-dock.service'; + +describe('TerminalDock', () => { + afterEach(() => { + document.body.style.overflow = ''; + vi.restoreAllMocks(); + }); + + async function createFixture( + locale: AppLocale = 'de', + extraProviders: { provide: unknown; useValue: unknown }[] = [], + ): Promise> { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [TerminalDock], + providers: [provideRouter([]), ...extraProviders], + }).compileComponents(); + + TestBed.inject(LocaleService).setLocale(locale); + const fixture = TestBed.createComponent(TerminalDock); + 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('.terminal-dock-trigger'); + } + + function panel(fixture: ComponentFixture): HTMLElement | null { + return fixture.nativeElement.querySelector('.terminal-dock-panel'); + } + + function input(fixture: ComponentFixture): HTMLInputElement | null { + return fixture.nativeElement.querySelector('input'); + } + + async function openViaTrigger(fixture: ComponentFixture): Promise { + trigger(fixture).click(); + await flush(fixture); + } + + async function submitQuery( + fixture: ComponentFixture, + value: string, + ): Promise { + const field = input(fixture); + expect(field).toBeTruthy(); + field!.value = value; + field!.dispatchEvent(new Event('input', { bubbles: true })); + fixture.nativeElement + .querySelector('form') + ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flush(fixture); + } + + function logText(fixture: ComponentFixture): string { + return fixture.nativeElement.querySelector('.terminal-dock-log')?.textContent ?? ''; + } + + it.each(APP_LOCALES)('renders the collapsed trigger on the server (%s)', async (locale) => { + const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]); + expect(trigger(fixture)).toBeTruthy(); + expect(panel(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); + expect(panel(fixture)).toBeTruthy(); + expect(document.activeElement).toBe(input(fixture)); + expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull(); + expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull(); + }); + + it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => { + const fixture = await createFixture(locale); + const dock = TestBed.inject(TerminalDockService); + expect(dock.state()).toBe('collapsed'); + + await openViaTrigger(fixture); + expect(dock.state()).toBe('expanded'); + + fixture.nativeElement.querySelector('[aria-pressed]')?.click(); + await flush(fixture); + expect(dock.state()).toBe('maximized'); + expect( + fixture.nativeElement.querySelector('[aria-pressed]')?.getAttribute('aria-pressed'), + ).toBe('true'); + + fixture.nativeElement.querySelector('[aria-pressed]')?.click(); + await flush(fixture); + expect(dock.state()).toBe('expanded'); + }); + + it.each(APP_LOCALES)('opens from Ctrl+K and Meta+K (%s)', async (locale) => { + const fixture = await createFixture(locale); + await flush(fixture); + + for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) { + if (panel(fixture)) { + input(fixture)?.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(panel(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); + input(fixture)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await flush(fixture); + + expect(panel(fixture)).toBeNull(); + expect(document.activeElement).toBe(trigger(fixture)); + }); + + it.each(APP_LOCALES)('does not lock scroll or mark the page inert (%s)', async (locale) => { + document.body.style.overflow = 'auto'; + const fixture = await createFixture(locale); + await openViaTrigger(fixture); + + expect(document.body.style.overflow).toBe('auto'); + expect(document.querySelector('.site')?.hasAttribute('inert')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('[aria-modal]')).toBeNull(); + document.body.style.overflow = ''; + }); + + it.each(APP_LOCALES)( + 'echoes the prompt, keeps the log and walks command history (%s)', + async (locale) => { + const fixture = await createFixture(locale); + await openViaTrigger(fixture); + const copy = SIGNATURE_COPY[locale].terminal; + + await submitQuery(fixture, 'help'); + const helpText = logText(fixture); + expect(helpText).toContain(`${copy.prompt} help`); + expect(helpText).toContain(copy.helpIntro); + expect(helpText).toContain(copy.helpGrammar); + + await submitQuery(fixture, 'brew'); + expect(logText(fixture)).toContain(copy.responses.brew); + expect(logText(fixture)).toContain(`${copy.prompt} help`); + + await submitQuery(fixture, 'rev'); + expect(logText(fixture)).toContain(copy.responses.rev); + + await submitQuery(fixture, 'history'); + expect(logText(fixture)).toContain(copy.historyIntro); + expect(logText(fixture)).toContain('help'); + + const field = input(fixture); + field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await flush(fixture); + expect(field?.value).toBe('history'); + + await submitQuery(fixture, 'clear'); + expect(logText(fixture).trim()).toBe(copy.clearedMessage); + }, + ); + + it.each(APP_LOCALES)('navigates a known target and opens the CV asset (%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); + const view = TestBed.inject(DOCUMENT).defaultView; + const open = vi.spyOn(view as Window, 'open').mockReturnValue(null); + + await openViaTrigger(fixture); + await submitQuery(fixture, 'navigate pitch'); + expect(navigate).toHaveBeenCalledWith(navigation.link('pitch')); + + await submitQuery(fixture, 'navigate cv'); + expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer'); + }); + + 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); + }); + + 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(); + TestBed.resetTestingModule(); + + expect(remove).toHaveBeenCalledWith('keydown', added?.[1]); + }); +}); diff --git a/src/app/shared/terminal/terminal-dock.ts b/src/app/shared/terminal/terminal-dock.ts new file mode 100644 index 0000000..1f0399c --- /dev/null +++ b/src/app/shared/terminal/terminal-dock.ts @@ -0,0 +1,279 @@ +import { DOCUMENT } from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + effect, + 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 { isApplePlatform, isBrowserPlatform } from '../../core/platform/browser'; +import { + applyCompletion, + COMMAND_IDS, + navigateTargetKeys, + parseCommand, + suggestCompletions, +} from './terminal-commands'; +import { TerminalDockService } from './terminal-dock.service'; + +interface LogLine { + readonly id: number; + readonly kind: 'echo' | 'output'; + readonly text: string; +} + +let terminalLogId = 0; + +@Component({ + selector: 'app-terminal-dock', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './terminal-dock.html', + styleUrl: './terminal-dock.scss', +}) +export class TerminalDock { + private readonly document = inject(DOCUMENT); + private readonly injector = inject(Injector); + private readonly router = inject(Router); + private readonly navigation = inject(NavigationService); + private readonly localeService = inject(LocaleService); + private readonly dock = inject(TerminalDockService); + private readonly isBrowser = isBrowserPlatform(); + private readonly applePlatform = isApplePlatform(); + + private readonly inputRef = viewChild>('commandInput'); + private readonly triggerRef = viewChild>('trigger'); + + protected readonly panelId = this.dock.panelId; + protected readonly inputId = this.dock.inputId; + protected readonly logId = this.dock.logId; + protected readonly state = this.dock.state; + protected readonly useAppleHint = signal(false); + protected readonly query = signal(''); + protected readonly log = signal([]); + protected readonly entered = signal([]); + protected readonly historyIndex = signal(null); + + protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].terminal); + protected readonly shortcutHint = computed(() => + this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint, + ); + protected readonly open = computed(() => this.state() !== 'collapsed'); + protected readonly maximized = computed(() => this.state() === 'maximized'); + protected readonly suggestions = computed(() => suggestCompletions(this.query())); + protected readonly maximizeLabel = computed(() => + this.maximized() ? this.copy().restoreLabel : this.copy().maximizeLabel, + ); + + constructor() { + afterNextRender( + () => { + if (this.isBrowser) { + this.useAppleHint.set(this.applePlatform); + } + + this.dock.registerTrigger(this.triggerRef()?.nativeElement ?? null); + }, + { injector: this.injector }, + ); + + effect(() => { + if (!this.open()) { + return; + } + + afterNextRender( + () => { + this.inputRef()?.nativeElement.focus(); + }, + { injector: this.injector }, + ); + }); + } + + protected onTriggerClick(): void { + const trigger = this.triggerRef()?.nativeElement; + trigger?.focus(); + this.dock.open(trigger ?? null); + } + + protected onQueryInput(event: Event): void { + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + return; + } + + this.query.set(target.value); + this.historyIndex.set(null); + } + + protected onSubmit(event: Event): void { + event.preventDefault(); + this.runRaw(this.query()); + } + + protected onInputKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + this.dock.collapse(); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.stepHistory(-1); + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.stepHistory(1); + return; + } + + if (event.key === 'Tab') { + event.preventDefault(); + this.completeCurrent(); + } + } + + protected toggleMaximized(): void { + this.dock.toggleMaximized(); + } + + protected collapse(): void { + this.dock.collapse(); + } + + private runRaw(raw: string): void { + const parsed = parseCommand(raw); + if (parsed.kind === 'empty') { + return; + } + + const copy = this.copy(); + this.echo(raw); + this.entered.update((items) => [...items, raw]); + this.historyIndex.set(null); + this.query.set(''); + + switch (parsed.kind) { + case 'help': { + this.append([ + copy.helpIntro, + copy.helpGrammar, + ...COMMAND_IDS.map((id) => `${id} — ${copy.commandDescriptions[id]}`), + ]); + return; + } + case 'history': { + const commands = this.entered(); + if (commands.length === 0) { + this.append([copy.historyEmpty]); + return; + } + this.append([copy.historyIntro, ...commands]); + return; + } + case 'clear': { + this.log.set([]); + this.append([copy.clearedMessage]); + return; + } + case 'brew': { + this.append([copy.responses.brew]); + return; + } + case 'rev': { + this.append([copy.responses.rev]); + return; + } + case 'navigate': { + if (parsed.resolution.kind === 'cv') { + if (this.isBrowser) { + this.document.defaultView?.open( + SITE_CONFIG.cvAssetPath, + '_blank', + 'noopener,noreferrer', + ); + } + this.append([copy.cvOpened]); + return; + } + + void this.router.navigate(this.navigation.link(parsed.resolution.routeId)); + this.append([copy.navigating.replace('{target}', parsed.targetKey)]); + return; + } + case 'unknown-command': { + this.append([copy.unknownCommand.replace('{command}', parsed.input)]); + return; + } + case 'unknown-target': { + this.append([ + copy.unknownTarget.replace('{target}', parsed.target), + copy.validTargetsLabel, + ...navigateTargetKeys(), + ]); + return; + } + case 'incomplete': { + this.append([copy.incompleteHint, ...parsed.suggestions]); + return; + } + } + } + + private completeCurrent(): void { + const result = applyCompletion(this.query()); + if (result.candidates.length === 1) { + this.query.set(result.value); + return; + } + + if (result.candidates.length > 1) { + this.append(result.candidates); + } + } + + private stepHistory(direction: -1 | 1): void { + const commands = this.entered(); + if (commands.length === 0) { + return; + } + + const current = this.historyIndex(); + const next = + current === null + ? direction < 0 + ? commands.length - 1 + : 0 + : Math.min(commands.length - 1, Math.max(0, current + direction)); + + this.historyIndex.set(next); + this.query.set(commands[next] ?? ''); + } + + private echo(input: string): void { + this.log.update((lines) => [ + ...lines, + { id: terminalLogId++, kind: 'echo', text: `${this.copy().prompt} ${input}` }, + ]); + } + + private append(texts: readonly string[]): void { + this.log.update((lines) => [ + ...lines, + ...texts.map((text) => ({ id: terminalLogId++, kind: 'output' as const, text })), + ]); + } +}