Compare commits
10 Commits
orchestrat
...
e63b4edade
| Author | SHA1 | Date | |
|---|---|---|---|
| e63b4edade | |||
| b8b9e57d06 | |||
| 19d00da9bb | |||
| 35a575dba9 | |||
| 255f0eace6 | |||
| 359e887a7a | |||
| 5921fc5ede | |||
| 162bd790da | |||
| 646b39b200 | |||
| 86d6dd085d |
@@ -4,7 +4,7 @@ Contributor and agent guide for this repository. Read this before changing routi
|
||||
|
||||
## 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
|
||||
|
||||
@@ -93,6 +93,7 @@ German and English are written idiomatically per language, never machine-transla
|
||||
## i18n rules
|
||||
|
||||
- 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
|
||||
- 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`
|
||||
@@ -154,7 +155,7 @@ A change is not done until:
|
||||
| ----------- | -------------------------------------------------------------------------------- |
|
||||
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -18,7 +18,7 @@ npm install
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
| ----------------------------- | ------------------------------------------------------------------------- |
|
||||
| ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| `npm start` | Start the Angular dev server. |
|
||||
| `npm run build` | Production build (default configuration), including SSR and prerendering. |
|
||||
| `npm run watch` | Development rebuild on change. |
|
||||
@@ -34,6 +34,7 @@ npm install
|
||||
| `npm run e2e:install` | Install the pinned Chromium build for Playwright. |
|
||||
| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). |
|
||||
| `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
|
||||
|
||||
@@ -42,7 +43,7 @@ npm run build
|
||||
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.
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { pagePath } from './helpers';
|
||||
const AXE_PATHS = [
|
||||
pagePath('home', 'de'),
|
||||
pagePath('home', 'en'),
|
||||
pagePath('pitch', 'de'),
|
||||
pagePath('pitch', 'en'),
|
||||
pagePath('projects', 'de'),
|
||||
pagePath('projects', 'en'),
|
||||
pagePath('servicesAi', 'de'),
|
||||
@@ -23,8 +25,11 @@ test.describe('accessibility', () => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
const pending = page.locator('.reveal-pending');
|
||||
await expect(pending).toHaveCount(0);
|
||||
await expect(page.locator('app-systems-map')).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) {
|
||||
|
||||
@@ -24,9 +24,13 @@ test.describe('contact briefing', () => {
|
||||
|
||||
await page.locator('#contact-name').fill('Ada');
|
||||
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.');
|
||||
|
||||
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');
|
||||
expect(href).toMatch(/^mailto:/);
|
||||
expect(href).toContain(encodeURIComponent(SITE_CONTENT_DATA.de.contact.mailSubject));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { pagePath } from './helpers';
|
||||
|
||||
test.describe('keyboard and palette', () => {
|
||||
test.describe('keyboard and terminal dock', () => {
|
||||
test('skip link is first and moves focus to main', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.keyboard.press('Tab');
|
||||
@@ -87,37 +87,32 @@ test.describe('keyboard and palette', () => {
|
||||
const headerBox = await page.locator('.site-header').boundingBox();
|
||||
expect(headerBox, 'header while submenu is open').toBeTruthy();
|
||||
expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200);
|
||||
|
||||
const background = await firstChild.evaluate((element) => {
|
||||
const submenu = element.closest('.submenu');
|
||||
return submenu ? getComputedStyle(submenu).backgroundColor : '';
|
||||
});
|
||||
expect(background, 'submenu background must be fully opaque').toMatch(
|
||||
/^rgb\(\d+,\s*\d+,\s*\d+\)$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({
|
||||
test('terminal dock opens with Control+K without locking the page and restores on Escape', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
const trigger = page.locator('.command-palette-trigger');
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Control+k');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('.site')).toHaveAttribute('inert', '');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');
|
||||
|
||||
const focusable = dialog.locator(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])',
|
||||
);
|
||||
const count = await focusable.count();
|
||||
const first = focusable.first();
|
||||
const last = focusable.nth(count - 1);
|
||||
await last.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(first).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(last).toBeFocused();
|
||||
const panel = page.locator('.terminal-dock-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden');
|
||||
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('');
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { expectNoOverflow, pagePath } from './helpers';
|
||||
const CHECKED = [
|
||||
pagePath('home', 'de'),
|
||||
pagePath('home', 'en'),
|
||||
pagePath('pitch', 'de'),
|
||||
pagePath('pitch', 'en'),
|
||||
pagePath('projects', 'de'),
|
||||
pagePath('projects', 'en'),
|
||||
pagePath('servicesAi', 'de'),
|
||||
|
||||
@@ -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 }) => {
|
||||
const home = await request.get('/');
|
||||
expect(home.status(), 'GET /').toBe(200);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pagePath } from './helpers';
|
||||
|
||||
test.describe('Systems Map', () => {
|
||||
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]) {
|
||||
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 }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
|
||||
for (const width of [1024, 1440]) {
|
||||
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 }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const hrefs = await page
|
||||
.locator('.systems-map-node')
|
||||
|
||||
145
e2e/terminal.e2e.ts
Normal file
145
e2e/terminal.e2e.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
"url": [
|
||||
"http://127.0.0.1:4000/",
|
||||
"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/en/projects"
|
||||
],
|
||||
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -27,7 +27,7 @@
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"angular-eslint": "^21.4.0",
|
||||
@@ -35,6 +35,7 @@
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
@@ -4285,6 +4286,26 @@
|
||||
"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": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
@@ -10888,6 +10909,13 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -11064,6 +11092,26 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build",
|
||||
"e2e": "playwright test",
|
||||
"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": {
|
||||
"printWidth": 100,
|
||||
@@ -61,6 +62,7 @@
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
|
||||
@@ -6,6 +6,8 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
|
||||
|
||||
- https://antoniolede.de/
|
||||
- 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/en/about
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
|
||||
</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>
|
||||
<loc>https://antoniolede.de/leistungen</loc>
|
||||
<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="x-default" href="https://antoniolede.de/" />
|
||||
</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>
|
||||
<loc>https://antoniolede.de/en/services</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />
|
||||
|
||||
123
scripts/add-cv-pitch-link.mjs
Normal file
123
scripts/add-cv-pitch-link.mjs
Normal 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.');
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
|
||||
<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">
|
||||
<div class="content-container site-header-inner glass-surface">
|
||||
<a class="site-identity" [routerLink]="navigation.link('home')">
|
||||
{{ siteConfig.personName }}
|
||||
</a>
|
||||
<div class="site-toolbar cluster">
|
||||
<app-command-palette-trigger></app-command-palette-trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
@@ -58,13 +57,6 @@
|
||||
>
|
||||
{{ shell().otherLocaleName }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,4 +86,4 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<app-command-palette></app-command-palette>
|
||||
<app-terminal-dock></app-terminal-dock>
|
||||
|
||||
@@ -9,7 +9,10 @@ describe('server routes', () => {
|
||||
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
|
||||
|
||||
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) {
|
||||
expect(route.renderMode, route.path).toBe(RenderMode.Prerender);
|
||||
|
||||
@@ -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', () => {
|
||||
const flattened = flattenRoutes(routes);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ type LazyPage = () => Promise<Type<unknown>>;
|
||||
|
||||
const PAGE_LOADERS: Record<RouteId, LazyPage> = {
|
||||
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),
|
||||
servicesSoftware: () =>
|
||||
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.site-header {
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
z-index: 30;
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
|
||||
.primary-nav > li {
|
||||
position: relative;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.primary-nav > li > a {
|
||||
@@ -211,20 +212,24 @@
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: max-content;
|
||||
z-index: 20;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
z-index: 32;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-glass);
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
background: var(--color-surface-menu);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.primary-nav > li:hover > .submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,16 +89,19 @@ describe('App', () => {
|
||||
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);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
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(trigger).toBeTruthy();
|
||||
expect(site?.contains(trigger)).toBe(true);
|
||||
expect(dock).toBeTruthy();
|
||||
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();
|
||||
fixture.detectChanges();
|
||||
@@ -106,10 +109,10 @@ describe('App', () => {
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const dialog = compiled.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(site?.contains(dialog)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(true);
|
||||
const panel = compiled.querySelector('.terminal-dock-panel');
|
||||
expect(panel).toBeTruthy();
|
||||
expect(site?.contains(panel)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => {
|
||||
|
||||
@@ -18,9 +18,7 @@ import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
||||
import { LocaleService } from './core/i18n/locale.service';
|
||||
import { NavigationService } from './core/navigation/navigation.service';
|
||||
import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
|
||||
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './shared/command-palette/command-palette.service';
|
||||
import { TerminalDock } from './shared/terminal/terminal-dock';
|
||||
|
||||
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
|
||||
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||
@@ -28,21 +26,13 @@ export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
DotBackground,
|
||||
CommandPalette,
|
||||
CommandPaletteTrigger,
|
||||
],
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {
|
||||
protected readonly navigation = inject(NavigationService);
|
||||
protected readonly localeService = inject(LocaleService);
|
||||
protected readonly palette = inject(CommandPaletteService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
export type CommandId =
|
||||
| 'help'
|
||||
| 'projects'
|
||||
| 'servicesAi'
|
||||
| 'cv'
|
||||
| 'contact'
|
||||
| 'brew'
|
||||
| 'ignite'
|
||||
| 'rev'
|
||||
| 'clear'
|
||||
| 'close';
|
||||
export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate';
|
||||
|
||||
export const COMMAND_IDS: readonly CommandId[] = [
|
||||
'help',
|
||||
'projects',
|
||||
'servicesAi',
|
||||
'cv',
|
||||
'contact',
|
||||
'brew',
|
||||
'ignite',
|
||||
'rev',
|
||||
'history',
|
||||
'clear',
|
||||
'close',
|
||||
'brew',
|
||||
'rev',
|
||||
'navigate',
|
||||
];
|
||||
|
||||
@@ -11,8 +11,10 @@ import { SITE_CONTENT_DATA } from './site-content';
|
||||
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;
|
||||
|
||||
const UNLIMITED_SCOPE = /\balles\b|\banything\b|any problem|jedes Problem|end-to-end für alles/i;
|
||||
|
||||
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[] {
|
||||
@@ -41,7 +43,7 @@ describe('content claims and attribution', () => {
|
||||
/8/.test(metric.value) && /90/.test(metric.value);
|
||||
const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
|
||||
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);
|
||||
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) {
|
||||
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
|
||||
@@ -83,4 +85,53 @@ describe('content claims and attribution', () => {
|
||||
).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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ describe('content completeness', () => {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
|
||||
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.projects).toBe(site.projects);
|
||||
expect(site.pages.stack).toBe(site.stack);
|
||||
|
||||
@@ -52,7 +52,16 @@ describe('content exclusions', () => {
|
||||
});
|
||||
|
||||
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) {
|
||||
await harness.navigateByUrl(path);
|
||||
|
||||
@@ -101,23 +101,43 @@ export interface ServicePageCopy extends PageCopy {
|
||||
readonly offeringCaseBackedLabel: string;
|
||||
}
|
||||
|
||||
export interface AudienceEntryCopy {
|
||||
readonly id: 'recruiters' | 'companies';
|
||||
readonly headline: string;
|
||||
export interface HomeServiceAreaCopy {
|
||||
readonly id: 'workplaces' | 'servers' | 'software' | 'ai';
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly bullets: readonly string[];
|
||||
readonly ctas: readonly CtaCopy[];
|
||||
readonly routeId: RouteId;
|
||||
}
|
||||
|
||||
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 metrics: readonly MetricCopy[];
|
||||
readonly audiences: readonly AudienceEntryCopy[];
|
||||
readonly timeline: readonly PitchTimelineEntryCopy[];
|
||||
readonly coreStack: readonly StackGroupCopy[];
|
||||
readonly featuredCaseIds: readonly CaseStudyId[];
|
||||
readonly systemsMap: {
|
||||
readonly heading: string;
|
||||
readonly intro: string;
|
||||
};
|
||||
readonly timelineHeading: string;
|
||||
readonly stackHeading: string;
|
||||
}
|
||||
|
||||
export interface ServicesOverviewPageCopy extends PageCopy {
|
||||
readonly systemsMap: { readonly heading: string; readonly intro: string };
|
||||
}
|
||||
|
||||
export type ContactFieldId =
|
||||
@@ -198,6 +218,8 @@ export interface SiteSeoCopy {
|
||||
export interface SiteContent {
|
||||
readonly pages: Record<RouteId, PageCopy>;
|
||||
readonly home: HomePageCopy;
|
||||
readonly pitch: PitchPageCopy;
|
||||
readonly servicesOverview: ServicesOverviewPageCopy;
|
||||
readonly services: Record<ServicePageId, ServicePageCopy>;
|
||||
readonly cases: Record<CaseStudyId, CaseStudyCopy>;
|
||||
readonly contact: ContactPageCopy;
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
type HomePageCopy,
|
||||
type LegalPageCopy,
|
||||
type PageCopy,
|
||||
type PitchPageCopy,
|
||||
type ProjectsPageCopy,
|
||||
type ServicePageCopy,
|
||||
type ServicePageId,
|
||||
type ServicesOverviewPageCopy,
|
||||
type StackPageCopy,
|
||||
} from './content.contracts';
|
||||
import { SITE_CONTENT } from './content.token';
|
||||
@@ -29,6 +31,14 @@ export class ContentService {
|
||||
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> {
|
||||
return computed(() => this.content[this.localeService.locale()].services[id]);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,93 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type HomePageCopy } from '../content.contracts';
|
||||
|
||||
export const HOME_DE: HomePageCopy = {
|
||||
routeId: 'home',
|
||||
title: 'Startseite | Antonio Ledebuhr',
|
||||
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: {
|
||||
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:
|
||||
'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',
|
||||
},
|
||||
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.',
|
||||
'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.',
|
||||
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.',
|
||||
],
|
||||
metrics: [
|
||||
},
|
||||
serviceAreasHeading: 'Wobei ich helfen kann',
|
||||
serviceAreas: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'ca. 7 Jahre',
|
||||
label: 'berufliche Erfahrung mit Webanwendungen',
|
||||
id: 'workplaces',
|
||||
title: 'Arbeitsplätze und Netz',
|
||||
body: 'Geräte, Netz und die Dienste, mit denen Dein Team täglich arbeitet, bleiben zuverlässig.',
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
},
|
||||
{
|
||||
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: 'servers',
|
||||
title: 'Server und Cluster',
|
||||
body: 'Einzelne Hosts, Virtualisierung oder ein Cluster. Der Weg dorthin bleibt nachvollziehbar, vor Ort oder auf Azure AKS.',
|
||||
routeId: 'servicesClusters',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
|
||||
caseId: 'myspa',
|
||||
},
|
||||
],
|
||||
audiences: [
|
||||
{
|
||||
id: 'recruiters',
|
||||
headline: 'Für Recruiterinnen und Recruiter',
|
||||
body: 'Ein kurzer Überblick über Erfahrung, ausgewählte Fälle, den technischen Stack und den Lebenslauf.',
|
||||
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: 'software',
|
||||
title: 'Software und Daten',
|
||||
body: 'Produktsoftware und Datenarbeit: von der Fachlichkeit über die API bis zur Oberfläche, inklusive SQL.',
|
||||
routeId: 'servicesSoftware',
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
headline: 'Für Unternehmen',
|
||||
body: 'Vier Leistungsbereiche, vom ersten Workshop bis zum Betrieb — und ein kurzes Briefing für die Anfrage.',
|
||||
bullets: [
|
||||
'Software: Fullstack-Produkte mit Java, .NET, Angular und Datenarbeit in SQL.',
|
||||
'Hardware und Netzwerk: Server, Geräte, Microsoft 365 und wartbare Infrastruktur.',
|
||||
'Cluster: Docker und Kubernetes on-premise und auf Azure AKS, inklusive Pipelines.',
|
||||
'KI-Integration: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde, weitere Bausteine als Angebot.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Leistungen ansehen', routeId: 'services' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
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',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
systemsMap: {
|
||||
heading: 'Wie die Schichten zusammenhängen',
|
||||
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
|
||||
processHeading: 'So läuft die Zusammenarbeit',
|
||||
process: [
|
||||
{
|
||||
id: 'describe',
|
||||
title: 'Lage in Deinen Worten',
|
||||
body: 'Du beschreibst das Ziel, den Schmerz oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
|
||||
},
|
||||
{
|
||||
id: 'diagnose',
|
||||
title: 'Technische Diagnose',
|
||||
body: 'Ich ordne die Lage den vier Bereichen zu, nenne die Grenze und sage, was außerhalb liegt.',
|
||||
},
|
||||
{
|
||||
id: 'implement',
|
||||
title: 'Umsetzung',
|
||||
body: 'Ein Ansprechpartner setzt um: Software, Infrastruktur, Cluster oder ein klar umrissenes KI-Werkzeug, je nach Briefing.',
|
||||
},
|
||||
{
|
||||
id: 'operate',
|
||||
title: 'Betrieb oder Übergabe',
|
||||
body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen, kein undokumentierter Zwischenstand.',
|
||||
},
|
||||
],
|
||||
proofCaseId: 'roesterei',
|
||||
proofHeading: 'Ein belegter Weg vom Netz bis zum Shop',
|
||||
proofNote: 'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
||||
featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In dreißig Sekunden',
|
||||
id: 'scope',
|
||||
headline: 'Vier Arbeitsbereiche',
|
||||
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',
|
||||
headline: 'Ausgewählte Fälle',
|
||||
id: 'proof',
|
||||
headline: 'Warum die Rösterei als Beleg steht',
|
||||
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: [
|
||||
{ label: 'Leistungen', routeId: 'services' },
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
{ label: 'Projektbriefing starten', routeId: 'contact' },
|
||||
{ label: 'Leistungen ansehen', routeId: 'services' },
|
||||
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PROJECTS_DE: ProjectsPageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
@@ -134,6 +135,7 @@ export const ABOUT_DE: PageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
@@ -173,8 +175,10 @@ export const CONTACT_DE: ContactPageCopy = {
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Art des Vorhabens',
|
||||
required: true,
|
||||
hint: 'freiwillig — wenn noch unklar, einfach offen lassen oder „Noch unklar“ wählen',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'unsure', label: 'Noch unklar' },
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware-network', label: 'Hardware und Netzwerk' },
|
||||
{ value: 'clusters', label: 'Cluster' },
|
||||
|
||||
133
src/app/core/content/de/pitch.ts
Normal file
133
src/app/core/content/de/pitch.ts
Normal 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}` },
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
title: 'Leistungen | Antonio Ledebuhr',
|
||||
description:
|
||||
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_DE: PageCopy = {
|
||||
{ label: 'KI-Integration', routeId: 'servicesAi' },
|
||||
{ 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 = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_DE } from './cases';
|
||||
import { HOME_DE } from './home';
|
||||
import { PITCH_DE } from './pitch';
|
||||
import {
|
||||
ABOUT_DE,
|
||||
CONTACT_DE,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
|
||||
export const SITE_CONTENT_DE: SiteContent = {
|
||||
home: HOME_DE,
|
||||
pitch: PITCH_DE,
|
||||
servicesOverview: SERVICES_OVERVIEW_DE,
|
||||
services: {
|
||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||
@@ -42,6 +45,7 @@ export const SITE_CONTENT_DE: SiteContent = {
|
||||
},
|
||||
pages: {
|
||||
home: HOME_DE,
|
||||
pitch: PITCH_DE,
|
||||
services: SERVICES_OVERVIEW_DE,
|
||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||
|
||||
@@ -1,103 +1,93 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type HomePageCopy } from '../content.contracts';
|
||||
|
||||
export const HOME_EN: HomePageCopy = {
|
||||
routeId: 'home',
|
||||
title: 'Home | 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.',
|
||||
'You describe what the business needs to achieve. One technical contact takes the path from diagnosis through delivery to operations or handover.',
|
||||
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:
|
||||
'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',
|
||||
},
|
||||
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 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.',
|
||||
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.',
|
||||
],
|
||||
metrics: [
|
||||
},
|
||||
serviceAreasHeading: 'What I can help with',
|
||||
serviceAreas: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'about 7 years',
|
||||
label: 'professional experience with web applications',
|
||||
id: 'workplaces',
|
||||
title: 'Workplaces and network',
|
||||
body: 'Devices, the network and the everyday services your team uses stay reliable.',
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
},
|
||||
{
|
||||
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: 'servers',
|
||||
title: 'Servers and clusters',
|
||||
body: 'A single host, virtualisation or a cluster. The path there stays clear, on-premise or on Azure AKS.',
|
||||
routeId: 'servicesClusters',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'led the backend team and the DevOps team through the MySpa launch',
|
||||
caseId: 'myspa',
|
||||
},
|
||||
],
|
||||
audiences: [
|
||||
{
|
||||
id: 'recruiters',
|
||||
headline: 'For recruiters',
|
||||
body: 'A short path through experience, selected cases, the public stack and the curriculum vitae.',
|
||||
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: 'software',
|
||||
title: 'Software and data',
|
||||
body: 'Product software and data work: from the domain through the API to the interface, including SQL.',
|
||||
routeId: 'servicesSoftware',
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
headline: 'For companies',
|
||||
body: 'Four service areas, from the first workshop through to operations — and a short briefing for an enquiry.',
|
||||
bullets: [
|
||||
'Software: fullstack product work with Java, .NET, Angular and SQL data work.',
|
||||
'Hardware and network: servers, devices, Microsoft 365 and maintainable infrastructure.',
|
||||
'Clusters: Docker and Kubernetes on-premise and on Azure AKS, including pipelines.',
|
||||
'AI integration: two delivered tools at Rösterei Tangermünde, further building blocks as an offer.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Browse services', routeId: 'services' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
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',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
systemsMap: {
|
||||
heading: 'How the layers connect',
|
||||
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
|
||||
processHeading: 'How the work runs',
|
||||
process: [
|
||||
{
|
||||
id: 'describe',
|
||||
title: 'The situation in your words',
|
||||
body: 'You describe the goal, the pain or the recurring process. Technical language is not required.',
|
||||
},
|
||||
{
|
||||
id: 'diagnose',
|
||||
title: 'Technical diagnosis',
|
||||
body: 'I map the brief onto the four areas, name the boundary and say what sits outside it.',
|
||||
},
|
||||
{
|
||||
id: 'implement',
|
||||
title: 'Implementation',
|
||||
body: 'One person delivers: software, infrastructure, a cluster or a clearly defined AI tool, according to the brief.',
|
||||
},
|
||||
{
|
||||
id: 'operate',
|
||||
title: 'Operations or handover',
|
||||
body: 'The result stays operable. Handover means structures you can follow, not an undocumented snapshot.',
|
||||
},
|
||||
],
|
||||
proofCaseId: 'roesterei',
|
||||
proofHeading: 'A verified path from the network to the shop',
|
||||
proofNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
||||
featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'Thirty seconds',
|
||||
id: 'scope',
|
||||
headline: 'Four areas of work',
|
||||
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',
|
||||
headline: 'Selected cases',
|
||||
id: 'proof',
|
||||
headline: 'Why the roastery is the proof',
|
||||
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: [
|
||||
{ label: 'Services', routeId: 'services' },
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
{ label: 'Start a project briefing', routeId: 'contact' },
|
||||
{ label: 'Browse services', routeId: 'services' },
|
||||
{ label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PROJECTS_EN: ProjectsPageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'View the stack', routeId: 'stack' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
@@ -134,6 +135,7 @@ export const ABOUT_EN: PageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'For recruiters', routeId: 'pitch' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
@@ -173,8 +175,10 @@ export const CONTACT_EN: ContactPageCopy = {
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Type of work',
|
||||
required: true,
|
||||
hint: 'optional — leave blank or pick “Not sure yet” if the category is still open',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'unsure', label: 'Not sure yet' },
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware-network', label: 'Hardware and network' },
|
||||
{ value: 'clusters', label: 'Clusters' },
|
||||
|
||||
133
src/app/core/content/en/pitch.ts
Normal file
133
src/app/core/content/en/pitch.ts
Normal 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 company’s 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}` },
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
title: 'Services | Antonio Ledebuhr',
|
||||
description:
|
||||
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_EN: PageCopy = {
|
||||
{ label: 'AI integration', routeId: 'servicesAi' },
|
||||
{ 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 = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_EN } from './cases';
|
||||
import { HOME_EN } from './home';
|
||||
import { PITCH_EN } from './pitch';
|
||||
import {
|
||||
ABOUT_EN,
|
||||
CONTACT_EN,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
|
||||
export const SITE_CONTENT_EN: SiteContent = {
|
||||
home: HOME_EN,
|
||||
pitch: PITCH_EN,
|
||||
servicesOverview: SERVICES_OVERVIEW_EN,
|
||||
services: {
|
||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||
@@ -42,6 +45,7 @@ export const SITE_CONTENT_EN: SiteContent = {
|
||||
},
|
||||
pages: {
|
||||
home: HOME_EN,
|
||||
pitch: PITCH_EN,
|
||||
services: SERVICES_OVERVIEW_EN,
|
||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface ShellCopy {
|
||||
readonly menuClose: string;
|
||||
readonly languageSwitch: string;
|
||||
readonly otherLocaleName: string;
|
||||
readonly cvLabel: string;
|
||||
readonly contactCta: string;
|
||||
}
|
||||
|
||||
@@ -21,7 +20,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
menuClose: 'Menü schließen',
|
||||
languageSwitch: 'Zur englischen Version wechseln',
|
||||
otherLocaleName: 'English',
|
||||
cvLabel: 'Lebenslauf als PDF',
|
||||
contactCta: 'Kontakt',
|
||||
},
|
||||
en: {
|
||||
@@ -32,7 +30,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
menuClose: 'Close menu',
|
||||
languageSwitch: 'Switch to the German version',
|
||||
otherLocaleName: 'Deutsch',
|
||||
cvLabel: 'Curriculum vitae as PDF',
|
||||
contactCta: 'Contact',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('SIGNATURE_COPY', () => {
|
||||
|
||||
it('describes every CommandId in both locales', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;
|
||||
const descriptions = SIGNATURE_COPY[locale].terminal.commandDescriptions;
|
||||
|
||||
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
|
||||
|
||||
|
||||
@@ -2,27 +2,31 @@ import { type CommandId } from '../commands/command-ids';
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
|
||||
export interface SignatureCopy {
|
||||
readonly palette: {
|
||||
readonly terminal: {
|
||||
readonly triggerLabel: string;
|
||||
readonly shortcutHint: string;
|
||||
readonly shortcutHintApple: string;
|
||||
readonly dialogTitle: string;
|
||||
readonly dialogDescription: string;
|
||||
readonly prompt: string;
|
||||
readonly panelLabel: string;
|
||||
readonly inputLabel: string;
|
||||
readonly inputPlaceholder: string;
|
||||
readonly closeLabel: string;
|
||||
readonly suggestionsLabel: string;
|
||||
readonly collapseLabel: string;
|
||||
readonly maximizeLabel: string;
|
||||
readonly outputLabel: string;
|
||||
readonly emptySuggestions: string;
|
||||
readonly unknownCommand: string;
|
||||
readonly unknownTarget: string;
|
||||
readonly validTargetsLabel: string;
|
||||
readonly helpIntro: string;
|
||||
readonly helpGrammar: string;
|
||||
readonly historyEmpty: string;
|
||||
readonly historyIntro: string;
|
||||
readonly clearedMessage: string;
|
||||
readonly cvOpened: string;
|
||||
readonly navigating: string;
|
||||
readonly incompleteHint: string;
|
||||
readonly commandDescriptions: Record<CommandId, string>;
|
||||
readonly responses: {
|
||||
readonly brew: string;
|
||||
readonly ignite: string;
|
||||
readonly rev: string;
|
||||
};
|
||||
};
|
||||
@@ -45,39 +49,38 @@ export interface SignatureCopy {
|
||||
|
||||
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
de: {
|
||||
palette: {
|
||||
triggerLabel: 'Befehle öffnen',
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
shortcutHint: 'Strg+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
dialogTitle: 'Befehle',
|
||||
dialogDescription:
|
||||
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Befehl',
|
||||
inputPlaceholder: 'Befehl eingeben',
|
||||
closeLabel: 'Schließen',
|
||||
suggestionsLabel: 'Vorschläge',
|
||||
collapseLabel: 'Terminal einklappen',
|
||||
maximizeLabel: 'Terminal vergrößern',
|
||||
outputLabel: 'Ausgabe',
|
||||
emptySuggestions: 'Keine passenden Befehle.',
|
||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||
unknownTarget: 'Unbekanntes Ziel: {target}',
|
||||
validTargetsLabel: 'Gültige Ziele:',
|
||||
helpIntro: 'Verfügbare Befehle:',
|
||||
helpGrammar: 'Grammatik: help | history | clear | brew | rev | navigate <ziel> [<unterziel>]',
|
||||
historyEmpty: 'In dieser Sitzung wurde noch kein Befehl eingegeben.',
|
||||
historyIntro: 'Eingegebene Befehle:',
|
||||
clearedMessage: 'Ausgabe geleert.',
|
||||
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||
navigating: 'Wechsel zu {target}.',
|
||||
incompleteHint: 'Unvollständiger Befehl. Mögliche Ziele:',
|
||||
commandDescriptions: {
|
||||
help: 'Listet die verfügbaren Befehle.',
|
||||
projects: 'Öffnet die Projektübersicht.',
|
||||
servicesAi: 'Öffnet die Seite zur KI-Integration.',
|
||||
cv: 'Öffnet den Lebenslauf als PDF.',
|
||||
contact: 'Öffnet die Kontaktseite.',
|
||||
help: 'Listet die Grammatik und jeden Befehl.',
|
||||
history: 'Listet die in dieser Sitzung eingegebenen Befehle.',
|
||||
clear: 'Leert das Ausgabebuch.',
|
||||
brew: 'Eine kurze, spielerische Rückmeldung.',
|
||||
ignite: 'Eine kurze, spielerische Rückmeldung.',
|
||||
rev: 'Eine kurze, spielerische Rückmeldung.',
|
||||
clear: 'Leert die Ausgabe.',
|
||||
close: 'Schließt die Befehlsübersicht.',
|
||||
navigate: 'Wechselt zu einer bekannten Seite oder öffnet den Lebenslauf.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
|
||||
ignite: 'Zündung frei. Die Systeme laufen warm.',
|
||||
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
|
||||
},
|
||||
},
|
||||
@@ -99,38 +102,38 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
},
|
||||
},
|
||||
en: {
|
||||
palette: {
|
||||
triggerLabel: 'Open commands',
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
shortcutHint: 'Ctrl+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
dialogTitle: 'Commands',
|
||||
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Command',
|
||||
inputPlaceholder: 'Type a command',
|
||||
closeLabel: 'Close',
|
||||
suggestionsLabel: 'Suggestions',
|
||||
collapseLabel: 'Collapse the terminal',
|
||||
maximizeLabel: 'Maximise the terminal',
|
||||
outputLabel: 'Output',
|
||||
emptySuggestions: 'No matching commands.',
|
||||
unknownCommand: 'Unknown command: {command}',
|
||||
unknownTarget: 'Unknown target: {target}',
|
||||
validTargetsLabel: 'Valid targets:',
|
||||
helpIntro: 'Available commands:',
|
||||
helpGrammar: 'Grammar: help | history | clear | brew | rev | navigate <target> [<sub>]',
|
||||
historyEmpty: 'No commands have been entered in this session.',
|
||||
historyIntro: 'Entered commands:',
|
||||
clearedMessage: 'Output cleared.',
|
||||
cvOpened: 'Opened the CV in a new tab.',
|
||||
navigating: 'Going to {target}.',
|
||||
incompleteHint: 'Incomplete command. Possible targets:',
|
||||
commandDescriptions: {
|
||||
help: 'Lists the available commands.',
|
||||
projects: 'Opens the projects overview.',
|
||||
servicesAi: 'Opens the AI integration page.',
|
||||
cv: 'Opens the CV as a PDF.',
|
||||
contact: 'Opens the contact page.',
|
||||
help: 'Lists the grammar and every command.',
|
||||
history: 'Lists the commands entered in this session.',
|
||||
clear: 'Clears the output log.',
|
||||
brew: 'A short playful acknowledgement.',
|
||||
ignite: 'A short playful acknowledgement.',
|
||||
rev: 'A short playful acknowledgement.',
|
||||
clear: 'Clears the output.',
|
||||
close: 'Closes the command palette.',
|
||||
navigate: 'Goes to a known page or opens the curriculum vitae.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
||||
ignite: 'Ignition on. Systems are warming up.',
|
||||
rev: 'Revs climbing, the content stays calm.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { routes } from '../../app.routes';
|
||||
import { SITE_CONTENT } from '../content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { LocaleService } from '../i18n/locale.service';
|
||||
import { PRIMARY_NAV } from './navigation';
|
||||
import { NavigationService } from './navigation.service';
|
||||
|
||||
describe('NavigationService', () => {
|
||||
@@ -40,4 +41,11 @@ describe('NavigationService', () => {
|
||||
expect(navigation.link('home')).toEqual(['/', 'en']);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,10 +38,6 @@ export const PRIMARY_NAV: readonly NavItem[] = [
|
||||
routeId: 'projects',
|
||||
label: { de: 'Projekte', en: 'Projects' },
|
||||
},
|
||||
{
|
||||
routeId: 'stack',
|
||||
label: { de: 'Stack', en: 'Stack' },
|
||||
},
|
||||
{
|
||||
routeId: 'about',
|
||||
label: { de: 'Über mich', en: 'About' },
|
||||
|
||||
@@ -19,7 +19,7 @@ export function viewportMatches(query: string): boolean {
|
||||
|
||||
/**
|
||||
* Keyboard-labelling exception: there is no CSS media query for the Command key.
|
||||
* Used only to swap the palette shortcut hint after hydration.
|
||||
* Used only to swap the terminal shortcut hint after hydration.
|
||||
*/
|
||||
export function isApplePlatform(): boolean {
|
||||
if (!isBrowserPlatform()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type RouteId =
|
||||
| 'home'
|
||||
| 'pitch'
|
||||
| 'services'
|
||||
| 'servicesSoftware'
|
||||
| 'servicesHardwareNetwork'
|
||||
@@ -15,6 +16,7 @@ export type RouteId =
|
||||
|
||||
export const ROUTE_IDS: readonly RouteId[] = [
|
||||
'home',
|
||||
'pitch',
|
||||
'services',
|
||||
'servicesSoftware',
|
||||
'servicesHardwareNetwork',
|
||||
|
||||
@@ -27,6 +27,14 @@ describe('route paths', () => {
|
||||
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', () => {
|
||||
const paths = prerenderablePaths();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const LOCALE_PREFIX: Record<AppLocale, string> = {
|
||||
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
de: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'leistungen',
|
||||
servicesSoftware: 'leistungen/software',
|
||||
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
|
||||
@@ -24,6 +25,7 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
},
|
||||
en: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'services',
|
||||
servicesSoftware: 'services/software',
|
||||
servicesHardwareNetwork: 'services/hardware-network',
|
||||
|
||||
@@ -113,6 +113,8 @@ describe('crawl assets', () => {
|
||||
const required = [
|
||||
absoluteUrl(routePath('home', 'de')),
|
||||
absoluteUrl(routePath('home', 'en')),
|
||||
absoluteUrl(routePath('pitch', 'de')),
|
||||
absoluteUrl(routePath('pitch', 'en')),
|
||||
absoluteUrl(routePath('about', 'de')),
|
||||
absoluteUrl(routePath('about', 'en')),
|
||||
absoluteUrl(routePath('services', 'de')),
|
||||
|
||||
@@ -58,4 +58,28 @@ describe('SeoService', () => {
|
||||
);
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('JSON-LD builders', () => {
|
||||
const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA);
|
||||
expect(home['@context']).toBe('https://schema.org');
|
||||
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
|
||||
expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']);
|
||||
|
||||
for (const routeId of SERVICE_ROUTES) {
|
||||
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']);
|
||||
|
||||
@@ -3,40 +3,39 @@
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
@if (section.id === 'profile') {
|
||||
<ul class="stack">
|
||||
@for (line of copy.profile; track $index) {
|
||||
<li>{{ line }}</li>
|
||||
@if (section.id === 'scope') {
|
||||
<section class="stack" [attr.aria-labelledby]="'home-service-areas'">
|
||||
<h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2>
|
||||
<ul class="home-service-areas">
|
||||
@for (area of copy.serviceAreas; track area.id) {
|
||||
<li>
|
||||
<a
|
||||
class="home-service-card glass-surface stack"
|
||||
[routerLink]="areaLink(area.routeId)"
|
||||
>
|
||||
<h3>{{ area.title }}</h3>
|
||||
<p>{{ area.body }}</p>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
|
||||
<div appReveal class="home-reveal">
|
||||
<app-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[headingLevel]="2"
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<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) {
|
||||
<app-case-card [caseStudy]="caseStudy" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@for (audience of copy.audiences; track audience.id) {
|
||||
<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>
|
||||
}
|
||||
<app-cta-row [ctas]="copy.ctas" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,8 +11,51 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page p,
|
||||
.page li {
|
||||
max-width: 40rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
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 { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { CtaRow } from '../../shared/cta-row/cta-row';
|
||||
import { MetricList } from '../../shared/metric-list/metric-list';
|
||||
import { RevealDirective } from '../../shared/motion/reveal.directive';
|
||||
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({
|
||||
selector: 'app-home-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow, SystemsMap, RevealDirective],
|
||||
imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow, RevealDirective, RouterLink],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.scss',
|
||||
})
|
||||
export class HomePage {
|
||||
private readonly content = inject(ContentService);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
|
||||
protected readonly page = this.content.home();
|
||||
protected readonly proofCase = computed(() => this.content.caseStudy(this.page().proofCaseId)());
|
||||
protected readonly featuredCases = computed(() => {
|
||||
const ids = this.page().featuredCaseIds;
|
||||
return ids.map((id) => this.content.caseStudy(id)());
|
||||
});
|
||||
|
||||
protected areaLink(routeId: RouteId): unknown[] {
|
||||
return this.navigation.link(routeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../app.routes';
|
||||
import { SITE_CONTENT } from '../core/content/content.token';
|
||||
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 { APP_LOCALES } from '../core/i18n/locale';
|
||||
import { routePath } from '../core/routing/route-paths';
|
||||
@@ -67,6 +66,8 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
const paths = [
|
||||
'/',
|
||||
'/en',
|
||||
'/pitch',
|
||||
'/en/pitch',
|
||||
'/leistungen/software',
|
||||
'/en/services/software',
|
||||
'/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({
|
||||
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']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root.querySelector('#recruiters')).toBeTruthy();
|
||||
expect(root.querySelector('#companies')).toBeTruthy();
|
||||
expect(root.querySelector(`a[href="${SITE_CONFIG.cvAssetPath}"]`)).toBeTruthy();
|
||||
expect(root.querySelector('#home-proof')).toBeTruthy();
|
||||
expect(root.querySelector('app-case-card')).toBeTruthy();
|
||||
expect(root.querySelector('#recruiters')).toBeNull();
|
||||
expect(root.querySelector('#companies')).toBeNull();
|
||||
}
|
||||
|
||||
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({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
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']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const metrics = root.querySelector('app-metric-list');
|
||||
const map = root.querySelector('app-systems-map');
|
||||
const firstCase = root.querySelector('app-case-card');
|
||||
expect(root.querySelector('app-systems-map')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(map).toBeTruthy();
|
||||
expect(firstCase).toBeTruthy();
|
||||
it('renders pitch as profile, stations, stack, then cases', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const position = metrics!.compareDocumentPosition(map!);
|
||||
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const afterMap = map!.compareDocumentPosition(firstCase!);
|
||||
expect(afterMap & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
54
src/app/features/pitch/pitch.html
Normal file
54
src/app/features/pitch/pitch.html
Normal 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>
|
||||
}
|
||||
24
src/app/features/pitch/pitch.ts
Normal file
24
src/app/features/pitch/pitch.ts
Normal 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)());
|
||||
});
|
||||
}
|
||||
@@ -4,5 +4,10 @@
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
<app-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[headingLevel]="2"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
import { SystemsMap } from '../../shared/systems-map/systems-map';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection],
|
||||
imports: [PageHero, ContentSection, SystemsMap],
|
||||
templateUrl: './services.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class ServicesPage {
|
||||
protected readonly page = inject(ContentService).page('services');
|
||||
protected readonly page = inject(ContentService).servicesOverview();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -92,6 +92,51 @@ describe('ContactBriefing', () => {
|
||||
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 () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContactBriefing],
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
@if (hero().playfulLine; as playfulLine) {
|
||||
<p class="playful">{{ playfulLine }}</p>
|
||||
}
|
||||
@if (hero().body; as body) {
|
||||
@for (paragraph of body; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
}
|
||||
<app-cta-row [ctas]="ctas()" />
|
||||
</header>
|
||||
|
||||
@@ -27,3 +27,9 @@ h1 {
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.page-hero p:not(.proof):not(.playful) {
|
||||
margin: 0;
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
91
src/app/shared/terminal/terminal-commands.spec.ts
Normal file
91
src/app/shared/terminal/terminal-commands.spec.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
234
src/app/shared/terminal/terminal-commands.ts
Normal file
234
src/app/shared/terminal/terminal-commands.ts
Normal 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 };
|
||||
}
|
||||
78
src/app/shared/terminal/terminal-dock.html
Normal file
78
src/app/shared/terminal/terminal-dock.html
Normal 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>
|
||||
182
src/app/shared/terminal/terminal-dock.scss
Normal file
182
src/app/shared/terminal/terminal-dock.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
119
src/app/shared/terminal/terminal-dock.service.ts
Normal file
119
src/app/shared/terminal/terminal-dock.service.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
432
src/app/shared/terminal/terminal-dock.spec.ts
Normal file
432
src/app/shared/terminal/terminal-dock.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
336
src/app/shared/terminal/terminal-dock.ts
Normal file
336
src/app/shared/terminal/terminal-dock.ts
Normal 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 })),
|
||||
]);
|
||||
}
|
||||
}
|
||||
15
src/app/submenu.styles.spec.ts
Normal file
15
src/app/submenu.styles.spec.ts
Normal 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:/);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
--color-surface-raised: #12121a;
|
||||
--color-surface-overlay: #1a1a24;
|
||||
--color-surface-muted: #22222e;
|
||||
--color-surface-menu: #16161f;
|
||||
|
||||
/* Color — accent ramp */
|
||||
--color-accent: #6366f1;
|
||||
|
||||
Reference in New Issue
Block a user