10 Commits

Author SHA1 Message Date
e63b4edade Restore the original Quartz Producer on the CV without duplicating the pitch link.
The script now repairs metadata independently of the annotation, and a repeat run leaves the bytes untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:51:58 +02:00
b8b9e57d06 Make the terminal dock toggle, scroll, and remember commands honestly.
The desktop trigger now collapses through the shared toggle, the log stays on the newest line, Shift+K is left to the browser, history walks like a shell, and maximize keeps one accessible name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:51:55 +02:00
19d00da9bb Collapse the terminal dock from Escape anywhere in the panel.
Drop unused suggestion state and copy, and give the panel a reduced-motion-aware open transition.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:13:43 +02:00
35a575dba9 Read Pitch as a profile: stations and stack before the case cards.
The four public cases stay on the page; the timeline no longer follows them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:13:43 +02:00
255f0eace6 Stop the Pitch timeline from repeating dates and talking about omitted stations.
Roles keep the date in period only, self-employment is described as the work itself, and Home workplace copy stays plain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:13:40 +02:00
359e887a7a Align tests and docs with Pitch, the customer Home, and the terminal dock.
Page, crawl, Playwright and Lighthouse coverage now include both pitch locales, and the contributor docs match the new route table and chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:45 +02:00
5921fc5ede Add a reproducible pitch URL and link annotation to the CV.
The script is idempotent, keeps the twelve A4 pages, and writes antoniolede.de/pitch on page 1 with a matching URI annotation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:26 +02:00
162bd790da 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>
2026-08-26 12:52:22 +02:00
646b39b200 Make the primary nav customer-facing and keep the services submenu readable.
Stack and the CV leave the chrome so Contact stays the main action. The desktop submenu now uses an opaque surface token and a stacking context above page content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:17 +02:00
86d6dd085d Add the recruiter Pitch route and rebuild Home for direct-customer work.
Pitch takes the verified profile, metrics and public cases off Home so `/` can speak to small companies in plain language. The Systems Map now sits on the services overview, and both locales stay table-driven.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:12 +02:00
81 changed files with 2830 additions and 1558 deletions

View File

@@ -4,7 +4,7 @@ Contributor and agent guide for this repository. Read this before changing routi
## Purpose ## Purpose
This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Every public page exists in both locales. This is a bilingual portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Home is the direct-customer experience. `/pitch` is the recruiter-oriented page and is reachable without sitting in the primary navigation. Every public page exists in both locales.
## Architecture ## Architecture
@@ -93,6 +93,7 @@ German and English are written idiomatically per language, never machine-transla
## i18n rules ## i18n rules
- German at `/`, English under `/en` - German at `/`, English under `/en`
- Primary navigation is Home, Services (with four children), Projects, About and Contact. Stack and Pitch stay reachable from content and the terminal, not from the primary nav.
- Every route id exists in both locales - Every route id exists in both locales
- New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths - New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths
- Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService` - Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService`
@@ -154,7 +155,7 @@ A change is not done until:
| ----------- | -------------------------------------------------------------------------------- | | ----------- | -------------------------------------------------------------------------------- |
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline | | Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
| Content | Bilingual copy data and page composition | | Content | Bilingual copy data and page composition |
| Signature | Systems Map, terminal, dot background and motion | | Signature | Systems Map, terminal dock, dot background and motion |
| Integration | SEO and cross-cutting a11y/performance hardening | | Integration | SEO and cross-cutting a11y/performance hardening |
Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract. Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract.

BIN
CV.pdf

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# Portfolio # Portfolio
Bilingual recruiter and B2B portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`. Bilingual portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`. Home speaks to small-company customers. `/pitch` is the recruiter-oriented page and is not in the primary navigation.
This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build. This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build.
@@ -17,23 +17,24 @@ npm install
## Scripts ## Scripts
| Script | Description | | Script | Description |
| ----------------------------- | ------------------------------------------------------------------------- | | ----------------------------- | -------------------------------------------------------------------------- |
| `npm start` | Start the Angular dev server. | | `npm start` | Start the Angular dev server. |
| `npm run build` | Production build (default configuration), including SSR and prerendering. | | `npm run build` | Production build (default configuration), including SSR and prerendering. |
| `npm run watch` | Development rebuild on change. | | `npm run watch` | Development rebuild on change. |
| `npm test` | Unit tests in watch mode. | | `npm test` | Unit tests in watch mode. |
| `npm run test:ci` | Unit tests once; exits when finished. | | `npm run test:ci` | Unit tests once; exits when finished. |
| `npm run lint` | Run ESLint. | | `npm run lint` | Run ESLint. |
| `npm run lint:fix` | Run ESLint with autofix. | | `npm run lint:fix` | Run ESLint with autofix. |
| `npm run format` | Format the workspace with Prettier. | | `npm run format` | Format the workspace with Prettier. |
| `npm run format:check` | Check formatting without writing. | | `npm run format:check` | Check formatting without writing. |
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. | | `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. | | `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
| `npm run e2e` | Playwright + axe-core against the production SSR bundle. | | `npm run e2e` | Playwright + axe-core against the production SSR bundle. |
| `npm run e2e:install` | Install the pinned Chromium build for Playwright. | | `npm run e2e:install` | Install the pinned Chromium build for Playwright. |
| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). | | `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). |
| `npm run ci` | lint, format check, tests, then production build. | | `npm run ci` | lint, format check, tests, then production build. |
| `npm run cv:link` | Add the visible `/pitch` URL and link annotation to `CV.pdf` (idempotent). |
## Local SSR build ## Local SSR build
@@ -42,7 +43,7 @@ npm run build
npm run serve:ssr npm run serve:ssr
``` ```
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`. The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`. The header no longer offers that download; Pitch and the terminal `navigate cv` command still do. The lower-right terminal dock replaces the old centered command palette (`Ctrl+K` / `⌘K`).
`npm run e2e` builds the SSR bundle and serves it on port 4173. `npm run lighthouse` builds, then starts the SSR server on port 4000. Browser binaries and generated reports (`test-results`, `playwright-report`, `.lighthouseci`) are not committed. `npm run e2e` builds the SSR bundle and serves it on port 4173. `npm run lighthouse` builds, then starts the SSR server on port 4000. Browser binaries and generated reports (`test-results`, `playwright-report`, `.lighthouseci`) are not committed.

View File

@@ -5,6 +5,8 @@ import { pagePath } from './helpers';
const AXE_PATHS = [ const AXE_PATHS = [
pagePath('home', 'de'), pagePath('home', 'de'),
pagePath('home', 'en'), pagePath('home', 'en'),
pagePath('pitch', 'de'),
pagePath('pitch', 'en'),
pagePath('projects', 'de'), pagePath('projects', 'de'),
pagePath('projects', 'en'), pagePath('projects', 'en'),
pagePath('servicesAi', 'de'), pagePath('servicesAi', 'de'),
@@ -23,8 +25,11 @@ test.describe('accessibility', () => {
await page.goto(pagePath('home', 'de')); await page.goto(pagePath('home', 'de'));
const pending = page.locator('.reveal-pending'); const pending = page.locator('.reveal-pending');
await expect(pending).toHaveCount(0); await expect(pending).toHaveCount(0);
await expect(page.locator('app-systems-map')).toBeVisible();
await expect(page.locator('app-case-card').first()).toBeVisible(); await expect(page.locator('app-case-card').first()).toBeVisible();
await page.goto(pagePath('services', 'de'));
await expect(page.locator('.reveal-pending')).toHaveCount(0);
await expect(page.locator('app-systems-map')).toBeVisible();
}); });
for (const path of AXE_PATHS) { for (const path of AXE_PATHS) {

View File

@@ -24,9 +24,13 @@ test.describe('contact briefing', () => {
await page.locator('#contact-name').fill('Ada'); await page.locator('#contact-name').fill('Ada');
await page.locator('#contact-email').fill('ada@example.com'); await page.locator('#contact-email').fill('ada@example.com');
await page.locator('#contact-projectType').selectOption('software');
await page.locator('#contact-situation').fill('Need a migration.'); await page.locator('#contact-situation').fill('Need a migration.');
const optionalHref = await page.locator('a.submit').getAttribute('href');
expect(optionalHref).toMatch(/^mailto:/);
await page.locator('#contact-projectType').selectOption('unsure');
const href = await page.locator('a.submit').getAttribute('href'); const href = await page.locator('a.submit').getAttribute('href');
expect(href).toMatch(/^mailto:/); expect(href).toMatch(/^mailto:/);
expect(href).toContain(encodeURIComponent(SITE_CONTENT_DATA.de.contact.mailSubject)); expect(href).toContain(encodeURIComponent(SITE_CONTENT_DATA.de.contact.mailSubject));

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

View File

@@ -5,6 +5,8 @@ import { expectNoOverflow, pagePath } from './helpers';
const CHECKED = [ const CHECKED = [
pagePath('home', 'de'), pagePath('home', 'de'),
pagePath('home', 'en'), pagePath('home', 'en'),
pagePath('pitch', 'de'),
pagePath('pitch', 'en'),
pagePath('projects', 'de'), pagePath('projects', 'de'),
pagePath('projects', 'en'), pagePath('projects', 'en'),
pagePath('servicesAi', 'de'), pagePath('servicesAi', 'de'),

View File

@@ -38,6 +38,25 @@ test.describe('server-rendered metadata', () => {
} }
}); });
test('pitch has full head metadata and stays out of the primary nav', async ({
request,
page,
}) => {
await expectHead(request, 'pitch', 'de');
await expectHead(request, 'pitch', 'en');
const sitemap = await request.get('/sitemap.xml');
const xml = await sitemap.text();
expect(xml).toContain('https://antoniolede.de/pitch');
expect(xml).toContain('https://antoniolede.de/en/pitch');
await page.goto('/');
await ensurePrimaryNavOpen(page);
const nav = page.locator('.primary-nav');
await expect(nav.locator(`a[href="${pagePath('pitch', 'de')}"]`)).toHaveCount(0);
await expect(nav.locator(`a[href="${pagePath('home', 'de')}"]`)).toHaveCount(1);
});
test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => { test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => {
const home = await request.get('/'); const home = await request.get('/');
expect(home.status(), 'GET /').toBe(200); expect(home.status(), 'GET /').toBe(200);

View File

@@ -3,7 +3,7 @@ import { pagePath } from './helpers';
test.describe('Systems Map', () => { test.describe('Systems Map', () => {
test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => { test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => {
await page.goto(pagePath('home', 'de')); await page.goto(pagePath('services', 'de'));
for (const width of [390, 768, 1024, 1440]) { for (const width of [390, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
@@ -21,7 +21,7 @@ test.describe('Systems Map', () => {
}); });
test('keeps SVG labels inside their shapes at 1024 and 1440', async ({ page }) => { test('keeps SVG labels inside their shapes at 1024 and 1440', async ({ page }) => {
await page.goto(pagePath('home', 'de')); await page.goto(pagePath('services', 'de'));
for (const width of [1024, 1440]) { for (const width of [1024, 1440]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
@@ -51,7 +51,7 @@ test.describe('Systems Map', () => {
}); });
test('every SVG node points at a real route', async ({ page, request }) => { test('every SVG node points at a real route', async ({ page, request }) => {
await page.goto(pagePath('home', 'de')); await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
const hrefs = await page const hrefs = await page
.locator('.systems-map-node') .locator('.systems-map-node')

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

@@ -0,0 +1,145 @@
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');
});
test('clicking the visible desktop trigger while open collapses the panel', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name === 'mobile',
'Below md the trigger is hidden while the panel is open.',
);
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel');
await trigger.click();
await expect(panel).toBeVisible();
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await trigger.click();
await expect(panel).toHaveCount(0);
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(trigger).toBeFocused();
});
test('keeps the newest echoed line in the log scrollport after overflow', async ({ page }) => {
const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const input = page.locator('#terminal-dock-input');
const log = page.locator('.terminal-dock-log');
await trigger.click();
await expect(log).toBeVisible();
for (let index = 0; index < 8; index += 1) {
await input.fill('help');
await input.press('Enter');
}
await expect(log.locator('p.echo').last()).toHaveText(`${copy.prompt} help`);
await expect
.poll(async () =>
log.evaluate((element) => {
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
}),
)
.toBe(true);
const last = log.locator('p').last();
const logBox = await log.boundingBox();
const lastBox = await last.boundingBox();
expect(logBox && lastBox).toBeTruthy();
expect(lastBox!.y).toBeGreaterThanOrEqual(logBox!.y - 1);
expect(lastBox!.y + lastBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height + 1);
});
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
page,
}) => {
const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel');
await trigger.click();
await expect(panel).toBeVisible();
const collapse = page.getByRole('button', { name: copy.collapseLabel });
await collapse.focus();
await expect(collapse).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
});
});

View File

@@ -6,6 +6,8 @@
"url": [ "url": [
"http://127.0.0.1:4000/", "http://127.0.0.1:4000/",
"http://127.0.0.1:4000/en", "http://127.0.0.1:4000/en",
"http://127.0.0.1:4000/pitch",
"http://127.0.0.1:4000/en/pitch",
"http://127.0.0.1:4000/leistungen/ai-integration", "http://127.0.0.1:4000/leistungen/ai-integration",
"http://127.0.0.1:4000/en/projects" "http://127.0.0.1:4000/en/projects"
], ],

50
package-lock.json generated
View File

@@ -27,7 +27,7 @@
"@axe-core/playwright": "^4.13.0", "@axe-core/playwright": "^4.13.0",
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
"@lhci/cli": "^0.15.1", "@lhci/cli": "^0.15.1",
"@playwright/test": "^1.62.1", "@playwright/test": "1.62.1",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"angular-eslint": "^21.4.0", "angular-eslint": "^21.4.0",
@@ -35,6 +35,7 @@
"eslint": "^9.39.5", "eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"pdf-lib": "^1.17.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "^8.68.0", "typescript-eslint": "^8.68.0",
@@ -4285,6 +4286,26 @@
"third-party-web": "latest" "third-party-web": "latest"
} }
}, },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.62.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
@@ -10888,6 +10909,13 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true,
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -11064,6 +11092,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/pend": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",

View File

@@ -17,7 +17,8 @@
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build", "ci": "npm run lint && npm run format:check && npm run test:ci && npm run build",
"e2e": "playwright test", "e2e": "playwright test",
"e2e:install": "playwright install chromium", "e2e:install": "playwright install chromium",
"lighthouse": "npm run build && lhci autorun" "lighthouse": "npm run build && lhci autorun",
"cv:link": "node scripts/add-cv-pitch-link.mjs"
}, },
"prettier": { "prettier": {
"printWidth": 100, "printWidth": 100,
@@ -61,6 +62,7 @@
"eslint": "^9.39.5", "eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"pdf-lib": "^1.17.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "^8.68.0", "typescript-eslint": "^8.68.0",

View File

@@ -6,6 +6,8 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
- https://antoniolede.de/ - https://antoniolede.de/
- https://antoniolede.de/en - https://antoniolede.de/en
- https://antoniolede.de/pitch — Recruiter-Profil / recruiter profile
- https://antoniolede.de/en/pitch — Recruiter profile / Recruiter-Profil
- https://antoniolede.de/ueber-mich - https://antoniolede.de/ueber-mich
- https://antoniolede.de/en/about - https://antoniolede.de/en/about

View File

@@ -6,6 +6,12 @@
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" /> <xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" /> <xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
</url> </url>
<url>
<loc>https://antoniolede.de/pitch</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/pitch" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/pitch" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/pitch" />
</url>
<url> <url>
<loc>https://antoniolede.de/leistungen</loc> <loc>https://antoniolede.de/leistungen</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" /> <xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />
@@ -78,6 +84,12 @@
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" /> <xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" /> <xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
</url> </url>
<url>
<loc>https://antoniolede.de/en/pitch</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/pitch" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/pitch" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/pitch" />
</url>
<url> <url>
<loc>https://antoniolede.de/en/services</loc> <loc>https://antoniolede.de/en/services</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" /> <xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />

View File

@@ -0,0 +1,123 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PDFDocument, PDFName, PDFString, rgb, StandardFonts } from 'pdf-lib';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CV_PATH = join(ROOT, 'CV.pdf');
const PITCH_URI = 'https://antoniolede.de/pitch';
const VISIBLE_TEXT = 'antoniolede.de/pitch';
const TEXT_X = 59.6;
const TEXT_Y = 215.4;
const FONT_SIZE = 10.5;
const PADDING = 2;
const ORIGINAL_TITLE = 'CV';
const ORIGINAL_CREATOR = 'Pages';
// Producer from the unmodified Pages/Quartz export of CV.pdf.
const ORIGINAL_PRODUCER = 'macOS Version 26.5.2 (Build 25F84) Quartz PDFContext';
function asString(value) {
if (!value) {
return '';
}
if (typeof value.decodeText === 'function') {
return value.decodeText();
}
return String(value);
}
function annotationUri(annotation) {
const action = annotation.lookup(PDFName.of('A'));
if (!action || typeof action.lookup !== 'function') {
return '';
}
const uri = action.lookup(PDFName.of('URI'));
return asString(uri);
}
function pageHasPitchLink(page) {
const annots = page.node.lookup(PDFName.of('Annots'));
if (!annots || typeof annots.size !== 'function') {
return false;
}
for (let index = 0; index < annots.size(); index += 1) {
const annotation = annots.lookup(index);
if (annotationUri(annotation) === PITCH_URI) {
return true;
}
}
return false;
}
function hasOriginalMetadata(pdfDoc) {
return (
pdfDoc.getTitle() === ORIGINAL_TITLE &&
pdfDoc.getCreator() === ORIGINAL_CREATOR &&
pdfDoc.getProducer() === ORIGINAL_PRODUCER
);
}
const bytes = readFileSync(CV_PATH);
const pdfDoc = await PDFDocument.load(bytes, { updateMetadata: false });
const page = pdfDoc.getPage(0);
const needsLink = !pageHasPitchLink(page);
const needsMetadata = !hasOriginalMetadata(pdfDoc);
if (!needsLink && !needsMetadata) {
console.log('Pitch link and original metadata already present; leaving CV.pdf unchanged.');
process.exit(0);
}
if (needsLink) {
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const textWidth = font.widthOfTextAtSize(VISIBLE_TEXT, FONT_SIZE);
page.drawText(VISIBLE_TEXT, {
x: TEXT_X,
y: TEXT_Y,
size: FONT_SIZE,
font,
color: rgb(54 / 255, 125 / 255, 162 / 255),
});
const link = pdfDoc.context.register(
pdfDoc.context.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [
TEXT_X - PADDING,
TEXT_Y - PADDING,
TEXT_X + textWidth + PADDING,
TEXT_Y + FONT_SIZE + PADDING,
],
Border: [0, 0, 0],
A: {
S: 'URI',
URI: PDFString.of(PITCH_URI),
},
}),
);
page.node.addAnnot(link);
}
if (needsMetadata) {
pdfDoc.setTitle(ORIGINAL_TITLE);
pdfDoc.setCreator(ORIGINAL_CREATOR);
pdfDoc.setProducer(ORIGINAL_PRODUCER);
}
const saved = await pdfDoc.save({ useObjectStreams: false });
writeFileSync(CV_PATH, saved);
if (needsLink && needsMetadata) {
console.log('Added the pitch link and restored the original CV.pdf metadata.');
} else if (needsLink) {
console.log('Added visible pitch URL and link annotation to page 1 of CV.pdf.');
} else {
console.log('Restored the original Title, Creator and Producer on CV.pdf.');
}

View File

@@ -1,13 +1,12 @@
<a class="skip-link" href="#main-content">{{ shell().skipLink }}</a> <a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
<app-dot-background aria-hidden="true"></app-dot-background> <app-dot-background aria-hidden="true"></app-dot-background>
<div class="site" [class.nav-collapsed]="!navOpen()" [attr.inert]="palette.open() ? '' : null"> <div class="site" [class.nav-collapsed]="!navOpen()">
<header class="site-header"> <header class="site-header">
<div class="content-container site-header-inner glass-surface"> <div class="content-container site-header-inner glass-surface">
<a class="site-identity" [routerLink]="navigation.link('home')"> <a class="site-identity" [routerLink]="navigation.link('home')">
{{ siteConfig.personName }} {{ siteConfig.personName }}
</a> </a>
<div class="site-toolbar cluster"> <div class="site-toolbar cluster">
<app-command-palette-trigger></app-command-palette-trigger>
<button <button
type="button" type="button"
class="nav-toggle" class="nav-toggle"
@@ -58,13 +57,6 @@
> >
{{ shell().otherLocaleName }} {{ shell().otherLocaleName }}
</a> </a>
<a
[href]="siteConfig.cvAssetPath"
[attr.download]="siteConfig.cvDownloadFileName"
type="application/pdf"
>
{{ shell().cvLabel }}
</a>
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a> <a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
</div> </div>
</div> </div>
@@ -94,4 +86,4 @@
</div> </div>
</footer> </footer>
</div> </div>
<app-command-palette></app-command-palette> <app-terminal-dock></app-terminal-dock>

View File

@@ -9,7 +9,10 @@ describe('server routes', () => {
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404); expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
const prerendered = serverRoutes.filter((route) => route.path !== '**'); const prerendered = serverRoutes.filter((route) => route.path !== '**');
expect(prerendered.length).toBeGreaterThan(0); expect(prerendered).toHaveLength(26);
expect(prerendered.map((route) => route.path)).toEqual(
expect.arrayContaining(['pitch', 'en/pitch']),
);
for (const route of prerendered) { for (const route of prerendered) {
expect(route.renderMode, route.path).toBe(RenderMode.Prerender); expect(route.renderMode, route.path).toBe(RenderMode.Prerender);

View File

@@ -64,6 +64,17 @@ describe('app routes', () => {
); );
}); });
it('registers pitch as a lazy route in both locales', () => {
const flattened = flattenRoutes(routes);
const german = flattened.find((entry) => entry.path === 'pitch');
const english = flattened.find((entry) => entry.path === 'en/pitch');
const pitchRoute = routes.find((route) => route.path === 'pitch');
expect(german?.data).toEqual({ routeId: 'pitch', locale: 'de' });
expect(english?.data).toEqual({ routeId: 'pitch', locale: 'en' });
expect(pitchRoute?.loadComponent).toBeTypeOf('function');
});
it('attaches the final page title for every locale route', () => { it('attaches the final page title for every locale route', () => {
const flattened = flattenRoutes(routes); const flattened = flattenRoutes(routes);

View File

@@ -11,6 +11,7 @@ type LazyPage = () => Promise<Type<unknown>>;
const PAGE_LOADERS: Record<RouteId, LazyPage> = { const PAGE_LOADERS: Record<RouteId, LazyPage> = {
home: () => import('./features/home/home').then((module) => module.HomePage), home: () => import('./features/home/home').then((module) => module.HomePage),
pitch: () => import('./features/pitch/pitch').then((module) => module.PitchPage),
services: () => import('./features/services/services').then((module) => module.ServicesPage), services: () => import('./features/services/services').then((module) => module.ServicesPage),
servicesSoftware: () => servicesSoftware: () =>
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage), import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),

View File

@@ -16,7 +16,7 @@
.site-header { .site-header {
position: relative; position: relative;
top: 0; top: 0;
z-index: 10; z-index: 30;
padding-block: var(--space-3); padding-block: var(--space-3);
} }
@@ -199,6 +199,7 @@
.primary-nav > li { .primary-nav > li {
position: relative; position: relative;
z-index: 31;
} }
.primary-nav > li > a { .primary-nav > li > a {
@@ -211,20 +212,24 @@
top: 100%; top: 100%;
left: 0; left: 0;
min-width: max-content; min-width: max-content;
z-index: 20; z-index: 32;
padding: var(--space-2) var(--space-3); padding: var(--space-3) var(--space-4);
gap: var(--space-1); gap: var(--space-1);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--surface-glass); background: var(--color-surface-menu);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--surface-glass-border); border: 1px solid var(--surface-glass-border);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-raised);
} }
.primary-nav > li:hover > .submenu,
.primary-nav > li:focus-within > .submenu { .primary-nav > li:focus-within > .submenu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media (hover: hover) and (pointer: fine) {
.primary-nav > li:hover > .submenu {
display: flex;
flex-direction: column;
}
}
} }

View File

@@ -89,16 +89,19 @@ describe('App', () => {
expect(compiled.querySelector('footer')).toBeTruthy(); expect(compiled.querySelector('footer')).toBeTruthy();
}); });
it('keeps the palette trigger in the header and the dialog outside .site', async () => { it('renders the terminal dock outside .site and keeps the header free of a CV link', async () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const site = compiled.querySelector('.site'); const site = compiled.querySelector('.site');
const trigger = compiled.querySelector('header .command-palette-trigger'); const dock = compiled.querySelector('app-terminal-dock');
const trigger = compiled.querySelector('.terminal-dock-trigger');
expect(site).toBeTruthy(); expect(site).toBeTruthy();
expect(trigger).toBeTruthy(); expect(dock).toBeTruthy();
expect(site?.contains(trigger)).toBe(true); expect(site?.contains(dock)).toBe(false);
expect(compiled.querySelector('header a[href="/cv/CV.pdf"]')).toBeNull();
expect(compiled.querySelector('header .command-palette-trigger')).toBeNull();
(trigger as HTMLButtonElement).click(); (trigger as HTMLButtonElement).click();
fixture.detectChanges(); fixture.detectChanges();
@@ -106,10 +109,10 @@ describe('App', () => {
TestBed.inject(ApplicationRef).tick(); TestBed.inject(ApplicationRef).tick();
fixture.detectChanges(); fixture.detectChanges();
const dialog = compiled.querySelector('[role="dialog"]'); const panel = compiled.querySelector('.terminal-dock-panel');
expect(dialog).toBeTruthy(); expect(panel).toBeTruthy();
expect(site?.contains(dialog)).toBe(false); expect(site?.contains(panel)).toBe(false);
expect(site?.hasAttribute('inert')).toBe(true); expect(site?.hasAttribute('inert')).toBe(false);
}); });
it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => { it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => {

View File

@@ -18,9 +18,7 @@ import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
import { LocaleService } from './core/i18n/locale.service'; import { LocaleService } from './core/i18n/locale.service';
import { NavigationService } from './core/navigation/navigation.service'; import { NavigationService } from './core/navigation/navigation.service';
import { isBrowserPlatform, viewportMatches } from './core/platform/browser'; import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
import { CommandPalette } from './shared/command-palette/command-palette'; import { TerminalDock } from './shared/terminal/terminal-dock';
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
import { CommandPaletteService } from './shared/command-palette/command-palette.service';
/** Matches `md` in `src/_breakpoints.scss` (48rem). */ /** Matches `md` in `src/_breakpoints.scss` (48rem). */
export const WIDE_NAV_QUERY = '(min-width: 48rem)'; export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@@ -28,21 +26,13 @@ export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
RouterOutlet,
RouterLink,
RouterLinkActive,
DotBackground,
CommandPalette,
CommandPaletteTrigger,
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
}) })
export class App { export class App {
protected readonly navigation = inject(NavigationService); protected readonly navigation = inject(NavigationService);
protected readonly localeService = inject(LocaleService); protected readonly localeService = inject(LocaleService);
protected readonly palette = inject(CommandPaletteService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);

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

@@ -11,8 +11,10 @@ import { SITE_CONTENT_DATA } from './site-content';
const DELIVERED_OFFER_WORDING = const DELIVERED_OFFER_WORDING =
/für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i; /für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i;
const UNLIMITED_SCOPE = /\balles\b|\banything\b|any problem|jedes Problem|end-to-end für alles/i;
function allMetrics(site: SiteContent): readonly MetricCopy[] { function allMetrics(site: SiteContent): readonly MetricCopy[] {
return [...site.home.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)]; return [...site.pitch.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
} }
function allOfferings(site: SiteContent): readonly OfferingCopy[] { function allOfferings(site: SiteContent): readonly OfferingCopy[] {
@@ -41,7 +43,7 @@ describe('content claims and attribution', () => {
/8/.test(metric.value) && /90/.test(metric.value); /8/.test(metric.value) && /90/.test(metric.value);
const runtimeMetrics = allMetrics(site).filter(matchesRuntime); const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true); expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true);
expect(site.home.metrics.filter(matchesRuntime)).toHaveLength(1); expect(site.pitch.metrics.filter(matchesRuntime)).toHaveLength(1);
expect(site.cases.innofocus.metrics.filter(matchesRuntime)).toHaveLength(1); expect(site.cases.innofocus.metrics.filter(matchesRuntime)).toHaveLength(1);
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) { for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) {
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0); expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
@@ -83,4 +85,53 @@ describe('content claims and attribution', () => {
).toBe(false); ).toBe(false);
} }
}); });
it('keeps Home free of unlimited-scope promises', () => {
for (const locale of APP_LOCALES) {
const home = SITE_CONTENT_DATA[locale].home;
const text = [
home.title,
home.description,
home.hero.headline,
home.hero.proof ?? '',
home.hero.playfulLine ?? '',
...(home.hero.body ?? []),
...home.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
...home.serviceAreas.flatMap((area) => [area.title, area.body]),
...home.process.flatMap((step) => [step.title, step.body]),
home.proofNote,
...home.ctas.map((cta) => cta.label),
].join('\n');
expect(UNLIMITED_SCOPE.test(text), `${locale} home contains unlimited-scope wording`).toBe(
false,
);
}
});
it('keeps pitch timeline roles free of the period date', () => {
for (const locale of APP_LOCALES) {
for (const entry of SITE_CONTENT_DATA[locale].pitch.timeline) {
expect(entry.role, `${locale}.${entry.id}`).not.toMatch(/\d{2}\/\d{4}/);
}
}
});
it('keeps Pitch and Home free of editorial omission commentary', () => {
const editorial =
/not listed on this site|auf dieser Seite zu listen|This page shows the public selection|Diese Seite zeigt die öffentliche Auswahl|What this page covers|Was diese Seite abdeckt/i;
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
const text = [
...site.pitch.profile,
...site.pitch.timeline.map((entry) => entry.body),
...site.pitch.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
...site.home.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
...site.home.serviceAreas.map((area) => area.body),
].join('\n');
expect(editorial.test(text), locale).toBe(false);
}
});
}); });

View File

@@ -39,6 +39,8 @@ describe('content completeness', () => {
const site = SITE_CONTENT_DATA[locale]; const site = SITE_CONTENT_DATA[locale];
expect(site.pages.home).toBe(site.home); expect(site.pages.home).toBe(site.home);
expect(site.pages.pitch).toBe(site.pitch);
expect(site.pages.services).toBe(site.servicesOverview);
expect(site.pages.contact).toBe(site.contact); expect(site.pages.contact).toBe(site.contact);
expect(site.pages.projects).toBe(site.projects); expect(site.pages.projects).toBe(site.projects);
expect(site.pages.stack).toBe(site.stack); expect(site.pages.stack).toBe(site.stack);

View File

@@ -52,7 +52,16 @@ describe('content exclusions', () => {
}); });
const harness = await RouterTestingHarness.create(); const harness = await RouterTestingHarness.create();
const paths = ['/', '/en', '/projekte', '/en/projects', '/ueber-mich', '/en/about']; const paths = [
'/',
'/en',
'/pitch',
'/en/pitch',
'/projekte',
'/en/projects',
'/ueber-mich',
'/en/about',
];
for (const path of paths) { for (const path of paths) {
await harness.navigateByUrl(path); await harness.navigateByUrl(path);

View File

@@ -101,23 +101,43 @@ export interface ServicePageCopy extends PageCopy {
readonly offeringCaseBackedLabel: string; readonly offeringCaseBackedLabel: string;
} }
export interface AudienceEntryCopy { export interface HomeServiceAreaCopy {
readonly id: 'recruiters' | 'companies'; readonly id: 'workplaces' | 'servers' | 'software' | 'ai';
readonly headline: string; readonly title: string;
readonly body: string; readonly body: string;
readonly bullets: readonly string[]; readonly routeId: RouteId;
readonly ctas: readonly CtaCopy[];
} }
export interface HomePageCopy extends PageCopy { export interface HomePageCopy extends PageCopy {
readonly serviceAreas: readonly HomeServiceAreaCopy[];
readonly serviceAreasHeading: string;
readonly process: readonly ProcessStepCopy[];
readonly processHeading: string;
readonly proofCaseId: CaseStudyId;
readonly proofHeading: string;
readonly proofNote: string;
readonly featuredCaseIds: readonly CaseStudyId[];
}
export interface PitchTimelineEntryCopy {
readonly id: string;
readonly period: string;
readonly role: string;
readonly body: string;
}
export interface PitchPageCopy extends PageCopy {
readonly profile: readonly string[]; readonly profile: readonly string[];
readonly metrics: readonly MetricCopy[]; readonly metrics: readonly MetricCopy[];
readonly audiences: readonly AudienceEntryCopy[]; readonly timeline: readonly PitchTimelineEntryCopy[];
readonly coreStack: readonly StackGroupCopy[];
readonly featuredCaseIds: readonly CaseStudyId[]; readonly featuredCaseIds: readonly CaseStudyId[];
readonly systemsMap: { readonly timelineHeading: string;
readonly heading: string; readonly stackHeading: string;
readonly intro: string; }
};
export interface ServicesOverviewPageCopy extends PageCopy {
readonly systemsMap: { readonly heading: string; readonly intro: string };
} }
export type ContactFieldId = export type ContactFieldId =
@@ -198,6 +218,8 @@ export interface SiteSeoCopy {
export interface SiteContent { export interface SiteContent {
readonly pages: Record<RouteId, PageCopy>; readonly pages: Record<RouteId, PageCopy>;
readonly home: HomePageCopy; readonly home: HomePageCopy;
readonly pitch: PitchPageCopy;
readonly servicesOverview: ServicesOverviewPageCopy;
readonly services: Record<ServicePageId, ServicePageCopy>; readonly services: Record<ServicePageId, ServicePageCopy>;
readonly cases: Record<CaseStudyId, CaseStudyCopy>; readonly cases: Record<CaseStudyId, CaseStudyCopy>;
readonly contact: ContactPageCopy; readonly contact: ContactPageCopy;

View File

@@ -9,9 +9,11 @@ import {
type HomePageCopy, type HomePageCopy,
type LegalPageCopy, type LegalPageCopy,
type PageCopy, type PageCopy,
type PitchPageCopy,
type ProjectsPageCopy, type ProjectsPageCopy,
type ServicePageCopy, type ServicePageCopy,
type ServicePageId, type ServicePageId,
type ServicesOverviewPageCopy,
type StackPageCopy, type StackPageCopy,
} from './content.contracts'; } from './content.contracts';
import { SITE_CONTENT } from './content.token'; import { SITE_CONTENT } from './content.token';
@@ -29,6 +31,14 @@ export class ContentService {
return computed(() => this.content[this.localeService.locale()].home); return computed(() => this.content[this.localeService.locale()].home);
} }
pitch(): Signal<PitchPageCopy> {
return computed(() => this.content[this.localeService.locale()].pitch);
}
servicesOverview(): Signal<ServicesOverviewPageCopy> {
return computed(() => this.content[this.localeService.locale()].servicesOverview);
}
service(id: ServicePageId): Signal<ServicePageCopy> { service(id: ServicePageId): Signal<ServicePageCopy> {
return computed(() => this.content[this.localeService.locale()].services[id]); return computed(() => this.content[this.localeService.locale()].services[id]);
} }

View File

@@ -1,103 +1,93 @@
import { SITE_CONFIG } from '../site-config';
import { type HomePageCopy } from '../content.contracts'; import { type HomePageCopy } from '../content.contracts';
export const HOME_DE: HomePageCopy = { export const HOME_DE: HomePageCopy = {
routeId: 'home', routeId: 'home',
title: 'Startseite | Antonio Ledebuhr', title: 'Startseite | Antonio Ledebuhr',
description: description:
'Fullstack- und DevOps-Ingenieur in Tangermünde: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung, freelance seit 04/2023.', 'Du beschreibst, was Dein Unternehmen erreichen soll. Ein technischer Ansprechpartner übernimmt den Weg von der Diagnose bis zum Betrieb oder zur Übergabe.',
hero: { hero: {
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde', headline:
'Du beschreibst, was Dein Unternehmen erreichen soll. Ich kümmere mich um den technischen Weg.',
proof: proof:
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freelance seit 04/2023.', 'Bei der Rösterei Tangermünde reicht die öffentliche Arbeit vom Netz und den Arbeitsplätzen bis zum Shop und zwei internen KI-Werkzeugen.',
playfulLine: 'IT mit Drehmoment', playfulLine: 'IT mit Drehmoment',
body: [
'Du schilderst das Ziel, den Engpass oder den wiederkehrenden Ablauf in Deinen Worten. Ein Ansprechpartner führt von der Diagnose über die Umsetzung bis zum Betrieb oder zur Übergabe.',
],
}, },
profile: [ serviceAreasHeading: 'Wobei ich helfen kann',
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.', serviceAreas: [
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Freelance seit 04/2023, mit Sitz in Tangermünde.',
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
],
metrics: [
{ {
id: 'experience-years', id: 'workplaces',
value: 'ca. 7 Jahre', title: 'Arbeitsplätze und Netz',
label: 'berufliche Erfahrung mit Webanwendungen', body: 'Geräte, Netz und die Dienste, mit denen Dein Team täglich arbeitet, bleiben zuverlässig.',
routeId: 'servicesHardwareNetwork',
}, },
{ {
id: 'innofocus-migration-runtime', id: 'servers',
value: '8 h → ca. 90 min', title: 'Server und Cluster',
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com', body: 'Einzelne Hosts, Virtualisierung oder ein Cluster. Der Weg dorthin bleibt nachvollziehbar, vor Ort oder auf Azure AKS.',
note: 'bei größerem Funktionsumfang und höherer Datenqualität', routeId: 'servicesClusters',
caseId: 'innofocus',
}, },
{ {
id: 'myspa-team-lead', id: 'software',
value: '4 + 2', title: 'Software und Daten',
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa', body: 'Produktsoftware und Datenarbeit: von der Fachlichkeit über die API bis zur Oberfläche, inklusive SQL.',
caseId: 'myspa', routeId: 'servicesSoftware',
},
{
id: 'ai',
title: 'KI-Abläufe',
body: 'Zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde. Weitere Bausteine — lokale Modelle, Suche in eigenen Unterlagen, Prüfung durch einen Menschen — werden je Auftrag geprüft.',
routeId: 'servicesAi',
}, },
], ],
audiences: [ processHeading: 'So läuft die Zusammenarbeit',
process: [
{ {
id: 'recruiters', id: 'describe',
headline: 'Für Recruiterinnen und Recruiter', title: 'Lage in Deinen Worten',
body: 'Ein kurzer Überblick über Erfahrung, ausgewählte Fälle, den technischen Stack und den Lebenslauf.', body: 'Du beschreibst das Ziel, den Schmerz oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
bullets: [
'Rund sieben Jahre Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt und Teamverantwortung.',
'Vier öffentlich dargestellte Fälle aus eCommerce, interner IT, IoT und Versicherung.',
'Heimspiel Java und Spring, dazu C#/.NET, Angular, SQL sowie Docker und Kubernetes.',
'Lebenslauf als PDF zum direkten Download.',
],
ctas: [
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{
label: 'Lebenslauf als PDF',
href: SITE_CONFIG.cvAssetPath,
},
],
}, },
{ {
id: 'companies', id: 'diagnose',
headline: 'Für Unternehmen', title: 'Technische Diagnose',
body: 'Vier Leistungsbereiche, vom ersten Workshop bis zum Betrieb — und ein kurzes Briefing für die Anfrage.', body: 'Ich ordne die Lage den vier Bereichen zu, nenne die Grenze und sage, was außerhalb liegt.',
bullets: [ },
'Software: Fullstack-Produkte mit Java, .NET, Angular und Datenarbeit in SQL.', {
'Hardware und Netzwerk: Server, Geräte, Microsoft 365 und wartbare Infrastruktur.', id: 'implement',
'Cluster: Docker und Kubernetes on-premise und auf Azure AKS, inklusive Pipelines.', title: 'Umsetzung',
'KI-Integration: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde, weitere Bausteine als Angebot.', body: 'Ein Ansprechpartner setzt um: Software, Infrastruktur, Cluster oder ein klar umrissenes KI-Werkzeug, je nach Briefing.',
], },
ctas: [ {
{ label: 'Leistungen ansehen', routeId: 'services' }, id: 'operate',
{ label: 'Projekt anfragen', routeId: 'contact' }, title: 'Betrieb oder Übergabe',
], body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen, kein undokumentierter Zwischenstand.',
}, },
], ],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'], proofCaseId: 'roesterei',
systemsMap: { proofHeading: 'Ein belegter Weg vom Netz bis zum Shop',
heading: 'Wie die Schichten zusammenhängen', proofNote: 'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.', featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
},
sections: [ sections: [
{ {
id: 'profile', id: 'scope',
headline: 'In dreißig Sekunden', headline: 'Vier Arbeitsbereiche',
body: [ body: [
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Diese Seite zeigt eine kuratierte Auswahl von Stationen; der vollständige Lebenslauf steht als PDF bereit.', 'Vier Bereiche: Arbeitsplätze und Netz, Server und Cluster, Software und Daten sowie KI-Abläufe. Was nicht dazu gehört, wird im Briefing benannt — nicht stillschweigend mitverkauft.',
], ],
}, },
{ {
id: 'featured-cases', id: 'proof',
headline: 'Ausgewählte Fälle', headline: 'Warum die Rösterei als Beleg steht',
body: [ body: [
'Die vier öffentlichen Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.', 'Die Rösterei Tangermünde ist der öffentliche Fall, der die Kette vom Netz bis zum Shop zeigt. Die übrigen drei Fälle stützen einzelne Bereiche.',
], ],
}, },
], ],
ctas: [ ctas: [
{ label: 'Leistungen', routeId: 'services' }, { label: 'Projektbriefing starten', routeId: 'contact' },
{ label: 'Projekte', routeId: 'projects' }, { label: 'Leistungen ansehen', routeId: 'services' },
{ label: 'Kontakt', routeId: 'contact' }, { label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
], ],
}; };

View File

@@ -35,6 +35,7 @@ export const PROJECTS_DE: ProjectsPageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath }, { label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{ label: 'Kontakt', routeId: 'contact' }, { label: 'Kontakt', routeId: 'contact' },
], ],
caseLabels: { caseLabels: {
@@ -134,6 +135,7 @@ export const ABOUT_DE: PageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Projekte', routeId: 'projects' }, { label: 'Projekte', routeId: 'projects' },
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
{ label: 'Kontakt', routeId: 'contact' }, { label: 'Kontakt', routeId: 'contact' },
], ],
}; };
@@ -173,8 +175,10 @@ export const CONTACT_DE: ContactPageCopy = {
id: 'projectType', id: 'projectType',
control: 'select', control: 'select',
label: 'Art des Vorhabens', label: 'Art des Vorhabens',
required: true, hint: 'freiwillig — wenn noch unklar, einfach offen lassen oder „Noch unklar“ wählen',
required: false,
options: [ options: [
{ value: 'unsure', label: 'Noch unklar' },
{ value: 'software', label: 'Software' }, { value: 'software', label: 'Software' },
{ value: 'hardware-network', label: 'Hardware und Netzwerk' }, { value: 'hardware-network', label: 'Hardware und Netzwerk' },
{ value: 'clusters', label: 'Cluster' }, { value: 'clusters', label: 'Cluster' },

View File

@@ -0,0 +1,133 @@
import { SITE_CONFIG } from '../site-config';
import { type PitchPageCopy } from '../content.contracts';
export const PITCH_DE: PitchPageCopy = {
routeId: 'pitch',
title: 'Profil | Antonio Ledebuhr',
description:
'Fullstack- und DevOps-Ingenieur in Tangermünde: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung. Freiberuflich seit 04/2023.',
hero: {
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
proof:
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freiberuflich seit 04/2023.',
playfulLine: 'IT mit Drehmoment',
},
profile: [
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.',
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Freiberuflich seit 04/2023, mit Sitz in Tangermünde.',
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
],
metrics: [
{
id: 'experience-years',
value: 'ca. 7 Jahre',
label: 'berufliche Erfahrung mit Webanwendungen',
},
{
id: 'innofocus-migration-runtime',
value: '8 h → ca. 90 min',
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
caseId: 'innofocus',
},
{
id: 'myspa-team-lead',
value: '4 + 2',
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
caseId: 'myspa',
},
],
timelineHeading: 'Stationen',
timeline: [
{
id: 'self-employed',
period: 'seit 12/2021',
role: 'unternehmerisch selbständig',
body: 'Eigene Verantwortung für Auftrag, Technik und Betrieb; eigene Rechnungsstellung und Kundenbeziehungen.',
},
{
id: 'freelance',
period: 'seit 04/2023',
role: 'freiberuflich',
body: 'Freiberufliche Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt, vom Anforderungsworkshop bis in den Produktivbetrieb.',
},
{
id: 'hdi',
period: '05/2023 01/2024',
role: 'Senior Fullstack / DevOps bei HDI Specialty',
body: 'Fullstack- und DevOps-Verantwortung in einem regulierten Versicherungsumfeld.',
},
{
id: 'roesterei',
period: 'seit 10/2023',
role: 'Gesamtverantwortung für die Technik der Rösterei Tangermünde',
body: 'Ein Ansprechpartner für die gesamte Betriebstechnik; die wirtschaftliche Beteiligung bleibt offengelegt.',
},
{
id: 'myspa',
period: '03/2024 09/2024',
role: 'Lead Backend / DevOps bei Aracom IT Services / MySpa',
body: 'Führung von Backend und DevOps bis zum Softwarestart, Anforderungen direkt vom Kunden.',
},
{
id: 'innofocus',
period: 'seit 09/2025',
role: 'Senior Backend- und Datenbankingenieur bei Innofocus / reifen.com',
body: 'Alleinverantwortung für die Migration, als einziger Freiberufler im direkten Kontakt mit dem Endkunden.',
},
],
stackHeading: 'Kernstack',
coreStack: [
{
id: 'java',
title: 'Java und Spring',
body: 'Heimspiel: Java mit Spring Boot, Spring Data und Tests.',
},
{ id: 'dotnet', title: 'C# und .NET', body: 'ASP.NET Core, Entity Framework und xUnit.' },
{
id: 'angular',
title: 'Angular und TypeScript',
body: 'Angular-SPAs, RxJS und SCSS.',
},
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL und MariaDB.' },
{
id: 'containers',
title: 'Docker und Kubernetes',
body: 'Container und Cluster vor Ort und in der Cloud.',
},
{
id: 'azure',
title: 'Azure einschließlich AKS',
body: 'Azure AKS, Azure DevOps und verwandte Cloud-Dienste.',
},
{
id: 'cicd',
title: 'GitLab CI/CD und Azure DevOps',
body: 'Pipelines, GitOps mit Argo CD wo es zum Auftrag passt.',
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
sections: [
{
id: 'profile',
headline: 'In dreißig Sekunden',
body: [
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Der Lebenslauf als PDF führt die Stationen vollständig.',
],
},
{
id: 'featured-cases',
headline: 'Ausgewählte Fälle',
body: [
'Vier Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
],
},
],
ctas: [
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'E-Mail schreiben', href: `mailto:${SITE_CONFIG.contactEmail}` },
],
};

View File

@@ -1,6 +1,6 @@
import { type PageCopy, type ServicePageCopy } from '../content.contracts'; import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_DE: PageCopy = { export const SERVICES_OVERVIEW_DE: ServicesOverviewPageCopy = {
routeId: 'services', routeId: 'services',
title: 'Leistungen | Antonio Ledebuhr', title: 'Leistungen | Antonio Ledebuhr',
description: description:
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_DE: PageCopy = {
{ label: 'KI-Integration', routeId: 'servicesAi' }, { label: 'KI-Integration', routeId: 'servicesAi' },
{ label: 'Projekt anfragen', routeId: 'contact' }, { label: 'Projekt anfragen', routeId: 'contact' },
], ],
systemsMap: {
heading: 'Wie die Schichten zusammenhängen',
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
},
}; };
export const SERVICES_SOFTWARE_DE: ServicePageCopy = { export const SERVICES_SOFTWARE_DE: ServicePageCopy = {

View File

@@ -1,6 +1,7 @@
import { type SiteContent } from '../content.contracts'; import { type SiteContent } from '../content.contracts';
import { CASES_DE } from './cases'; import { CASES_DE } from './cases';
import { HOME_DE } from './home'; import { HOME_DE } from './home';
import { PITCH_DE } from './pitch';
import { import {
ABOUT_DE, ABOUT_DE,
CONTACT_DE, CONTACT_DE,
@@ -20,6 +21,8 @@ import {
export const SITE_CONTENT_DE: SiteContent = { export const SITE_CONTENT_DE: SiteContent = {
home: HOME_DE, home: HOME_DE,
pitch: PITCH_DE,
servicesOverview: SERVICES_OVERVIEW_DE,
services: { services: {
servicesSoftware: SERVICES_SOFTWARE_DE, servicesSoftware: SERVICES_SOFTWARE_DE,
servicesHardwareNetwork: SERVICES_HARDWARE_DE, servicesHardwareNetwork: SERVICES_HARDWARE_DE,
@@ -42,6 +45,7 @@ export const SITE_CONTENT_DE: SiteContent = {
}, },
pages: { pages: {
home: HOME_DE, home: HOME_DE,
pitch: PITCH_DE,
services: SERVICES_OVERVIEW_DE, services: SERVICES_OVERVIEW_DE,
servicesSoftware: SERVICES_SOFTWARE_DE, servicesSoftware: SERVICES_SOFTWARE_DE,
servicesHardwareNetwork: SERVICES_HARDWARE_DE, servicesHardwareNetwork: SERVICES_HARDWARE_DE,

View File

@@ -1,103 +1,93 @@
import { SITE_CONFIG } from '../site-config';
import { type HomePageCopy } from '../content.contracts'; import { type HomePageCopy } from '../content.contracts';
export const HOME_EN: HomePageCopy = { export const HOME_EN: HomePageCopy = {
routeId: 'home', routeId: 'home',
title: 'Home | Antonio Ledebuhr', title: 'Home | Antonio Ledebuhr',
description: description:
'Fullstack and DevOps engineer in Tangermünde: about seven years of web application work, direct customer contact and team responsibility, freelance since 04/2023.', 'You describe what the business needs to achieve. One technical contact takes the path from diagnosis through delivery to operations or handover.',
hero: { hero: {
headline: 'Fullstack and DevOps engineer based in Tangermünde', headline:
'You describe what the business needs to achieve. I take the technical path from there.',
proof: proof:
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.', 'At Rösterei Tangermünde the public work runs from the network and workplaces through to the shop and two in-house AI tools.',
playfulLine: 'Full-stack, full throttle', playfulLine: 'Full-stack, full throttle',
body: [
'You put the outcome, the bottleneck or the recurring process in your own words. One person then handles diagnosis, implementation and operations or handover.',
],
}, },
profile: [ serviceAreasHeading: 'What I can help with',
'About seven years of professional experience building and operating web applications.', serviceAreas: [
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
'Freelance since 04/2023, based in Tangermünde.',
'Home ground is Java with Spring; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
],
metrics: [
{ {
id: 'experience-years', id: 'workplaces',
value: 'about 7 years', title: 'Workplaces and network',
label: 'professional experience with web applications', body: 'Devices, the network and the everyday services your team uses stay reliable.',
routeId: 'servicesHardwareNetwork',
}, },
{ {
id: 'innofocus-migration-runtime', id: 'servers',
value: '8 h → about 90 min', title: 'Servers and clusters',
label: 'data-migration run time at Innofocus / reifen.com', body: 'A single host, virtualisation or a cluster. The path there stays clear, on-premise or on Azure AKS.',
note: 'with a larger feature scope and higher data quality', routeId: 'servicesClusters',
caseId: 'innofocus',
}, },
{ {
id: 'myspa-team-lead', id: 'software',
value: '4 + 2', title: 'Software and data',
label: 'led the backend team and the DevOps team through the MySpa launch', body: 'Product software and data work: from the domain through the API to the interface, including SQL.',
caseId: 'myspa', routeId: 'servicesSoftware',
},
{
id: 'ai',
title: 'AI workflows',
body: 'Two delivered tools at Rösterei Tangermünde. Further building blocks — local models, search over your own documents, review by a person — are agreed for each assignment.',
routeId: 'servicesAi',
}, },
], ],
audiences: [ processHeading: 'How the work runs',
process: [
{ {
id: 'recruiters', id: 'describe',
headline: 'For recruiters', title: 'The situation in your words',
body: 'A short path through experience, selected cases, the public stack and the curriculum vitae.', body: 'You describe the goal, the pain or the recurring process. Technical language is not required.',
bullets: [
'About seven years of fullstack and DevOps work with direct customer contact and team responsibility.',
'Four public cases across eCommerce, in-house IT, IoT and insurance.',
'Home ground Java and Spring, plus C#/.NET, Angular, SQL, Docker and Kubernetes.',
'Curriculum vitae as a PDF download.',
],
ctas: [
{ label: 'Selected projects', routeId: 'projects' },
{ label: 'View the stack', routeId: 'stack' },
{
label: 'Curriculum vitae as PDF',
href: SITE_CONFIG.cvAssetPath,
},
],
}, },
{ {
id: 'companies', id: 'diagnose',
headline: 'For companies', title: 'Technical diagnosis',
body: 'Four service areas, from the first workshop through to operations — and a short briefing for an enquiry.', body: 'I map the brief onto the four areas, name the boundary and say what sits outside it.',
bullets: [ },
'Software: fullstack product work with Java, .NET, Angular and SQL data work.', {
'Hardware and network: servers, devices, Microsoft 365 and maintainable infrastructure.', id: 'implement',
'Clusters: Docker and Kubernetes on-premise and on Azure AKS, including pipelines.', title: 'Implementation',
'AI integration: two delivered tools at Rösterei Tangermünde, further building blocks as an offer.', body: 'One person delivers: software, infrastructure, a cluster or a clearly defined AI tool, according to the brief.',
], },
ctas: [ {
{ label: 'Browse services', routeId: 'services' }, id: 'operate',
{ label: 'Start a project enquiry', routeId: 'contact' }, title: 'Operations or handover',
], body: 'The result stays operable. Handover means structures you can follow, not an undocumented snapshot.',
}, },
], ],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'], proofCaseId: 'roesterei',
systemsMap: { proofHeading: 'A verified path from the network to the shop',
heading: 'How the layers connect', proofNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.', featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
},
sections: [ sections: [
{ {
id: 'profile', id: 'scope',
headline: 'Thirty seconds', headline: 'Four areas of work',
body: [ body: [
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. This site shows a curated selection of stations; the full curriculum vitae is available as a PDF.', 'Four areas: workplaces and network, servers and clusters, software and data, and AI workflows. What sits outside that set is named in the briefing — it is not sold by implication.',
], ],
}, },
{ {
id: 'featured-cases', id: 'proof',
headline: 'Selected cases', headline: 'Why the roastery is the proof',
body: [ body: [
'The four public cases cover eCommerce and ERP, shop-floor IT, IoT and insurance. Each case is written in full on the projects page.', 'Rösterei Tangermünde is the public case that shows the chain from the network to the shop. The other three cases support individual areas.',
], ],
}, },
], ],
ctas: [ ctas: [
{ label: 'Services', routeId: 'services' }, { label: 'Start a project briefing', routeId: 'contact' },
{ label: 'Projects', routeId: 'projects' }, { label: 'Browse services', routeId: 'services' },
{ label: 'Contact', routeId: 'contact' }, { label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
], ],
}; };

View File

@@ -35,6 +35,7 @@ export const PROJECTS_EN: ProjectsPageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath }, { label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'View the stack', routeId: 'stack' },
{ label: 'Contact', routeId: 'contact' }, { label: 'Contact', routeId: 'contact' },
], ],
caseLabels: { caseLabels: {
@@ -134,6 +135,7 @@ export const ABOUT_EN: PageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Projects', routeId: 'projects' }, { label: 'Projects', routeId: 'projects' },
{ label: 'For recruiters', routeId: 'pitch' },
{ label: 'Contact', routeId: 'contact' }, { label: 'Contact', routeId: 'contact' },
], ],
}; };
@@ -173,8 +175,10 @@ export const CONTACT_EN: ContactPageCopy = {
id: 'projectType', id: 'projectType',
control: 'select', control: 'select',
label: 'Type of work', label: 'Type of work',
required: true, hint: 'optional — leave blank or pick “Not sure yet” if the category is still open',
required: false,
options: [ options: [
{ value: 'unsure', label: 'Not sure yet' },
{ value: 'software', label: 'Software' }, { value: 'software', label: 'Software' },
{ value: 'hardware-network', label: 'Hardware and network' }, { value: 'hardware-network', label: 'Hardware and network' },
{ value: 'clusters', label: 'Clusters' }, { value: 'clusters', label: 'Clusters' },

View File

@@ -0,0 +1,133 @@
import { SITE_CONFIG } from '../site-config';
import { type PitchPageCopy } from '../content.contracts';
export const PITCH_EN: PitchPageCopy = {
routeId: 'pitch',
title: 'Profile | Antonio Ledebuhr',
description:
'Fullstack and DevOps engineer in Tangermünde: about seven years of web application work, direct customer contact and team responsibility. Freelance since 04/2023.',
hero: {
headline: 'Fullstack and DevOps engineer based in Tangermünde',
proof:
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.',
playfulLine: 'Full-stack, full throttle',
},
profile: [
'About seven years of professional experience building and operating web applications.',
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and line management and team leadership.',
'Freelance since 04/2023, based in Tangermünde.',
'Java with Spring is the home stack; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
],
metrics: [
{
id: 'experience-years',
value: 'about 7 years',
label: 'professional experience with web applications',
},
{
id: 'innofocus-migration-runtime',
value: '8 h → about 90 min',
label: 'data-migration run time at Innofocus / reifen.com',
note: 'with a larger feature scope and higher data quality',
caseId: 'innofocus',
},
{
id: 'myspa-team-lead',
value: '4 + 2',
label: 'led the backend team and the DevOps team through the MySpa launch',
caseId: 'myspa',
},
],
timelineHeading: 'Stations',
timeline: [
{
id: 'self-employed',
period: 'since 12/2021',
role: 'entrepreneurially self-employed',
body: 'Own responsibility for the brief, the technology and operations; own invoicing and client relationships.',
},
{
id: 'freelance',
period: 'since 04/2023',
role: 'freelance',
body: 'Freelance fullstack and DevOps work with direct customer contact, from the requirements workshop through to production.',
},
{
id: 'hdi',
period: '05/2023 01/2024',
role: 'Senior fullstack / DevOps at HDI Specialty',
body: 'Fullstack and DevOps ownership in a regulated insurance setting.',
},
{
id: 'roesterei',
period: 'since 10/2023',
role: 'Full technical responsibility at Rösterei Tangermünde',
body: 'One contact for the companys technology as a whole; the economic stake stays disclosed.',
},
{
id: 'myspa',
period: '03/2024 09/2024',
role: 'Lead backend / DevOps at Aracom IT Services / MySpa',
body: 'Led backend and DevOps through go-live, taking requirements directly from the customer.',
},
{
id: 'innofocus',
period: 'since 09/2025',
role: 'Senior backend and database engineer at Innofocus / reifen.com',
body: 'Sole ownership of the migration, as the only freelancer in direct contact with the end customer.',
},
],
stackHeading: 'Core stack',
coreStack: [
{
id: 'java',
title: 'Java and Spring',
body: 'Home stack: Java with Spring Boot, Spring Data and tests.',
},
{ id: 'dotnet', title: 'C# and .NET', body: 'ASP.NET Core, Entity Framework and xUnit.' },
{
id: 'angular',
title: 'Angular and TypeScript',
body: 'Angular SPAs with RxJS and SCSS.',
},
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL and MariaDB.' },
{
id: 'containers',
title: 'Docker and Kubernetes',
body: 'Containers and clusters on-premise and in the cloud.',
},
{
id: 'azure',
title: 'Azure including AKS',
body: 'Azure AKS, Azure DevOps and related cloud services.',
},
{
id: 'cicd',
title: 'GitLab CI/CD and Azure DevOps',
body: 'Pipelines, and GitOps with Argo CD where the brief fits.',
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
sections: [
{
id: 'profile',
headline: 'Thirty seconds',
body: [
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. The CV PDF carries the full record.',
],
},
{
id: 'featured-cases',
headline: 'Selected cases',
body: [
'Four cases cover eCommerce and ERP, shop IT, IoT and insurance. Each case is written in full on the projects page.',
],
},
],
ctas: [
{ label: 'Selected projects', routeId: 'projects' },
{ label: 'View the stack', routeId: 'stack' },
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'Write an email', href: `mailto:${SITE_CONFIG.contactEmail}` },
],
};

View File

@@ -1,6 +1,6 @@
import { type PageCopy, type ServicePageCopy } from '../content.contracts'; import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_EN: PageCopy = { export const SERVICES_OVERVIEW_EN: ServicesOverviewPageCopy = {
routeId: 'services', routeId: 'services',
title: 'Services | Antonio Ledebuhr', title: 'Services | Antonio Ledebuhr',
description: description:
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_EN: PageCopy = {
{ label: 'AI integration', routeId: 'servicesAi' }, { label: 'AI integration', routeId: 'servicesAi' },
{ label: 'Start a project enquiry', routeId: 'contact' }, { label: 'Start a project enquiry', routeId: 'contact' },
], ],
systemsMap: {
heading: 'How the layers connect',
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
},
}; };
export const SERVICES_SOFTWARE_EN: ServicePageCopy = { export const SERVICES_SOFTWARE_EN: ServicePageCopy = {

View File

@@ -1,6 +1,7 @@
import { type SiteContent } from '../content.contracts'; import { type SiteContent } from '../content.contracts';
import { CASES_EN } from './cases'; import { CASES_EN } from './cases';
import { HOME_EN } from './home'; import { HOME_EN } from './home';
import { PITCH_EN } from './pitch';
import { import {
ABOUT_EN, ABOUT_EN,
CONTACT_EN, CONTACT_EN,
@@ -20,6 +21,8 @@ import {
export const SITE_CONTENT_EN: SiteContent = { export const SITE_CONTENT_EN: SiteContent = {
home: HOME_EN, home: HOME_EN,
pitch: PITCH_EN,
servicesOverview: SERVICES_OVERVIEW_EN,
services: { services: {
servicesSoftware: SERVICES_SOFTWARE_EN, servicesSoftware: SERVICES_SOFTWARE_EN,
servicesHardwareNetwork: SERVICES_HARDWARE_EN, servicesHardwareNetwork: SERVICES_HARDWARE_EN,
@@ -42,6 +45,7 @@ export const SITE_CONTENT_EN: SiteContent = {
}, },
pages: { pages: {
home: HOME_EN, home: HOME_EN,
pitch: PITCH_EN,
services: SERVICES_OVERVIEW_EN, services: SERVICES_OVERVIEW_EN,
servicesSoftware: SERVICES_SOFTWARE_EN, servicesSoftware: SERVICES_SOFTWARE_EN,
servicesHardwareNetwork: SERVICES_HARDWARE_EN, servicesHardwareNetwork: SERVICES_HARDWARE_EN,

View File

@@ -8,7 +8,6 @@ export interface ShellCopy {
readonly menuClose: string; readonly menuClose: string;
readonly languageSwitch: string; readonly languageSwitch: string;
readonly otherLocaleName: string; readonly otherLocaleName: string;
readonly cvLabel: string;
readonly contactCta: string; readonly contactCta: string;
} }
@@ -21,7 +20,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
menuClose: 'Menü schließen', menuClose: 'Menü schließen',
languageSwitch: 'Zur englischen Version wechseln', languageSwitch: 'Zur englischen Version wechseln',
otherLocaleName: 'English', otherLocaleName: 'English',
cvLabel: 'Lebenslauf als PDF',
contactCta: 'Kontakt', contactCta: 'Kontakt',
}, },
en: { en: {
@@ -32,7 +30,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
menuClose: 'Close menu', menuClose: 'Close menu',
languageSwitch: 'Switch to the German version', languageSwitch: 'Switch to the German version',
otherLocaleName: 'Deutsch', otherLocaleName: 'Deutsch',
cvLabel: 'Curriculum vitae as PDF',
contactCta: 'Contact', contactCta: 'Contact',
}, },
}; };

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,31 @@ 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 maximizeLabel: string;
readonly outputLabel: string; readonly outputLabel: 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 +49,38 @@ 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', maximizeLabel: 'Terminal vergrößern',
outputLabel: 'Ausgabe', outputLabel: 'Ausgabe',
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 +102,38 @@ 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', maximizeLabel: 'Maximise the terminal',
outputLabel: 'Output', outputLabel: 'Output',
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

@@ -5,6 +5,7 @@ import { routes } from '../../app.routes';
import { SITE_CONTENT } from '../content/content.token'; import { SITE_CONTENT } from '../content/content.token';
import { SITE_CONTENT_DATA } from '../content/site-content'; import { SITE_CONTENT_DATA } from '../content/site-content';
import { LocaleService } from '../i18n/locale.service'; import { LocaleService } from '../i18n/locale.service';
import { PRIMARY_NAV } from './navigation';
import { NavigationService } from './navigation.service'; import { NavigationService } from './navigation.service';
describe('NavigationService', () => { describe('NavigationService', () => {
@@ -40,4 +41,11 @@ describe('NavigationService', () => {
expect(navigation.link('home')).toEqual(['/', 'en']); expect(navigation.link('home')).toEqual(['/', 'en']);
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']); expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
}); });
it('keeps primary nav to home, services, projects, about and contact', () => {
const ids = PRIMARY_NAV.map((item) => item.routeId);
expect(ids).toEqual(['home', 'services', 'projects', 'about', 'contact']);
expect(ids).not.toContain('stack');
expect(ids).not.toContain('pitch');
});
}); });

View File

@@ -38,10 +38,6 @@ export const PRIMARY_NAV: readonly NavItem[] = [
routeId: 'projects', routeId: 'projects',
label: { de: 'Projekte', en: 'Projects' }, label: { de: 'Projekte', en: 'Projects' },
}, },
{
routeId: 'stack',
label: { de: 'Stack', en: 'Stack' },
},
{ {
routeId: 'about', routeId: 'about',
label: { de: 'Über mich', en: 'About' }, label: { de: 'Über mich', en: 'About' },

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,5 +1,6 @@
export type RouteId = export type RouteId =
| 'home' | 'home'
| 'pitch'
| 'services' | 'services'
| 'servicesSoftware' | 'servicesSoftware'
| 'servicesHardwareNetwork' | 'servicesHardwareNetwork'
@@ -15,6 +16,7 @@ export type RouteId =
export const ROUTE_IDS: readonly RouteId[] = [ export const ROUTE_IDS: readonly RouteId[] = [
'home', 'home',
'pitch',
'services', 'services',
'servicesSoftware', 'servicesSoftware',
'servicesHardwareNetwork', 'servicesHardwareNetwork',

View File

@@ -27,6 +27,14 @@ describe('route paths', () => {
expect(routePath('projects', 'en')).toBe('/en/projects'); expect(routePath('projects', 'en')).toBe('/en/projects');
}); });
it('exposes pitch in both locales and keeps prerenderablePaths at 26', () => {
expect(ROUTE_SEGMENTS.de.pitch).toBe('pitch');
expect(ROUTE_SEGMENTS.en.pitch).toBe('pitch');
expect(routePath('pitch', 'de')).toBe('/pitch');
expect(routePath('pitch', 'en')).toBe('/en/pitch');
expect(prerenderablePaths()).toHaveLength(26);
});
it('includes both locales in prerenderable paths and excludes the wildcard', () => { it('includes both locales in prerenderable paths and excludes the wildcard', () => {
const paths = prerenderablePaths(); const paths = prerenderablePaths();

View File

@@ -9,6 +9,7 @@ export const LOCALE_PREFIX: Record<AppLocale, string> = {
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = { export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
de: { de: {
home: '', home: '',
pitch: 'pitch',
services: 'leistungen', services: 'leistungen',
servicesSoftware: 'leistungen/software', servicesSoftware: 'leistungen/software',
servicesHardwareNetwork: 'leistungen/hardware-netzwerk', servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
@@ -24,6 +25,7 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
}, },
en: { en: {
home: '', home: '',
pitch: 'pitch',
services: 'services', services: 'services',
servicesSoftware: 'services/software', servicesSoftware: 'services/software',
servicesHardwareNetwork: 'services/hardware-network', servicesHardwareNetwork: 'services/hardware-network',

View File

@@ -113,6 +113,8 @@ describe('crawl assets', () => {
const required = [ const required = [
absoluteUrl(routePath('home', 'de')), absoluteUrl(routePath('home', 'de')),
absoluteUrl(routePath('home', 'en')), absoluteUrl(routePath('home', 'en')),
absoluteUrl(routePath('pitch', 'de')),
absoluteUrl(routePath('pitch', 'en')),
absoluteUrl(routePath('about', 'de')), absoluteUrl(routePath('about', 'de')),
absoluteUrl(routePath('about', 'en')), absoluteUrl(routePath('about', 'en')),
absoluteUrl(routePath('services', 'de')), absoluteUrl(routePath('services', 'de')),

View File

@@ -58,4 +58,28 @@ describe('SeoService', () => {
); );
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1); expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
}); });
it('writes pitch metadata, canonical and reciprocal hreflang', () => {
const seo = TestBed.inject(SeoService);
seo.apply('pitch', 'de');
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`,
);
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'index, follow',
);
expect(
document.querySelector('link[rel="alternate"][hreflang="de-DE"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
expect(
document.querySelector('link[rel="alternate"][hreflang="en"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'en')}`);
expect(
document.querySelector('link[rel="alternate"][hreflang="x-default"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
expect(document.querySelector('script[type="application/ld+json"]')?.textContent).toContain(
'WebPage',
);
});
}); });

View File

@@ -61,6 +61,7 @@ describe('JSON-LD builders', () => {
const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA); const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA);
expect(home['@context']).toBe('https://schema.org'); expect(home['@context']).toBe('https://schema.org');
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']); expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']);
for (const routeId of SERVICE_ROUTES) { for (const routeId of SERVICE_ROUTES) {
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']); expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']);

View File

@@ -3,40 +3,39 @@
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" /> <app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
@for (section of copy.sections; track section.id) { @for (section of copy.sections; track section.id) {
<app-content-section [section]="section" /> <app-content-section [section]="section" />
@if (section.id === 'profile') { @if (section.id === 'scope') {
<ul class="stack"> <section class="stack" [attr.aria-labelledby]="'home-service-areas'">
@for (line of copy.profile; track $index) { <h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2>
<li>{{ line }}</li> <ul class="home-service-areas">
} @for (area of copy.serviceAreas; track area.id) {
</ul> <li>
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" /> <a
<div appReveal class="home-reveal"> class="home-service-card glass-surface stack"
<app-systems-map [routerLink]="areaLink(area.routeId)"
[heading]="copy.systemsMap.heading" >
[intro]="copy.systemsMap.intro" <h3>{{ area.title }}</h3>
[headingLevel]="2" <p>{{ area.body }}</p>
/> </a>
</div> </li>
}
</ul>
</section>
<section class="stack" [attr.aria-labelledby]="'home-process'">
<h2 id="home-process">{{ copy.processHeading }}</h2>
<app-process-steps [steps]="copy.process" [labelledBy]="'home-process'" />
</section>
} }
@if (section.id === 'featured-cases') { @if (section.id === 'proof') {
<div appReveal class="home-reveal stack"> <div appReveal class="home-reveal stack">
<h2 id="home-proof">{{ copy.proofHeading }}</h2>
<app-case-card [caseStudy]="proofCase()" />
<p class="home-proof-note" role="note">{{ copy.proofNote }}</p>
@for (caseStudy of featuredCases(); track caseStudy.id) { @for (caseStudy of featuredCases(); track caseStudy.id) {
<app-case-card [caseStudy]="caseStudy" /> <app-case-card [caseStudy]="caseStudy" />
} }
</div> </div>
} }
} }
@for (audience of copy.audiences; track audience.id) { <app-cta-row [ctas]="copy.ctas" />
<section class="stack" [id]="audience.id" [attr.aria-labelledby]="'audience-' + audience.id">
<h2 [id]="'audience-' + audience.id">{{ audience.headline }}</h2>
<p>{{ audience.body }}</p>
<ul>
@for (bullet of audience.bullets; track $index) {
<li>{{ bullet }}</li>
}
</ul>
<app-cta-row [ctas]="audience.ctas" />
</section>
}
</div> </div>
} }

View File

@@ -11,8 +11,51 @@
font-weight: 600; font-weight: 600;
} }
.page h3 {
margin: 0;
font-size: var(--text-lg);
font-weight: 600;
}
.page p, .page p,
.page li { .page li {
max-width: 40rem; max-width: 40rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.home-service-areas {
display: grid;
gap: var(--space-4);
list-style: none;
margin: 0;
padding: 0;
}
.home-service-card {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border-radius: var(--radius-md);
color: inherit;
text-decoration: none;
}
.home-service-card p {
margin: 0;
}
.home-proof-note {
margin: 0;
}
@media (hover: hover) and (pointer: fine) {
.home-service-card:hover {
border-color: var(--surface-glass-border-strong);
}
}
@media (prefers-reduced-motion: reduce) {
.home-service-card {
transition: none;
}
}

View File

@@ -1,26 +1,34 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ContentService } from '../../core/content/content.service'; import { ContentService } from '../../core/content/content.service';
import { type RouteId } from '../../core/routing/route-ids';
import { NavigationService } from '../../core/navigation/navigation.service';
import { CaseCard } from '../../shared/case-card/case-card'; import { CaseCard } from '../../shared/case-card/case-card';
import { ContentSection } from '../../shared/content-section/content-section'; import { ContentSection } from '../../shared/content-section/content-section';
import { CtaRow } from '../../shared/cta-row/cta-row'; import { CtaRow } from '../../shared/cta-row/cta-row';
import { MetricList } from '../../shared/metric-list/metric-list';
import { RevealDirective } from '../../shared/motion/reveal.directive'; import { RevealDirective } from '../../shared/motion/reveal.directive';
import { PageHero } from '../../shared/page-hero/page-hero'; import { PageHero } from '../../shared/page-hero/page-hero';
import { SystemsMap } from '../../shared/systems-map/systems-map'; import { ProcessSteps } from '../../shared/process-steps/process-steps';
@Component({ @Component({
selector: 'app-home-page', selector: 'app-home-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow, SystemsMap, RevealDirective], imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow, RevealDirective, RouterLink],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.scss', styleUrl: './home.scss',
}) })
export class HomePage { export class HomePage {
private readonly content = inject(ContentService); private readonly content = inject(ContentService);
private readonly navigation = inject(NavigationService);
protected readonly page = this.content.home(); protected readonly page = this.content.home();
protected readonly proofCase = computed(() => this.content.caseStudy(this.page().proofCaseId)());
protected readonly featuredCases = computed(() => { protected readonly featuredCases = computed(() => {
const ids = this.page().featuredCaseIds; const ids = this.page().featuredCaseIds;
return ids.map((id) => this.content.caseStudy(id)()); return ids.map((id) => this.content.caseStudy(id)());
}); });
protected areaLink(routeId: RouteId): unknown[] {
return this.navigation.link(routeId);
}
} }

View File

@@ -4,7 +4,6 @@ import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
import { SITE_CONTENT } from '../core/content/content.token'; import { SITE_CONTENT } from '../core/content/content.token';
import { SITE_CONTENT_DATA } from '../core/content/site-content'; import { SITE_CONTENT_DATA } from '../core/content/site-content';
import { SITE_CONFIG } from '../core/content/site-config';
import { CASE_STUDY_IDS } from '../core/content/content.contracts'; import { CASE_STUDY_IDS } from '../core/content/content.contracts';
import { APP_LOCALES } from '../core/i18n/locale'; import { APP_LOCALES } from '../core/i18n/locale';
import { routePath } from '../core/routing/route-paths'; import { routePath } from '../core/routing/route-paths';
@@ -67,6 +66,8 @@ describe('page rendering, semantics and accessibility', () => {
const paths = [ const paths = [
'/', '/',
'/en', '/en',
'/pitch',
'/en/pitch',
'/leistungen/software', '/leistungen/software',
'/en/services/software', '/en/services/software',
'/projekte', '/projekte',
@@ -111,7 +112,7 @@ describe('page rendering, semantics and accessibility', () => {
} }
}); });
it('exposes case anchors, audience entries and the CV download', async () => { it('exposes case anchors and the Rösterei proof on Home', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }], providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
}); });
@@ -129,37 +130,69 @@ describe('page rendering, semantics and accessibility', () => {
for (const path of ['/', '/en']) { for (const path of ['/', '/en']) {
await harness.navigateByUrl(path); await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement; const root = harness.routeNativeElement as HTMLElement;
expect(root.querySelector('#recruiters')).toBeTruthy(); expect(root.querySelector('#home-proof')).toBeTruthy();
expect(root.querySelector('#companies')).toBeTruthy(); expect(root.querySelector('app-case-card')).toBeTruthy();
expect(root.querySelector(`a[href="${SITE_CONFIG.cvAssetPath}"]`)).toBeTruthy(); expect(root.querySelector('#recruiters')).toBeNull();
expect(root.querySelector('#companies')).toBeNull();
} }
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy(); expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
}); });
it('places the Systems Map between the profile block and the featured cases', async () => { it('places the Systems Map on the services overview', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }], providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
}); });
const harness = await RouterTestingHarness.create(); const harness = await RouterTestingHarness.create();
for (const path of ['/leistungen', '/en/services']) {
await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement;
const map = root.querySelector('app-systems-map');
expect(map).toBeTruthy();
assertPageSemantics(root);
}
for (const path of ['/', '/en']) { for (const path of ['/', '/en']) {
await harness.navigateByUrl(path); await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement; const root = harness.routeNativeElement as HTMLElement;
const metrics = root.querySelector('app-metric-list'); expect(root.querySelector('app-systems-map')).toBeNull();
const map = root.querySelector('app-systems-map'); }
const firstCase = root.querySelector('app-case-card'); });
expect(metrics).toBeTruthy(); it('renders pitch as profile, stations, stack, then cases', async () => {
expect(map).toBeTruthy(); TestBed.configureTestingModule({
expect(firstCase).toBeTruthy(); providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const position = metrics!.compareDocumentPosition(map!); const harness = await RouterTestingHarness.create();
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const afterMap = map!.compareDocumentPosition(firstCase!);
expect(afterMap & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
for (const path of ['/pitch', '/en/pitch']) {
await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement;
const headingIds = [...root.querySelectorAll('h2')].map((heading) => heading.id);
expect(headingIds, path).toEqual([
'section-profile',
'pitch-timeline',
'pitch-stack',
'section-featured-cases',
]);
expect(root.querySelectorAll('app-case-card')).toHaveLength(CASE_STUDY_IDS.length);
}
});
it('renders pitch in both locales with a single h1', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
for (const path of ['/pitch', '/en/pitch']) {
await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement;
expect(root.querySelectorAll('h1')).toHaveLength(1);
assertPageSemantics(root); assertPageSemantics(root);
} }
}); });

View File

@@ -0,0 +1,54 @@
@if (page(); as copy) {
<div class="content-container stack page">
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
@for (section of copy.sections; track section.id) {
@if (section.id === 'profile') {
<app-content-section [section]="section" />
<ul class="stack">
@for (line of copy.profile; track $index) {
<li>{{ line }}</li>
}
</ul>
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
<section class="stack" [attr.aria-labelledby]="'pitch-timeline'">
<h2 id="pitch-timeline">{{ copy.timelineHeading }}</h2>
<ol class="stack">
@for (entry of copy.timeline; track entry.id) {
<li>
<p>
<strong>{{ entry.period }}</strong>
· {{ entry.role }}
</p>
<p>{{ entry.body }}</p>
</li>
}
</ol>
</section>
<section class="stack" [attr.aria-labelledby]="'pitch-stack'">
<h2 id="pitch-stack">{{ copy.stackHeading }}</h2>
<ul class="stack">
@for (group of copy.coreStack; track group.id) {
<li>
<p>
<strong>{{ group.title }}</strong>
@if (group.body) {
— {{ group.body }}
}
</p>
</li>
}
</ul>
</section>
}
@if (section.id === 'featured-cases') {
<app-content-section [section]="section" />
<div class="stack">
@for (caseStudy of featuredCases(); track caseStudy.id) {
<app-case-card [caseStudy]="caseStudy" />
}
</div>
}
}
<app-cta-row [ctas]="copy.ctas" />
</div>
}

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { CaseCard } from '../../shared/case-card/case-card';
import { ContentSection } from '../../shared/content-section/content-section';
import { CtaRow } from '../../shared/cta-row/cta-row';
import { MetricList } from '../../shared/metric-list/metric-list';
import { PageHero } from '../../shared/page-hero/page-hero';
@Component({
selector: 'app-pitch-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
templateUrl: './pitch.html',
styleUrl: '../../shared/page-shell.scss',
})
export class PitchPage {
private readonly content = inject(ContentService);
protected readonly page = this.content.pitch();
protected readonly featuredCases = computed(() => {
const ids = this.page().featuredCaseIds;
return ids.map((id) => this.content.caseStudy(id)());
});
}

View File

@@ -4,5 +4,10 @@
@for (section of copy.sections; track section.id) { @for (section of copy.sections; track section.id) {
<app-content-section [section]="section" /> <app-content-section [section]="section" />
} }
<app-systems-map
[heading]="copy.systemsMap.heading"
[intro]="copy.systemsMap.intro"
[headingLevel]="2"
/>
</div> </div>
} }

View File

@@ -2,14 +2,15 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service'; import { ContentService } from '../../core/content/content.service';
import { ContentSection } from '../../shared/content-section/content-section'; import { ContentSection } from '../../shared/content-section/content-section';
import { PageHero } from '../../shared/page-hero/page-hero'; import { PageHero } from '../../shared/page-hero/page-hero';
import { SystemsMap } from '../../shared/systems-map/systems-map';
@Component({ @Component({
selector: 'app-services-page', selector: 'app-services-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection], imports: [PageHero, ContentSection, SystemsMap],
templateUrl: './services.html', templateUrl: './services.html',
styleUrl: '../../shared/page-shell.scss', styleUrl: '../../shared/page-shell.scss',
}) })
export class ServicesPage { export class ServicesPage {
protected readonly page = inject(ContentService).page('services'); protected readonly page = inject(ContentService).servicesOverview();
} }

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

@@ -92,6 +92,51 @@ describe('ContactBriefing', () => {
expect(xhrSpy).not.toHaveBeenCalled(); expect(xhrSpy).not.toHaveBeenCalled();
}); });
it('treats project type as optional and enables mailto with name, email and situation', async () => {
await TestBed.configureTestingModule({
imports: [ContactBriefing],
}).compileComponents();
const fixture = TestBed.createComponent(ContactBriefing);
const copy = SITE_CONTENT_DATA.de.contact;
fixture.componentRef.setInput('copy', copy);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const projectType = copy.fields.find((field) => field.id === 'projectType');
expect(projectType?.required).toBe(false);
expect(projectType?.options?.some((option) => option.value === 'unsure')).toBe(true);
expect(
SITE_CONTENT_DATA.en.contact.fields
.find((field) => field.id === 'projectType')
?.options?.some((option) => option.value === 'unsure'),
).toBe(true);
const unsure = projectType?.options?.find((option) => option.value === 'unsure');
expect(unsure?.label).toBe('Noch unklar');
expect(
SITE_CONTENT_DATA.en.contact.fields
.find((field) => field.id === 'projectType')
?.options?.find((option) => option.value === 'unsure')?.label,
).toBe('Not sure yet');
setControl(root, 'contact-name', 'Ada');
setControl(root, 'contact-email', 'ada@example.com');
setControl(root, 'contact-situation', 'Need a shop.');
fixture.detectChanges();
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
expect(submit?.getAttribute('href')?.startsWith('mailto:')).toBe(true);
expect(submit?.getAttribute('href')).not.toContain(
encodeURIComponent(`${projectType!.label}:`),
);
setControl(root, 'contact-projectType', 'unsure');
fixture.detectChanges();
const withType = root.querySelector<HTMLAnchorElement>('a.submit')?.getAttribute('href') ?? '';
expect(decodeURIComponent(withType)).toContain(`${projectType!.label}: ${unsure!.label}`);
});
it('marks required fields invalid only after interaction', async () => { it('marks required fields invalid only after interaction', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ContactBriefing], imports: [ContactBriefing],

View File

@@ -6,5 +6,10 @@
@if (hero().playfulLine; as playfulLine) { @if (hero().playfulLine; as playfulLine) {
<p class="playful">{{ playfulLine }}</p> <p class="playful">{{ playfulLine }}</p>
} }
@if (hero().body; as body) {
@for (paragraph of body; track $index) {
<p>{{ paragraph }}</p>
}
}
<app-cta-row [ctas]="ctas()" /> <app-cta-row [ctas]="ctas()" />
</header> </header>

View File

@@ -27,3 +27,9 @@ h1 {
font-size: var(--text-sm); font-size: var(--text-sm);
font-style: italic; font-style: italic;
} }
.page-hero p:not(.proof):not(.playful) {
margin: 0;
max-width: 40rem;
color: var(--color-text-muted);
}

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,78 @@
<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"
tabindex="-1"
[attr.aria-label]="copy().panelLabel"
(keydown)="onPanelKeydown($event)"
>
<div class="terminal-dock-toolbar cluster">
<p class="terminal-dock-title">{{ copy().panelLabel }}</p>
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="copy().maximizeLabel"
[attr.aria-pressed]="maximized()"
(click)="toggleMaximized()"
>
{{ copy().maximizeLabel }}
</button>
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="copy().collapseLabel"
(click)="collapse()"
>
{{ copy().collapseLabel }}
</button>
</div>
<div
#outputLog
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,182 @@
@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);
animation: terminal-dock-in var(--duration-base) var(--ease-standard);
transition:
width var(--duration-base) var(--ease-standard),
max-height var(--duration-base) var(--ease-standard);
}
.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-control[aria-pressed='true'] {
border-color: var(--surface-glass-border-strong);
background: var(--color-surface);
}
.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);
}
}
@keyframes terminal-dock-in {
from {
opacity: 0;
transform: translateY(0.4rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.terminal-dock-panel {
animation: none;
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,119 @@
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 ||
event.shiftKey
) {
return;
}
event.preventDefault();
this.toggle();
};
}

View File

@@ -0,0 +1,432 @@
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)('toggles the trigger open, closed, then open again (%s)', async (locale) => {
const fixture = await createFixture(locale);
const dock = TestBed.inject(TerminalDockService);
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(dock.state()).toBe('expanded');
expect(document.activeElement).toBe(input(fixture));
trigger(fixture).click();
await flush(fixture);
expect(panel(fixture)).toBeNull();
expect(dock.state()).toBe('collapsed');
expect(document.activeElement).toBe(trigger(fixture));
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(dock.state()).toBe('expanded');
expect(document.activeElement).toBe(input(fixture));
});
it.each(APP_LOCALES)(
'returns focus to the trigger after a trigger-initiated collapse (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
expect(document.activeElement).toBe(input(fixture));
trigger(fixture).click();
await flush(fixture);
expect(panel(fixture)).toBeNull();
expect(document.activeElement).toBe(trigger(fixture));
},
);
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)(
'keeps one stable maximize name across both aria-pressed states (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const label = SIGNATURE_COPY[locale].terminal.maximizeLabel;
const button = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
expect(button.getAttribute('aria-label')).toBe(label);
expect(button.textContent?.trim()).toBe(label);
expect(button.getAttribute('aria-pressed')).toBe('false');
button.click();
await flush(fixture);
expect(button.getAttribute('aria-label')).toBe(label);
expect(button.textContent?.trim()).toBe(label);
expect(button.getAttribute('aria-pressed')).toBe('true');
},
);
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('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => {
const fixture = await createFixture();
await flush(fixture);
for (const modifier of [
{ ctrlKey: true, shiftKey: true },
{ metaKey: true, shiftKey: true },
] as const) {
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).not.toHaveBeenCalled();
expect(panel(fixture)).toBeNull();
}
const openEvent = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
ctrlKey: true,
});
const preventOpen = vi.spyOn(openEvent, 'preventDefault');
document.dispatchEvent(openEvent);
await flush(fixture);
expect(preventOpen).toHaveBeenCalled();
expect(panel(fixture)).toBeTruthy();
const shiftWhileOpen = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
ctrlKey: true,
shiftKey: true,
});
const preventShift = vi.spyOn(shiftWhileOpen, 'preventDefault');
document.dispatchEvent(shiftWhileOpen);
await flush(fixture);
expect(preventShift).not.toHaveBeenCalled();
expect(panel(fixture)).toBeTruthy();
const metaOpen = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
metaKey: true,
});
const preventMeta = vi.spyOn(metaOpen, 'preventDefault');
document.dispatchEvent(metaOpen);
await flush(fixture);
expect(preventMeta).toHaveBeenCalled();
expect(panel(fixture)).toBeNull();
});
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)(
'closes on Escape from a panel control and returns focus to the trigger (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const maximize = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
maximize.focus();
expect(document.activeElement).toBe(maximize);
maximize.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)('scrolls the log to the newest line after output (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const logEl = fixture.nativeElement.querySelector('.terminal-dock-log') as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperty(logEl, 'scrollHeight', { configurable: true, get: () => 480 });
Object.defineProperty(logEl, 'scrollTo', { configurable: true, value: scrollTo });
await submitQuery(fixture, 'help');
expect(scrollTo).toHaveBeenCalled();
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
top: 480,
}),
);
});
it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
await submitQuery(fixture, 'help');
await submitQuery(fixture, 'brew');
await submitQuery(fixture, 'rev');
const field = input(fixture);
expect(field?.value).toBe('');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('rev');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('brew');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('help');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('help');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('brew');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('rev');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('');
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('');
});
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,336 @@
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,
prefersReducedMotion,
} from '../../core/platform/browser';
import {
applyCompletion,
COMMAND_IDS,
navigateTargetKeys,
parseCommand,
} 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 reducedMotion = prefersReducedMotion();
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
private readonly logRef = viewChild<ElementRef<HTMLElement>>('outputLog');
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');
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 },
);
});
effect(() => {
const lines = this.log();
if (!this.isBrowser || !this.open() || lines.length === 0) {
return;
}
afterNextRender(
() => {
this.scrollLogToLatest();
},
{ injector: this.injector },
);
});
}
protected onTriggerClick(): void {
this.dock.toggle(this.triggerRef()?.nativeElement ?? 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 onPanelKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
this.dock.collapse();
}
protected onInputKeydown(event: KeyboardEvent): void {
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.writeQuery('');
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.writeQuery(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();
if (current === null) {
if (direction > 0) {
return;
}
const newest = commands.length - 1;
this.historyIndex.set(newest);
this.writeQuery(commands[newest] ?? '');
return;
}
const next = current + direction;
if (next < 0) {
return;
}
if (next >= commands.length) {
this.historyIndex.set(null);
this.writeQuery('');
return;
}
this.historyIndex.set(next);
this.writeQuery(commands[next] ?? '');
}
private writeQuery(value: string): void {
this.query.set(value);
const field = this.inputRef()?.nativeElement;
if (field) {
field.value = value;
}
}
private scrollLogToLatest(): void {
const element = this.logRef()?.nativeElement;
if (!element) {
return;
}
const top = element.scrollHeight;
if (typeof element.scrollTo === 'function') {
element.scrollTo({
top,
behavior: this.reducedMotion ? 'auto' : 'smooth',
});
return;
}
element.scrollTop = top;
}
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 })),
]);
}
}

View File

@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const APP_SCSS = readFileSync(join(process.cwd(), 'src/app/app.scss'), 'utf8');
describe('primary nav submenu surface', () => {
it('uses the opaque menu token and not the glass surface', () => {
const submenu = APP_SCSS.slice(APP_SCSS.indexOf('.primary-nav > li > .submenu'));
expect(submenu).toMatch(/background:\s*var\(--color-surface-menu\)/);
expect(submenu).not.toMatch(/var\(--surface-glass\)/);
expect(submenu).toMatch(/box-shadow:/);
expect(submenu).toMatch(/border-radius:/);
});
});

View File

@@ -5,6 +5,7 @@
--color-surface-raised: #12121a; --color-surface-raised: #12121a;
--color-surface-overlay: #1a1a24; --color-surface-overlay: #1a1a24;
--color-surface-muted: #22222e; --color-surface-muted: #22222e;
--color-surface-menu: #16161f;
/* Color — accent ramp */ /* Color — accent ramp */
--color-accent: #6366f1; --color-accent: #6366f1;