Replace the modal command palette with a nonmodal terminal dock.

Ctrl+K now opens a corner session with a parsed navigate grammar, persistent history, and no page lock. Unknown targets stay on a closed alias table and never become URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 12:52:22 +02:00
parent 646b39b200
commit 162bd790da
23 changed files with 1322 additions and 1278 deletions

View File

@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { pagePath } from './helpers'; 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 }) => { test('skip link is first and moves focus to main', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
@@ -87,37 +87,32 @@ test.describe('keyboard and palette', () => {
const headerBox = await page.locator('.site-header').boundingBox(); const headerBox = await page.locator('.site-header').boundingBox();
expect(headerBox, 'header while submenu is open').toBeTruthy(); expect(headerBox, 'header while submenu is open').toBeTruthy();
expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200); 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, page,
}) => { }) => {
await page.goto('/'); await page.goto('/');
const trigger = page.locator('.command-palette-trigger'); const trigger = page.locator('.terminal-dock-trigger');
await trigger.focus(); await trigger.focus();
await page.keyboard.press('Control+k'); await page.keyboard.press('Control+k');
const dialog = page.locator('[role="dialog"]'); const panel = page.locator('.terminal-dock-panel');
await expect(dialog).toBeVisible(); await expect(panel).toBeVisible();
await expect(page.locator('.site')).toHaveAttribute('inert', ''); await expect(page.locator('.site')).not.toHaveAttribute('inert');
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden'); expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden');
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
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();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0); await expect(panel).toHaveCount(0);
await expect(page.locator('.site')).not.toHaveAttribute('inert');
expect(await page.evaluate(() => document.body.style.overflow)).toBe('');
await expect(trigger).toBeFocused(); await expect(trigger).toBeFocused();
}); });
}); });

67
e2e/terminal.e2e.ts Normal file
View File

@@ -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');
});
});

View File

@@ -1,24 +1,10 @@
export type CommandId = export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate';
| 'help'
| 'projects'
| 'servicesAi'
| 'cv'
| 'contact'
| 'brew'
| 'ignite'
| 'rev'
| 'clear'
| 'close';
export const COMMAND_IDS: readonly CommandId[] = [ export const COMMAND_IDS: readonly CommandId[] = [
'help', 'help',
'projects', 'history',
'servicesAi',
'cv',
'contact',
'brew',
'ignite',
'rev',
'clear', 'clear',
'close', 'brew',
'rev',
'navigate',
]; ];

View File

@@ -58,7 +58,7 @@ describe('SIGNATURE_COPY', () => {
it('describes every CommandId in both locales', () => { it('describes every CommandId in both locales', () => {
for (const locale of APP_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()); expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());

View File

@@ -2,27 +2,34 @@ import { type CommandId } from '../commands/command-ids';
import { type AppLocale } from '../i18n/locale'; import { type AppLocale } from '../i18n/locale';
export interface SignatureCopy { export interface SignatureCopy {
readonly palette: { readonly terminal: {
readonly triggerLabel: string; readonly triggerLabel: string;
readonly shortcutHint: string; readonly shortcutHint: string;
readonly shortcutHintApple: string; readonly shortcutHintApple: string;
readonly dialogTitle: string; readonly prompt: string;
readonly dialogDescription: string; readonly panelLabel: string;
readonly inputLabel: string; readonly inputLabel: string;
readonly inputPlaceholder: string; readonly inputPlaceholder: string;
readonly closeLabel: string; readonly collapseLabel: string;
readonly suggestionsLabel: string; readonly expandLabel: string;
readonly restoreLabel: string;
readonly maximizeLabel: string;
readonly outputLabel: string; readonly outputLabel: string;
readonly emptySuggestions: string; readonly emptySuggestions: string;
readonly unknownCommand: string; readonly unknownCommand: string;
readonly unknownTarget: string;
readonly validTargetsLabel: string;
readonly helpIntro: string; readonly helpIntro: string;
readonly helpGrammar: string;
readonly historyEmpty: string;
readonly historyIntro: string;
readonly clearedMessage: string; readonly clearedMessage: string;
readonly cvOpened: string; readonly cvOpened: string;
readonly navigating: string; readonly navigating: string;
readonly incompleteHint: string;
readonly commandDescriptions: Record<CommandId, string>; readonly commandDescriptions: Record<CommandId, string>;
readonly responses: { readonly responses: {
readonly brew: string; readonly brew: string;
readonly ignite: string;
readonly rev: string; readonly rev: string;
}; };
}; };
@@ -45,39 +52,41 @@ export interface SignatureCopy {
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = { export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
de: { de: {
palette: { terminal: {
triggerLabel: 'Befehle öffnen', triggerLabel: 'Terminal',
shortcutHint: 'Strg+K', shortcutHint: 'Strg+K',
shortcutHintApple: '⌘K', shortcutHintApple: '⌘K',
dialogTitle: 'Befehle', prompt: 'visitor@antoniolede:~$',
dialogDescription: panelLabel: 'Terminal',
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
inputLabel: 'Befehl', inputLabel: 'Befehl',
inputPlaceholder: 'Befehl eingeben', inputPlaceholder: 'Befehl eingeben',
closeLabel: 'Schließen', collapseLabel: 'Terminal einklappen',
suggestionsLabel: 'Vorschläge', expandLabel: 'Terminal öffnen',
restoreLabel: 'Terminal verkleinern',
maximizeLabel: 'Terminal vergrößern',
outputLabel: 'Ausgabe', outputLabel: 'Ausgabe',
emptySuggestions: 'Keine passenden Befehle.', emptySuggestions: 'Keine passenden Befehle.',
unknownCommand: 'Unbekannter Befehl: {command}', unknownCommand: 'Unbekannter Befehl: {command}',
unknownTarget: 'Unbekanntes Ziel: {target}',
validTargetsLabel: 'Gültige Ziele:',
helpIntro: 'Verfügbare Befehle:', helpIntro: 'Verfügbare Befehle:',
helpGrammar: 'Grammatik: help | history | clear | brew | rev | navigate <ziel> [<unterziel>]',
historyEmpty: 'In dieser Sitzung wurde noch kein Befehl eingegeben.',
historyIntro: 'Eingegebene Befehle:',
clearedMessage: 'Ausgabe geleert.', clearedMessage: 'Ausgabe geleert.',
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.', cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
navigating: 'Wechsel zu {target}.', navigating: 'Wechsel zu {target}.',
incompleteHint: 'Unvollständiger Befehl. Mögliche Ziele:',
commandDescriptions: { commandDescriptions: {
help: 'Listet die verfügbaren Befehle.', help: 'Listet die Grammatik und jeden Befehl.',
projects: 'Öffnet die Projektübersicht.', history: 'Listet die in dieser Sitzung eingegebenen Befehle.',
servicesAi: 'Öffnet die Seite zur KI-Integration.', clear: 'Leert das Ausgabebuch.',
cv: 'Öffnet den Lebenslauf als PDF.',
contact: 'Öffnet die Kontaktseite.',
brew: 'Eine kurze, spielerische Rückmeldung.', brew: 'Eine kurze, spielerische Rückmeldung.',
ignite: 'Eine kurze, spielerische Rückmeldung.',
rev: 'Eine kurze, spielerische Rückmeldung.', rev: 'Eine kurze, spielerische Rückmeldung.',
clear: 'Leert die Ausgabe.', navigate: 'Wechselt zu einer bekannten Seite oder öffnet den Lebenslauf.',
close: 'Schließt die Befehlsübersicht.',
}, },
responses: { responses: {
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.', 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.', rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
}, },
}, },
@@ -99,38 +108,41 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
}, },
}, },
en: { en: {
palette: { terminal: {
triggerLabel: 'Open commands', triggerLabel: 'Terminal',
shortcutHint: 'Ctrl+K', shortcutHint: 'Ctrl+K',
shortcutHintApple: '⌘K', shortcutHintApple: '⌘K',
dialogTitle: 'Commands', prompt: 'visitor@antoniolede:~$',
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.', panelLabel: 'Terminal',
inputLabel: 'Command', inputLabel: 'Command',
inputPlaceholder: 'Type a command', inputPlaceholder: 'Type a command',
closeLabel: 'Close', collapseLabel: 'Collapse the terminal',
suggestionsLabel: 'Suggestions', expandLabel: 'Open the terminal',
restoreLabel: 'Restore the terminal',
maximizeLabel: 'Maximise the terminal',
outputLabel: 'Output', outputLabel: 'Output',
emptySuggestions: 'No matching commands.', emptySuggestions: 'No matching commands.',
unknownCommand: 'Unknown command: {command}', unknownCommand: 'Unknown command: {command}',
unknownTarget: 'Unknown target: {target}',
validTargetsLabel: 'Valid targets:',
helpIntro: 'Available commands:', helpIntro: 'Available commands:',
helpGrammar: 'Grammar: help | history | clear | brew | rev | navigate <target> [<sub>]',
historyEmpty: 'No commands have been entered in this session.',
historyIntro: 'Entered commands:',
clearedMessage: 'Output cleared.', clearedMessage: 'Output cleared.',
cvOpened: 'Opened the CV in a new tab.', cvOpened: 'Opened the CV in a new tab.',
navigating: 'Going to {target}.', navigating: 'Going to {target}.',
incompleteHint: 'Incomplete command. Possible targets:',
commandDescriptions: { commandDescriptions: {
help: 'Lists the available commands.', help: 'Lists the grammar and every command.',
projects: 'Opens the projects overview.', history: 'Lists the commands entered in this session.',
servicesAi: 'Opens the AI integration page.', clear: 'Clears the output log.',
cv: 'Opens the CV as a PDF.',
contact: 'Opens the contact page.',
brew: 'A short playful acknowledgement.', brew: 'A short playful acknowledgement.',
ignite: 'A short playful acknowledgement.',
rev: 'A short playful acknowledgement.', rev: 'A short playful acknowledgement.',
clear: 'Clears the output.', navigate: 'Goes to a known page or opens the curriculum vitae.',
close: 'Closes the command palette.',
}, },
responses: { responses: {
brew: 'Freshly brewed. Automation that runs before the first coffee.', brew: 'Freshly brewed. Automation that runs before the first coffee.',
ignite: 'Ignition on. Systems are warming up.',
rev: 'Revs climbing, the content stays calm.', rev: 'Revs climbing, the content stays calm.',
}, },
}, },

View File

@@ -19,7 +19,7 @@ export function viewportMatches(query: string): boolean {
/** /**
* Keyboard-labelling exception: there is no CSS media query for the Command key. * 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 { export function isApplePlatform(): boolean {
if (!isBrowserPlatform()) { if (!isBrowserPlatform()) {

View File

@@ -1,13 +0,0 @@
<button
#trigger
type="button"
class="command-palette-trigger"
aria-haspopup="dialog"
[attr.aria-expanded]="open()"
[attr.aria-controls]="open() ? dialogId : null"
[attr.aria-label]="copy().triggerLabel"
(click)="onTriggerClick()"
>
<span>{{ copy().triggerLabel }}</span>
<kbd>{{ shortcutHint() }}</kbd>
</button>

View File

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

View File

@@ -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<ElementRef<HTMLButtonElement>>('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);
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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();
};
}

View File

@@ -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: `
<div class="site" [attr.inert]="palette.open() ? '' : null">
<app-command-palette-trigger></app-command-palette-trigger>
</div>
<app-command-palette></app-command-palette>
`,
})
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<ComponentFixture<PaletteHost>> {
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<PaletteHost>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
await fixture.whenStable();
}
function trigger(fixture: ComponentFixture<PaletteHost>): HTMLButtonElement {
return fixture.nativeElement.querySelector('.command-palette-trigger');
}
function dialog(fixture: ComponentFixture<PaletteHost>): HTMLElement | null {
return fixture.nativeElement.querySelector('[role="dialog"]');
}
function site(fixture: ComponentFixture<PaletteHost>): HTMLElement {
return fixture.nativeElement.querySelector('.site');
}
async function openViaTrigger(fixture: ComponentFixture<PaletteHost>): Promise<void> {
trigger(fixture).click();
await flush(fixture);
}
async function submitQuery(fixture: ComponentFixture<PaletteHost>, value: string): Promise<void> {
const input = fixture.nativeElement.querySelector('input');
expect(input).toBeTruthy();
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
fixture.nativeElement
.querySelector('form')
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await flush(fixture);
}
function outputText(fixture: ComponentFixture<PaletteHost>): string {
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
}
function focusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((element) => element.tabIndex >= 0);
}
it.each(APP_LOCALES)(
'renders the trigger and not the dialog on the server (%s)',
async (locale) => {
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
expect(trigger(fixture)).toBeTruthy();
expect(dialog(fixture)).toBeNull();
},
);
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const input = fixture.nativeElement.querySelector('input');
expect(dialog(fixture)).toBeTruthy();
expect(document.activeElement).toBe(input);
});
it.each(APP_LOCALES)(
'opens from Ctrl+K and Meta+K and prevents the default (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await flush(fixture);
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
const openDialog = dialog(fixture);
if (openDialog) {
openDialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
}
const event = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
...modifier,
});
const prevent = vi.spyOn(event, 'preventDefault');
document.dispatchEvent(event);
await flush(fixture);
expect(prevent).toHaveBeenCalled();
expect(dialog(fixture)).toBeTruthy();
}
},
);
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const openDialog = dialog(fixture);
expect(openDialog).toBeTruthy();
openDialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
expect(dialog(fixture)).toBeNull();
expect(document.activeElement).toBe(trigger(fixture));
});
it.each(APP_LOCALES)('closes from the close button and restores focus (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const closeButton = fixture.nativeElement.querySelector('.command-palette-close');
closeButton.click();
await flush(fixture);
expect(dialog(fixture)).toBeNull();
expect(document.activeElement).toBe(trigger(fixture));
});
it.each(APP_LOCALES)('wraps Tab and Shift+Tab inside the dialog (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const openDialog = dialog(fixture);
expect(openDialog).toBeTruthy();
const items = focusable(openDialog as HTMLElement);
const first = items[0];
const last = items[items.length - 1];
last.focus();
openDialog?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(first);
first.focus();
openDialog?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(last);
});
it.each(APP_LOCALES)(
'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);
});
});

View File

@@ -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<ElementRef<HTMLInputElement>>('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<readonly { id: number; text: string }[]>([]);
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
protected readonly suggestions = computed(() =>
suggestCommands(this.query(), this.localeService.locale()),
);
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<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0);
}
}

View File

@@ -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()',
'<script>alert(1)</script>',
'__proto__',
'constructor',
'toString',
'hasOwnProperty',
];
for (const input of hostile) {
expect(parseCommand(input)).toEqual({ kind: 'unknown', input });
}
expect(parseCommand('')).toEqual({ kind: 'empty' });
expect(parseCommand(' ')).toEqual({ kind: 'empty' });
expect(Object.getOwnPropertyNames(Object.prototype)).toEqual(prototypeNames);
expect(Object.prototype).not.toHaveProperty('polluted');
});
it('returns an equal result for the same input across repeated calls', () => {
const samples = ['help', 'services ai', ' ', 'unknown-token', '__proto__'];
for (const sample of samples) {
expect(parseCommand(sample)).toEqual(parseCommand(sample));
}
});
it('suggests commands in a stable COMMAND_IDS order and filters by prefix', () => {
const emptyDe = suggestCommands('', 'de').map((command) => command.id);
const emptyEn = suggestCommands('', 'en').map((command) => command.id);
const expectedIds: CommandId[] = [...COMMAND_IDS];
expect(emptyDe).toEqual(expectedIds);
expect(emptyEn).toEqual(expectedIds);
expect(suggestCommands('c', 'en').map((command) => command.id)).toEqual([
'cv',
'contact',
'clear',
'close',
]);
expect(suggestCommands('lei', 'de').map((command) => command.id)).toEqual(['servicesAi']);
expect(suggestCommands('c', 'de')).toEqual(suggestCommands('c', 'de'));
});
});

View File

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

View File

@@ -0,0 +1,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,
});
}
});
});

View File

@@ -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<string, Exclude<CommandId, 'navigate'>> = 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<string>();
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 };
}

View File

@@ -0,0 +1,71 @@
<div class="terminal-dock" [class.is-open]="open()" [class.is-maximized]="maximized()">
<button
#trigger
type="button"
class="terminal-dock-trigger"
[attr.aria-expanded]="open()"
[attr.aria-controls]="open() ? panelId : null"
[attr.aria-label]="copy().triggerLabel"
(click)="onTriggerClick()"
>
<span>{{ copy().triggerLabel }}</span>
<kbd>{{ shortcutHint() }}</kbd>
</button>
@if (open()) {
<section class="terminal-dock-panel" [id]="panelId" [attr.aria-label]="copy().panelLabel">
<div class="terminal-dock-toolbar cluster">
<p class="terminal-dock-title">{{ copy().panelLabel }}</p>
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="maximizeLabel()"
[attr.aria-pressed]="maximized()"
(click)="toggleMaximized()"
>
{{ maximizeLabel() }}
</button>
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="copy().collapseLabel"
(click)="collapse()"
>
{{ copy().collapseLabel }}
</button>
</div>
<div
class="terminal-dock-log"
[id]="logId"
role="log"
aria-live="polite"
aria-atomic="false"
[attr.aria-label]="copy().outputLabel"
>
@for (line of log(); track line.id) {
<p [class.echo]="line.kind === 'echo'">{{ line.text }}</p>
}
</div>
<form class="terminal-dock-form" (submit)="onSubmit($event)">
<label class="terminal-dock-prompt" [attr.for]="inputId">
<span aria-hidden="true">{{ copy().prompt }}</span>
<span class="visually-hidden">{{ copy().inputLabel }}</span>
</label>
<input
#commandInput
[id]="inputId"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
[attr.placeholder]="copy().inputPlaceholder"
[value]="query()"
(input)="onQueryInput($event)"
(keydown)="onInputKeydown($event)"
/>
</form>
</section>
}
</div>

View File

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

View File

@@ -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<TerminalDockState>('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();
};
}

View File

@@ -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<ComponentFixture<TerminalDock>> {
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<TerminalDock>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
await fixture.whenStable();
}
function trigger(fixture: ComponentFixture<TerminalDock>): HTMLButtonElement {
return fixture.nativeElement.querySelector('.terminal-dock-trigger');
}
function panel(fixture: ComponentFixture<TerminalDock>): HTMLElement | null {
return fixture.nativeElement.querySelector('.terminal-dock-panel');
}
function input(fixture: ComponentFixture<TerminalDock>): HTMLInputElement | null {
return fixture.nativeElement.querySelector('input');
}
async function openViaTrigger(fixture: ComponentFixture<TerminalDock>): Promise<void> {
trigger(fixture).click();
await flush(fixture);
}
async function submitQuery(
fixture: ComponentFixture<TerminalDock>,
value: string,
): Promise<void> {
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<TerminalDock>): 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]);
});
});

View File

@@ -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<ElementRef<HTMLInputElement>>('commandInput');
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('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<readonly LogLine[]>([]);
protected readonly entered = signal<readonly string[]>([]);
protected readonly historyIndex = signal<number | null>(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 })),
]);
}
}