22 Commits

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:26 +02:00
162bd790da Replace the modal command palette with a nonmodal terminal dock.
Ctrl+K now opens a corner session with a parsed navigate grammar, persistent history, and no page lock. Unknown targets stay on a closed alias table and never become URLs.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:12 +02:00
b15dfd7801 integration: compact the wide primary navigation and keep fragment targets visible
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:55:22 +02:00
575ba06bb9 integration: disable the contact mailto until required fields are complete
Keep the submit control focusable with a disabled semantic while the briefing is incomplete, mark invalid fields only after interaction, and leave the encoded mailto path unchanged once the form is filled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:22 +02:00
1ff26be61b integration: keep reveal transitions on the revealed state
Move the transition onto the base reveal-target selector so removing reveal-pending no longer drops the animation, while reduced-motion still suppresses it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:22 +02:00
1de146c650 integration: name case metrics, split delivered offerings from offers, and frame the stack honestly
Give every metrics list a localized heading, keep delivered Rösterei tools out of the offer boundary, and describe the skills grid as a familiarity overview with an evidence note instead of claiming public-case backing for every item.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:18 +02:00
1d2965a7ff integration: collapse the mobile nav after hydration and keep fragment targets clear of the sticky header
SSR still ships the expanded menu, but phones collapse it after render, stop sticking the header below md, and use the header-offset token so case anchors no longer sit underneath the bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:13 +02:00
271df12334 integration: return HTTP 404 for unmatched URLs and close remaining browser-gate gaps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:45:28 +02:00
34367181d2 integration: add Playwright, axe and Lighthouse CI gates
Lock SEO, accessibility and crawlability with headless browser checks so regressions fail before a merge instead of after publication.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:28:24 +02:00
46c86447d1 integration: add route metadata, JSON-LD and harden the public shell
Ship prerendered SEO, crawl files and palette/map a11y so every locale route is indexable, honest and keyboard-usable without inventing claims.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:28:22 +02:00
2ccac6b28a merge: integrate signature UX domain 2026-08-25 17:57:03 +02:00
da38cc3624 merge: integrate bilingual content domain 2026-08-25 17:57:03 +02:00
6c1572bf3c signature: restore lean dots, reduced-motion redraw, above-fold reveal and map focus rings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:49:57 +02:00
7813499ee3 signature: add systems map, command palette and motion primitives
Ship the Signature UX as standalone, SSR-safe pieces: bilingual copy, a dual SVG/list systems map, a Map-backed command palette in the shell, opt-in reveal and metric primitives, and a leak-free decorative canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:37:25 +02:00
120 changed files with 10547 additions and 367 deletions

6
.gitignore vendored
View File

@@ -41,3 +41,9 @@ __screenshots__/
# System files
.DS_Store
Thumbs.db
# Browser and audit artifacts
/test-results
/playwright-report
/blob-report
/.lighthouseci

View File

@@ -8,3 +8,7 @@ public/**/*.svg
Dockerfile
docker-compose.yml
.cursor
test-results
playwright-report
.lighthouseci
blob-report

View File

@@ -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
@@ -42,6 +42,9 @@ This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer.
| `npm run format:check` | Prettier check (CI). |
| `npm run serve:ssr` | Serve the production SSR bundle. |
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr` for existing tooling. |
| `npm run e2e` | Playwright + axe-core against the SSR bundle. |
| `npm run e2e:install` | Install the pinned Chromium build. |
| `npm run lighthouse` | Production build, then Lighthouse CI. |
| `npm run ci` | lint, format:check, test:ci, then build. |
## Code conventions
@@ -90,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`
@@ -99,6 +103,7 @@ German and English are written idiomatically per language, never machine-transla
- No `window`, `document`, `navigator`, `localStorage` or `matchMedia` access outside `afterNextRender` or `isPlatformBrowser` guards
- Use the injected `DOCUMENT` token when the document element is required (it works on server and browser)
- No user-agent sniffing; use CSS media queries for device capability
- Exception: `isApplePlatform()` in `src/app/core/platform/browser.ts` may read `navigator.userAgentData?.platform ?? navigator.platform` after hydration, only to label the Command key. There is no CSS media query for that key. It is not used for device capability.
- Capability helpers live in `src/app/core/platform/browser.ts` and return conservative defaults on the server
- Helpers that use `inject()` must be called from a field initializer or constructor, never from a lifecycle hook or callback, and the resolved value must be stored on the instance
- Every addressable route must remain prerenderable
@@ -121,8 +126,13 @@ German and English are written idiomatically per language, never machine-transla
- Real locale-prefixed URLs
- Server-rendered core text
- Per-route titles from the content layer
- Canonical origin is `SITE_CONFIG.siteOrigin` (`https://antoniolede.de`); every absolute URL in the app and tests is derived from it
- Per-route metadata (description, canonical, reciprocal hreflang including `x-default`, Open Graph, Twitter, robots) is written by `SeoService` during SSR and on every client navigation
- One JSON-LD `@graph` script per route: Person and ProfessionalService on Home, Service on service routes, CreativeWork per public case on projects, WebPage elsewhere
- `public/robots.txt`, `public/sitemap.xml` and `public/llms.txt` stay synchronized with `prerenderablePaths()` via `crawl-assets.spec.ts`
- The language switch exposes `hreflang` on the alternate-locale link
Canonical, hreflang document tags and JSON-LD arrive in the integration phase. The language switch already exposes `hreflang` on the alternate-locale link.
Browser binaries, HTML reports, traces and screenshots are not committed (`test-results`, `playwright-report`, `.lighthouseci`).
## Test checklist
@@ -136,6 +146,8 @@ A change is not done until:
6. Routing and locale contracts still have unit coverage (paths, locale helpers, navigation links)
7. No new `window` / `document` / `navigator` reads were added outside an SSR-safe guard
8. Placeholder or legal copy was not replaced with invented professional claims
9. Crawl files still match `SITE_CONFIG.siteOrigin` and `prerenderablePaths()`
10. Playwright (`npm run e2e`) and Lighthouse CI (`npm run lighthouse`) still pass when the change affects public HTML, metadata or chrome
## Domain boundaries
@@ -143,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.

BIN
CV.pdf

Binary file not shown.

View File

@@ -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. |
@@ -30,7 +30,11 @@ npm install
| `npm run format:check` | Check formatting without writing. |
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
| `npm run e2e` | Playwright + axe-core against the production SSR bundle. |
| `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
@@ -39,7 +43,9 @@ 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.
## Project structure

53
e2e/a11y.e2e.ts Normal file
View File

@@ -0,0 +1,53 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
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'),
pagePath('servicesAi', 'en'),
pagePath('contact', 'de'),
pagePath('contact', 'en'),
pagePath('imprint', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
'/en/missing-route',
];
test.describe('accessibility', () => {
test('revealed content stays visible under reduced motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(pagePath('home', 'de'));
const pending = page.locator('.reveal-pending');
await expect(pending).toHaveCount(0);
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) {
test(`axe has no serious or critical violations on ${path}`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page }).analyze();
const blocking = results.violations.filter(
(violation) => violation.impact === 'serious' || violation.impact === 'critical',
);
const details = blocking
.map((violation) => {
const nodes = violation.nodes.map((node) => node.target.join(' ')).join(', ');
return `${violation.id} (${violation.impact}): ${nodes}`;
})
.join('\n');
expect(blocking, details).toEqual([]);
});
}
});

41
e2e/contact.e2e.ts Normal file
View File

@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
import { pagePath } from './helpers';
test.describe('contact briefing', () => {
test('builds a mailto href and issues no network request', async ({ page }) => {
const extras: string[] = [];
await page.route('**/*', (route) => {
const url = route.request().url();
const resource = route.request().resourceType();
if (resource !== 'document' && !url.startsWith('data:') && !url.startsWith('blob:')) {
extras.push(`${resource} ${url}`);
}
void route.continue();
});
await page.goto(pagePath('contact', 'de'), { waitUntil: 'networkidle' });
extras.length = 0;
const emptySubmit = page.locator('.submit');
await expect(emptySubmit).toHaveAttribute('aria-disabled', 'true');
expect(await emptySubmit.getAttribute('href')).toBeNull();
await page.locator('#contact-name').fill('Ada');
await page.locator('#contact-email').fill('ada@example.com');
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));
expect(href).toContain(encodeURIComponent('Ada'));
expect(href).toContain(SITE_CONFIG.contactEmail);
expect(extras, extras.join('\n')).toEqual([]);
});
});

79
e2e/fragments.e2e.ts Normal file
View File

@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test';
import { APP_LOCALES } from '../src/app/core/i18n/locale';
import { pagePath } from './helpers';
const VIEWPORTS = [768, 820, 1024, 1280, 1440] as const;
const VIEWPORT_HEIGHT = 900;
const MAX_HEADER_HEIGHT = 200;
async function waitForScrollSettle(page: Page): Promise<void> {
await page.evaluate(async () => {
await new Promise<void>((resolve) => {
let last = window.scrollY;
let stableFrames = 0;
const tick = () => {
if (window.scrollY === last) {
stableFrames += 1;
if (stableFrames >= 8) {
resolve();
return;
}
} else {
stableFrames = 0;
last = window.scrollY;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
});
}
test.describe('fragment scrolling', () => {
test('case headings stay fully visible under the compact header', async ({ page }, testInfo) => {
test.skip(
testInfo.project.name === 'mobile',
'desktop widths are measured here to avoid a duplicate run',
);
for (const width of VIEWPORTS) {
await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
for (const locale of APP_LOCALES) {
const path = `${pagePath('projects', locale)}#myspa`;
const label = `${width}px ${locale}`;
await page.goto(path, { waitUntil: 'networkidle' });
const header = page.locator('.site-header');
const heading = page.locator('#myspa h2');
await expect(heading).toBeVisible();
await waitForScrollSettle(page);
const headerBox = await header.boundingBox();
expect(headerBox, label).toBeTruthy();
expect(
headerBox!.height,
`header height at ${label} must be at most ${MAX_HEADER_HEIGHT}px`,
).toBeLessThanOrEqual(MAX_HEADER_HEIGHT);
expect(
headerBox!.height,
`header height at ${label} must be at most 25% of the viewport`,
).toBeLessThanOrEqual(VIEWPORT_HEIGHT * 0.25);
await expect(header).not.toHaveCSS('position', 'sticky');
await expect(heading).toBeInViewport({ ratio: 1 });
const headingOwnsCentre = await heading.evaluate((element) => {
const rect = element.getBoundingClientRect();
const node = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return node !== null && (element === node || element.contains(node));
});
expect(headingOwnsCentre, `heading centre must not be covered at ${label}`).toBe(true);
}
}
});
});

97
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,97 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { type AppLocale } from '../src/app/core/i18n/locale';
import { LOCALE_HTML_LANG } from '../src/app/core/i18n/locale';
import { type RouteId } from '../src/app/core/routing/route-ids';
import { routePath } from '../src/app/core/routing/route-paths';
import { buildRouteMetadata } from '../src/app/core/seo/route-metadata';
export const ORIGIN = SITE_CONFIG.siteOrigin;
export function pagePath(routeId: RouteId, locale: AppLocale): string {
return routePath(routeId, locale);
}
export async function readHtml(
request: APIRequestContext,
path: string,
expectedStatus: number,
): Promise<string> {
const response = await request.get(path);
expect(response.status(), `GET ${path} status`).toBe(expectedStatus);
return response.text();
}
export function attr(html: string, selector: string, attribute: string): string | null {
const pattern = new RegExp(
`<[^>]*${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^>]*${attribute}="([^"]*)"`,
'i',
);
return html.match(pattern)?.[1] ?? null;
}
export function count(html: string, snippet: string): number {
return html.split(snippet).length - 1;
}
export async function expectHead(
request: APIRequestContext,
routeId: RouteId,
locale: AppLocale,
): Promise<void> {
const path =
routeId === 'notFound'
? locale === 'en'
? '/en/missing-route'
: '/missing-route'
: pagePath(routeId, locale);
const expectedStatus = routeId === 'notFound' ? 404 : 200;
const html = await readHtml(request, path, expectedStatus);
const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA);
expect(html, `${path} title`).toContain(`<title>${metadata.title}</title>`);
expect(html).toContain(`name="description"`);
expect(html).toContain(`content="${metadata.description}"`);
expect(html).toContain(`name="robots"`);
expect(html).toContain(`content="${metadata.robots}"`);
expect(html).toContain(`<html lang="${LOCALE_HTML_LANG[locale]}"`);
expect(count(html, 'name="description"')).toBe(1);
expect(count(html, 'name="robots"')).toBe(1);
expect(count(html, 'type="application/ld+json"')).toBe(1);
if (routeId === 'notFound') {
expect(html).not.toMatch(/rel="canonical"/);
expect(html).not.toMatch(/rel="alternate"[^>]*hreflang/);
return;
}
expect(html).toContain(`rel="canonical"`);
expect(html).toContain(`href="${metadata.canonical}"`);
expect(count(html, 'rel="canonical"')).toBe(1);
expect(html).toContain('hreflang="de-DE"');
expect(html).toContain('hreflang="en"');
expect(html).toContain('hreflang="x-default"');
expect(html).toContain('property="og:type"');
expect(html).toContain('property="og:title"');
expect(html).toContain('property="og:description"');
expect(html).toContain('property="og:url"');
expect(html).toContain('property="og:site_name"');
expect(html).toContain('property="og:locale"');
expect(html).toContain('name="twitter:card"');
expect(html).toContain('name="twitter:title"');
expect(html).toContain('name="twitter:description"');
}
export async function expectNoOverflow(page: Page, route: string, width: number): Promise<void> {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth + 1,
);
expect(overflow, `horizontal overflow at ${width}px on ${route}`).toBe(true);
}
export async function jsonLdGraph(page: Page): Promise<unknown> {
const raw = await page.locator('script[type="application/ld+json"]').first().textContent();
expect(raw).toBeTruthy();
return JSON.parse(raw ?? 'null');
}

118
e2e/keyboard.e2e.ts Normal file
View File

@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test';
import { pagePath } from './helpers';
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');
const skip = page.locator('.skip-link');
await expect(skip).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('#main-content')).toBeFocused();
});
test('mobile nav toggle keeps aria-expanded in sync', async ({ page, request }) => {
const ssr = await request.get('/');
expect(ssr.ok()).toBe(true);
const html = await ssr.text();
expect(html).toContain('aria-expanded="true"');
expect(html).toContain('id="primary-nav"');
expect(html).toContain('class="site-nav"');
expect(html).toMatch(/<div[^>]*class="site"/);
expect(html).not.toMatch(/<div[^>]*class="[^"]*\bsite\b[^"]*\bnav-collapsed\b/);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const toggle = page.locator('.nav-toggle');
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
test('collapsed header stays under 25% of the viewport and is not sticky below md', async ({
page,
}) => {
const viewport = { width: 390, height: 844 };
await page.setViewportSize(viewport);
await page.goto('/');
const header = page.locator('.site-header');
const box = await header.boundingBox();
expect(box).toBeTruthy();
expect(box!.height).toBeLessThan(viewport.height * 0.25);
await expect
.poll(async () => header.evaluate((element) => getComputedStyle(element).position))
.not.toBe('sticky');
await page.locator('.nav-toggle').click();
await expect(page.locator('.nav-toggle')).toHaveAttribute('aria-expanded', 'true');
expect(await header.evaluate((element) => getComputedStyle(element).position)).not.toBe(
'sticky',
);
for (const width of [768, 820, 1024, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/');
expect(
await header.evaluate((element) => getComputedStyle(element).position),
`header must not be sticky at ${width}px`,
).not.toBe('sticky');
}
});
test('compact services submenu stays keyboard accessible at 1024px', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name === 'mobile', 'compact submenu applies from md up');
await page.setViewportSize({ width: 1024, height: 900 });
await page.goto('/');
const servicesParent = page.locator(
`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`,
);
const firstChild = page.locator(
`.primary-nav > li > .submenu a[href="${pagePath('servicesSoftware', 'de')}"]`,
);
await servicesParent.focus();
await expect(firstChild).toBeVisible();
await page.keyboard.press('Tab');
await expect(firstChild).toBeFocused();
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('terminal dock opens with Control+K without locking the page and restores on Escape', async ({
page,
}) => {
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
await trigger.focus();
await page.keyboard.press('Control+k');
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(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
});
});

81
e2e/layout.e2e.ts Normal file
View File

@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
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'),
pagePath('contact', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
];
const VIEWPORTS = [320, 768, 1024, 1440] as const;
test.describe('layout and crawlability', () => {
test('does not overflow horizontally on checked routes', async ({ page }) => {
for (const width of VIEWPORTS) {
await page.setViewportSize({ width, height: 900 });
for (const path of CHECKED) {
await page.goto(path);
await expectNoOverflow(page, path, width);
}
}
});
test('same-origin links and crawl files respond below 400', async ({ page, request }) => {
const seen = new Set<string>();
const queue = [pagePath('home', 'de'), pagePath('home', 'en')];
while (queue.length > 0) {
const path = queue.shift() as string;
if (seen.has(path)) {
continue;
}
seen.add(path);
await page.goto(path);
const { urls, baseURI } = await page.evaluate(() => {
const values = [
...Array.from(document.querySelectorAll('a[href]'), (node) => node.getAttribute('href')),
...Array.from(document.querySelectorAll('[src]'), (node) => node.getAttribute('src')),
];
return {
baseURI: document.baseURI,
urls: values.filter(
(value): value is string => typeof value === 'string' && value.length > 0,
),
};
});
for (const raw of urls) {
if (raw.startsWith('mailto:') || raw.startsWith('tel:') || raw.startsWith('#')) {
continue;
}
const resolved = new URL(raw, baseURI);
if (resolved.origin !== new URL(baseURI).origin) {
continue;
}
const next = `${resolved.pathname}${resolved.search}`;
const response = await request.get(next);
expect(response.status(), `${raw} from ${path}`).toBeLessThan(400);
if (!seen.has(next) && !next.includes('.')) {
queue.push(next);
}
}
}
for (const asset of ['/robots.txt', '/sitemap.xml', '/llms.txt', SITE_CONFIG.cvAssetPath]) {
const response = await request.get(asset);
expect(response.status(), asset).toBe(200);
}
});
});

119
e2e/seo.e2e.ts Normal file
View File

@@ -0,0 +1,119 @@
import { expect, test, type Page } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { expectHead, jsonLdGraph, pagePath } from './helpers';
async function ensurePrimaryNavOpen(page: Page): Promise<void> {
const toggle = page.locator('.nav-toggle');
if ((await toggle.isVisible()) && (await toggle.getAttribute('aria-expanded')) === 'false') {
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
}
test.describe('server-rendered metadata', () => {
test('German and English Home include the full head contract', async ({ request, page }) => {
await expectHead(request, 'home', 'de');
await expectHead(request, 'home', 'en');
await page.goto('/');
const graph = (await jsonLdGraph(page)) as { '@graph': Array<{ '@type': string }> };
const types = graph['@graph'].map((node) => node['@type']);
expect(types).toContain('Person');
expect(types).toContain('ProfessionalService');
await page.goto('/en');
const english = (await jsonLdGraph(page)) as { '@graph': Array<{ '@type': string }> };
expect(english['@graph'].map((node) => node['@type'])).toEqual(
expect.arrayContaining(['Person', 'ProfessionalService']),
);
});
test('projects, AI service, contact, legal and 404 keep locale metadata', async ({ request }) => {
for (const locale of ['de', 'en'] as const) {
await expectHead(request, 'projects', locale);
await expectHead(request, 'servicesAi', locale);
await expectHead(request, 'contact', locale);
await expectHead(request, 'imprint', locale);
await expectHead(request, 'notFound', locale);
}
});
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);
await expectHead(request, 'notFound', 'de');
await expectHead(request, 'notFound', 'en');
});
test('client navigation does not duplicate head tags', async ({ page }) => {
await page.goto('/');
await ensurePrimaryNavOpen(page);
await page.locator('a.contact-cta').first().waitFor();
const assertUnique = async (canonical: string, description: string) => {
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="de-DE"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="en"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="x-default"]')).toHaveCount(1);
await expect(page.locator('meta[name="description"]')).toHaveCount(1);
await expect(page.locator('meta[name="robots"]')).toHaveCount(1);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', canonical);
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
description,
);
};
await ensurePrimaryNavOpen(page);
await page
.locator(`a[href="${pagePath('projects', 'de')}"]`)
.first()
.click();
await page.waitForURL('**/projekte');
await assertUnique(
'https://antoniolede.de/projekte',
SITE_CONTENT_DATA.de.pages.projects.description,
);
await ensurePrimaryNavOpen(page);
const servicesParent = page.locator(
`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`,
);
const servicesAiLink = page
.locator(`.primary-nav a[href="${pagePath('servicesAi', 'de')}"]`)
.first();
if (!(await servicesAiLink.isVisible())) {
await servicesParent.hover();
await expect(servicesAiLink).toBeVisible();
}
await servicesAiLink.click();
await page.waitForURL('**/leistungen/ai-integration');
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://antoniolede.de/leistungen/ai-integration',
);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
});
});

66
e2e/systems-map.e2e.ts Normal file
View File

@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test';
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('services', 'de'));
for (const width of [390, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
const list = page.locator('.systems-map-list');
await expect(list).toBeVisible();
await expect(list.locator('.systems-map-cards li')).toHaveCount(8);
const figure = page.locator('.systems-map-figure');
if (width < 1024) {
await expect(figure).toBeHidden();
} else {
await expect(figure).toBeVisible();
}
}
});
test('keeps SVG labels inside their shapes at 1024 and 1440', async ({ page }) => {
await page.goto(pagePath('services', 'de'));
for (const width of [1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
const overflow = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll<SVGGElement>('.systems-map-node'));
return nodes.flatMap((node) => {
const shape = node.querySelector('circle, rect');
const text = node.querySelector('text');
if (!shape || !text) {
return [`${node.getAttribute('aria-label') ?? 'node'} missing shape or text`];
}
const shapeBox = (shape as SVGGraphicsElement).getBBox();
const textBox = (text as SVGGraphicsElement).getBBox();
const fits =
textBox.x >= shapeBox.x - 0.5 &&
textBox.y >= shapeBox.y - 0.5 &&
textBox.x + textBox.width <= shapeBox.x + shapeBox.width + 0.5 &&
textBox.y + textBox.height <= shapeBox.y + shapeBox.height + 0.5;
return fits ? [] : [node.getAttribute('aria-label') ?? 'unnamed node'];
});
});
expect(overflow, `labels overflow at ${width}px`).toEqual([]);
}
});
test('every SVG node points at a real route', async ({ page, request }) => {
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 1440, height: 900 });
const hrefs = await page
.locator('.systems-map-node')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('href') ?? ''));
expect(hrefs.length).toBeGreaterThan(0);
for (const href of hrefs) {
const response = await request.get(href);
expect(response.status(), href).toBe(200);
}
});
});

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

@@ -0,0 +1,145 @@
import { expect, test } from '@playwright/test';
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
test.describe('terminal dock', () => {
test('walks collapsed, expanded and maximized and runs the grammar', async ({
page,
}, testInfo) => {
const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel');
const input = page.locator('#terminal-dock-input');
const log = page.locator('.terminal-dock-log');
const maximize = page.locator('.terminal-dock-control[aria-pressed]');
await expect(trigger).toBeVisible();
await expect(panel).toHaveCount(0);
await trigger.click();
await expect(panel).toBeVisible();
await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt);
if (testInfo.project.name === 'mobile') {
const box = await panel.boundingBox();
expect(box, 'bottom-sheet panel').toBeTruthy();
expect(box!.width).toBeGreaterThan(300);
} else {
await maximize.click();
await expect(maximize).toHaveAttribute('aria-pressed', 'true');
await maximize.click();
await expect(maximize).toHaveAttribute('aria-pressed', 'false');
}
await input.fill('navigate pitch');
await input.press('Enter');
await page.waitForURL('**/pitch');
await expect(page).toHaveURL(/\/pitch$/);
await expect(log).toContainText(`${copy.prompt} navigate pitch`);
await input.press('ArrowUp');
await expect(input).toHaveValue('navigate pitch');
await input.fill('history');
await input.press('Enter');
await expect(log).toContainText(copy.historyIntro);
await expect(log).toContainText('navigate pitch');
await input.fill('clear');
await input.press('Enter');
await expect(log).toHaveText(copy.clearedMessage);
await input.fill('navigate p');
await input.press('Tab');
await expect(log).toContainText('pitch');
await expect(log).toContainText('projects');
await input.fill('navigate nowhere');
await input.press('Enter');
await expect(log).toContainText(copy.validTargetsLabel);
await expect(log).toContainText('pitch');
await input.fill('nav');
await input.press('Tab');
await expect(input).toHaveValue('navigate');
});
test('clicking the visible desktop trigger while open collapses the panel', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name === 'mobile',
'Below md the trigger is hidden while the panel is open.',
);
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel');
await trigger.click();
await expect(panel).toBeVisible();
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await trigger.click();
await expect(panel).toHaveCount(0);
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(trigger).toBeFocused();
});
test('keeps the newest echoed line in the log scrollport after overflow', async ({ page }) => {
const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const input = page.locator('#terminal-dock-input');
const log = page.locator('.terminal-dock-log');
await trigger.click();
await expect(log).toBeVisible();
for (let index = 0; index < 8; index += 1) {
await input.fill('help');
await input.press('Enter');
}
await expect(log.locator('p.echo').last()).toHaveText(`${copy.prompt} help`);
await expect
.poll(async () =>
log.evaluate((element) => {
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
}),
)
.toBe(true);
const last = log.locator('p').last();
const logBox = await log.boundingBox();
const lastBox = await last.boundingBox();
expect(logBox && lastBox).toBeTruthy();
expect(lastBox!.y).toBeGreaterThanOrEqual(logBox!.y - 1);
expect(lastBox!.y + lastBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height + 1);
});
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
page,
}) => {
const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel');
await trigger.click();
await expect(panel).toBeVisible();
const collapse = page.getByRole('button', { name: copy.collapseLabel });
await collapse.focus();
await expect(collapse).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
});
});

View File

@@ -6,10 +6,19 @@ const prettier = require('eslint-config-prettier');
module.exports = tseslint.config(
{
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'coverage/**'],
ignores: [
'dist/**',
'.angular/**',
'node_modules/**',
'coverage/**',
'playwright-report/**',
'test-results/**',
'.lighthouseci/**',
],
},
{
files: ['**/*.ts'],
ignores: ['e2e/**/*.ts', 'playwright.config.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
@@ -39,5 +48,15 @@ module.exports = tseslint.config(
files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
},
{
files: ['e2e/**/*.ts', 'playwright.config.ts'],
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
parserOptions: {
project: './tsconfig.e2e.json',
tsconfigRootDir: __dirname,
},
},
},
prettier,
);

33
lighthouserc.json Normal file
View File

@@ -0,0 +1,33 @@
{
"ci": {
"collect": {
"startServerCommand": "npm run serve:ssr",
"startServerReadyPattern": "Node Express server listening",
"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"
],
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"chromeFlags": "--no-sandbox --headless=new"
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"categories:accessibility": ["error", { "minScore": 1 }],
"categories:best-practices": ["error", { "minScore": 1 }],
"categories:seo": ["error", { "minScore": 1 }]
}
},
"upload": {
"target": "filesystem",
"outputDir": ".lighthouseci"
}
}
}

3225
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,11 @@
"format:check": "prettier --check .",
"serve:ssr": "node dist/Portfolio/server/server.mjs",
"serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs",
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build"
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build",
"e2e": "playwright test",
"e2e:install": "playwright install chromium",
"lighthouse": "npm run build && lhci autorun",
"cv:link": "node scripts/add-cv-pitch-link.mjs"
},
"prettier": {
"printWidth": 100,
@@ -47,13 +51,18 @@
"@angular/build": "^21.0.5",
"@angular/cli": "^21.0.5",
"@angular/compiler-cli": "^21.0.0",
"@axe-core/playwright": "^4.13.0",
"@eslint/js": "^9.39.5",
"@lhci/cli": "^0.15.1",
"@playwright/test": "1.62.1",
"@types/express": "^5.0.1",
"@types/node": "^20.17.19",
"angular-eslint": "^21.4.0",
"axe-core": "^4.13.0",
"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",

40
playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.e2e.ts',
forbidOnly: !!process.env['CI'],
retries: 0,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:4173',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run build && npm run serve:ssr',
url: 'http://127.0.0.1:4173',
env: { PORT: '4173' },
timeout: 300_000,
reuseExistingServer: !process.env['CI'],
},
projects: [
{
name: 'desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'mobile',
use: {
...devices['Desktop Chrome'],
viewport: { width: 390, height: 844 },
hasTouch: true,
isMobile: true,
},
},
],
});

53
public/llms.txt Normal file
View File

@@ -0,0 +1,53 @@
# Antonio Ledebuhr
Fullstack and DevOps engineer in Tangermünde. The public site documents a curated set of stations, four service areas, and a local mailto briefing. German is the default language; English is the full second version.
## Profile
- 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
## Services
- https://antoniolede.de/leistungen
- https://antoniolede.de/en/services
- https://antoniolede.de/leistungen/software
- https://antoniolede.de/en/services/software
- https://antoniolede.de/leistungen/hardware-netzwerk
- https://antoniolede.de/en/services/hardware-network
- https://antoniolede.de/leistungen/cluster
- https://antoniolede.de/en/services/clusters
- https://antoniolede.de/leistungen/ai-integration
- https://antoniolede.de/en/services/ai-integration
## Projects
- https://antoniolede.de/projekte
- https://antoniolede.de/en/projects
- https://antoniolede.de/projekte#innofocus
- https://antoniolede.de/en/projects#innofocus
- https://antoniolede.de/projekte#roesterei
- https://antoniolede.de/en/projects#roesterei
- https://antoniolede.de/projekte#myspa
- https://antoniolede.de/en/projects#myspa
- https://antoniolede.de/projekte#hdi
- https://antoniolede.de/en/projects#hdi
## Contact, stack and legal
- https://antoniolede.de/kontakt
- https://antoniolede.de/en/contact
- https://antoniolede.de/stack
- https://antoniolede.de/en/stack
- https://antoniolede.de/impressum
- https://antoniolede.de/en/legal-notice
- https://antoniolede.de/datenschutz
- https://antoniolede.de/en/privacy
## Languages
German URLs are unprefixed. English URLs live under `/en`. Each public page exists in both locales.

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://antoniolede.de/sitemap.xml

159
public/sitemap.xml Normal file
View File

@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://antoniolede.de/</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/" />
<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" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
</url>
<url>
<loc>https://antoniolede.de/leistungen/software</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
</url>
<url>
<loc>https://antoniolede.de/leistungen/hardware-netzwerk</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
</url>
<url>
<loc>https://antoniolede.de/leistungen/cluster</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
</url>
<url>
<loc>https://antoniolede.de/leistungen/ai-integration</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
</url>
<url>
<loc>https://antoniolede.de/projekte</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/projects" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/projekte" />
</url>
<url>
<loc>https://antoniolede.de/stack</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/stack" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/stack" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/stack" />
</url>
<url>
<loc>https://antoniolede.de/ueber-mich</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/ueber-mich" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/ueber-mich" />
</url>
<url>
<loc>https://antoniolede.de/kontakt</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/kontakt" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/kontakt" />
</url>
<url>
<loc>https://antoniolede.de/impressum</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/impressum" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/legal-notice" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/impressum" />
</url>
<url>
<loc>https://antoniolede.de/datenschutz</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/datenschutz" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/datenschutz" />
</url>
<url>
<loc>https://antoniolede.de/en</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/" />
<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" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
</url>
<url>
<loc>https://antoniolede.de/en/services/software</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
</url>
<url>
<loc>https://antoniolede.de/en/services/hardware-network</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
</url>
<url>
<loc>https://antoniolede.de/en/services/clusters</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
</url>
<url>
<loc>https://antoniolede.de/en/services/ai-integration</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
</url>
<url>
<loc>https://antoniolede.de/en/projects</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/projects" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/projekte" />
</url>
<url>
<loc>https://antoniolede.de/en/stack</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/stack" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/stack" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/stack" />
</url>
<url>
<loc>https://antoniolede.de/en/about</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/ueber-mich" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/ueber-mich" />
</url>
<url>
<loc>https://antoniolede.de/en/contact</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/kontakt" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/kontakt" />
</url>
<url>
<loc>https://antoniolede.de/en/legal-notice</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/impressum" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/legal-notice" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/impressum" />
</url>
<url>
<loc>https://antoniolede.de/en/privacy</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/datenschutz" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/datenschutz" />
</url>
</urlset>

View File

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

View File

@@ -1,9 +1,15 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import {
provideRouter,
TitleStrategy,
withComponentInputBinding,
withInMemoryScrolling,
} from '@angular/router';
import { routes } from './app.routes';
import { SITE_CONTENT } from './core/content/content.token';
import { SITE_CONTENT_DATA } from './core/content/site-content';
import { routes } from './app.routes';
import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
export const appConfig: ApplicationConfig = {
providers: [
@@ -18,5 +24,6 @@ export const appConfig: ApplicationConfig = {
),
provideClientHydration(withEventReplay()),
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
],
};

View File

@@ -6,6 +6,7 @@
<a class="site-identity" [routerLink]="navigation.link('home')">
{{ siteConfig.personName }}
</a>
<div class="site-toolbar cluster">
<button
type="button"
class="nav-toggle"
@@ -16,6 +17,7 @@
>
<span aria-hidden="true"></span>
</button>
</div>
<nav class="site-nav" [attr.aria-label]="shell().primaryNavLabel">
<ul class="primary-nav" id="primary-nav">
@for (item of navigation.primaryNav(); track item.routeId) {
@@ -28,7 +30,7 @@
>{{ item.label }}</a
>
@if (item.children; as children) {
<ul>
<ul class="submenu">
@for (child of children; track child.routeId) {
<li>
<a
@@ -55,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>
@@ -91,3 +86,4 @@
</div>
</footer>
</div>
<app-terminal-dock></app-terminal-dock>

View File

@@ -0,0 +1,22 @@
import { RenderMode } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';
describe('server routes', () => {
it('returns HTTP 404 from the catch-all and leaves prerendered routes without a status', () => {
const catchAll = serverRoutes.find((route) => route.path === '**');
expect(catchAll?.renderMode).toBe(RenderMode.Server);
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
const prerendered = serverRoutes.filter((route) => route.path !== '**');
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);
expect('status' in route, `${route.path} must not set status`).toBe(false);
}
});
});

View File

@@ -9,5 +9,6 @@ export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Server,
status: 404,
},
];

View File

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

View File

@@ -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),

View File

@@ -14,9 +14,9 @@
}
.site-header {
position: sticky;
position: relative;
top: 0;
z-index: 10;
z-index: 30;
padding-block: var(--space-3);
}
@@ -37,14 +37,34 @@
text-decoration: none;
}
.site-identity,
.site-nav a,
.site-actions a,
.site-footer a,
.nav-toggle {
min-height: 2.75rem;
}
.site-identity,
.site-nav a,
.site-actions a,
.site-footer a {
display: inline-flex;
align-items: center;
}
.site-identity {
font-weight: 600;
}
.nav-toggle {
.site-toolbar {
justify-self: end;
width: 2.5rem;
height: 2.5rem;
align-items: center;
}
.nav-toggle {
width: 2.75rem;
height: 2.75rem;
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: transparent;
@@ -116,7 +136,11 @@
.site-main {
flex: 1;
outline: none;
}
.site-main:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
.site-footer {
@@ -137,27 +161,75 @@
display: none;
}
.site-header {
padding-block: var(--space-2);
}
.site-header-inner {
grid-template-columns: auto 1fr auto;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-2);
padding-block: var(--space-2);
}
.site-nav,
.nav-collapsed .site-nav {
display: flex;
grid-column: 1 / -1;
order: 5;
}
.site-actions,
.nav-collapsed .site-nav,
.nav-collapsed .site-actions {
display: flex;
grid-column: auto;
order: 3;
}
.site-toolbar {
order: 2;
}
.primary-nav {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-4);
flex-wrap: nowrap;
align-items: center;
gap: var(--space-3);
}
.primary-nav ul {
padding-inline-start: 0;
.primary-nav > li {
position: relative;
z-index: 31;
}
.primary-nav > li > a {
white-space: nowrap;
}
.primary-nav > li > .submenu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: max-content;
z-index: 32;
padding: var(--space-3) var(--space-4);
gap: var(--space-1);
border-radius: var(--radius-md);
background: var(--color-surface-menu);
border: 1px solid var(--surface-glass-border);
box-shadow: var(--shadow-raised);
}
.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;
}
}
}

View File

@@ -1,22 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { provideRouter, Router, TitleStrategy } from '@angular/router';
import { App, WIDE_NAV_QUERY } from './app';
import { routes } from './app.routes';
import { SITE_CONTENT } from './core/content/content.token';
import { SITE_CONTENT_DATA } from './core/content/site-content';
import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
function mockViewport(wide: boolean): void {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: wide && query === WIDE_NAV_QUERY,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
async function configureApp(
extraProviders: { provide: unknown; useValue: unknown }[] = [],
): Promise<void> {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter(routes),
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
...extraProviders,
],
}).compileComponents();
}
async function flush(fixture: ComponentFixture<App>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
await fixture.whenStable();
}
function toggle(root: HTMLElement): HTMLButtonElement {
return root.querySelector('.nav-toggle') as HTMLButtonElement;
}
describe('App', () => {
beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(true);
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
}).compileComponents();
await configureApp();
});
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, 'matchMedia');
});
it('should create the shell', async () => {
@@ -43,4 +88,86 @@ describe('App', () => {
expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy();
expect(compiled.querySelector('footer')).toBeTruthy();
});
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 dock = compiled.querySelector('app-terminal-dock');
const trigger = compiled.querySelector('.terminal-dock-trigger');
expect(site).toBeTruthy();
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();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
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 () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp();
const fixture = TestBed.createComponent(App);
const shell = fixture.componentInstance as unknown as { navOpen: () => boolean };
expect(shell.navOpen()).toBe(true);
await flush(fixture);
const button = toggle(fixture.nativeElement);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('aria-controls')).toBe('primary-nav');
expect(button.getAttribute('aria-label')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
true,
);
});
it('does not collapse the nav on the server even when the viewport helper would be narrow', async () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp([{ provide: PLATFORM_ID, useValue: 'server' }]);
const fixture = TestBed.createComponent(App);
await flush(fixture);
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('true');
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
false,
);
});
it('closes the nav on navigation when the viewport is narrow', async () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp();
const fixture = TestBed.createComponent(App);
await flush(fixture);
const button = toggle(fixture.nativeElement);
expect(button.getAttribute('aria-expanded')).toBe('false');
button.click();
fixture.detectChanges();
expect(button.getAttribute('aria-expanded')).toBe('true');
await TestBed.inject(Router).navigateByUrl('/projekte');
await flush(fixture);
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('false');
});
});

View File

@@ -1,22 +1,44 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { DOCUMENT, ViewportScroller } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
inject,
Injector,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';
import { DotBackground } from './components/dot-background/dot-background';
import { SHELL_COPY } from './core/content/shell-copy';
import { SITE_CONFIG } from './core/content/site-config';
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 { TerminalDock } from './shared/terminal/terminal-dock';
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground],
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);
private readonly router = inject(Router);
private readonly injector = inject(Injector);
private readonly document = inject(DOCUMENT);
private readonly viewportScroller = inject(ViewportScroller);
private readonly isBrowser = isBrowserPlatform();
private readonly wideNav = viewportMatches(WIDE_NAV_QUERY);
protected readonly siteConfig = SITE_CONFIG;
protected readonly navOpen = signal(true);
@@ -27,7 +49,61 @@ export class App {
this.navOpen() ? this.shell().menuClose : this.shell().menuOpen,
);
constructor() {
afterNextRender(
() => {
this.collapseNavIfNarrow();
this.bindHeaderScrollOffset();
},
{ injector: this.injector },
);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.collapseNavIfNarrow());
}
protected toggleNav(): void {
this.navOpen.update((open) => !open);
}
private collapseNavIfNarrow(): void {
if (!this.isBrowser || this.wideNav) {
return;
}
this.navOpen.set(false);
}
private bindHeaderScrollOffset(): void {
if (!this.isBrowser) {
return;
}
this.viewportScroller.setOffset(() => [0, this.headerOffsetPx()]);
}
private headerOffsetPx(): number {
const view = this.document.defaultView;
if (!view) {
return 0;
}
const styles = view.getComputedStyle(this.document.documentElement);
const raw = styles.getPropertyValue('--header-offset').trim();
const numeric = Number.parseFloat(raw);
if (!Number.isFinite(numeric)) {
return 0;
}
if (raw.endsWith('rem')) {
const rootSize = Number.parseFloat(styles.fontSize);
return numeric * (Number.isFinite(rootSize) ? rootSize : 16);
}
return numeric;
}
}

View File

@@ -1,53 +1,281 @@
import { ApplicationRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DotBackground } from './dot-background';
describe('DotBackground', () => {
let component: DotBackground;
let fixture: ComponentFixture<DotBackground>;
beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
fixture = TestBed.createComponent(DotBackground);
component = fixture.componentInstance;
await fixture.whenStable();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('does not throw when destroyed after browser initialization', async () => {
function mockContext(): CanvasRenderingContext2D {
const gradient = { addColorStop: vi.fn() };
const context = {
return {
clearRect: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
createRadialGradient: vi.fn(() => gradient),
};
} as unknown as CanvasRenderingContext2D;
}
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
context as unknown as CanvasRenderingContext2D,
);
const animationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0);
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: matchesQuery(query),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
const initializedFixture = TestBed.createComponent(DotBackground);
initializedFixture.detectChanges();
await initializedFixture.whenStable();
expect(() => initializedFixture.destroy()).not.toThrow();
animationFrameSpy.mockRestore();
describe('DotBackground', () => {
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, 'matchMedia');
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
});
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
return fixture;
}
it('should create', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const fixture = await createFixture();
expect(fixture.componentInstance).toBeTruthy();
});
it('does not throw when destroyed after browser initialization', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
const fixture = await createFixture();
expect(() => fixture.destroy()).not.toThrow();
});
it('adds no listener and schedules no animation frame when the 2D context is null', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
const windowAdd = vi.spyOn(window, 'addEventListener');
const documentAdd = vi.spyOn(document, 'addEventListener');
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
expect(scheduled).toEqual([]);
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
expect(documentAdd.mock.calls.some((call) => call[0] === 'visibilitychange')).toBe(false);
expect(() => fixture.destroy()).not.toThrow();
});
it('cancels the scheduled frame and removes every listener on destroy', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(17);
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
const windowAdd = vi.spyOn(window, 'addEventListener');
const windowRemove = vi.spyOn(window, 'removeEventListener');
const documentAdd = vi.spyOn(document, 'addEventListener');
const documentRemove = vi.spyOn(document, 'removeEventListener');
const fixture = await createFixture();
expect(raf).toHaveBeenCalled();
const windowAdds = windowAdd.mock.calls.filter((call) =>
['resize', 'mousemove', 'click'].includes(String(call[0])),
);
const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange');
expect(windowAdds.length).toBeGreaterThan(0);
expect(documentAdds.length).toBeGreaterThan(0);
fixture.destroy();
expect(cancel).toHaveBeenCalled();
for (const [type, handler] of windowAdds) {
expect(windowRemove).toHaveBeenCalledWith(type, handler);
}
for (const [type, handler] of documentAdds) {
expect(documentRemove).toHaveBeenCalledWith(type, handler);
}
});
it('draws a single static frame under reduced motion and skips pointer listeners', async () => {
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
const context = mockContext();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
const windowAdd = vi.spyOn(window, 'addEventListener');
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
expect(context.clearRect).toHaveBeenCalled();
expect(scheduled).toEqual([]);
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
fixture.destroy();
});
it('registers no pointer listeners for a coarse pointer', async () => {
mockMatchMedia((query) => query.includes('pointer: coarse'));
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
const windowAdd = vi.spyOn(window, 'addEventListener');
await createFixture();
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(true);
});
it('pauses the loop when the document is hidden and resumes when it is visible', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(21);
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
await createFixture();
expect(raf).toHaveBeenCalled();
raf.mockClear();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => true,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(cancel).toHaveBeenCalled();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(raf).toHaveBeenCalled();
});
it('starts with a bounded initial dot count on a large desktop viewport', async () => {
const context = mockContext();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
const widthStub = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(2560);
const heightStub = vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1440);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
try {
const fixture = await createFixture();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
const arcCalls = vi.mocked(context.arc).mock.calls.length;
expect(arcCalls).toBeGreaterThanOrEqual(6);
expect(arcCalls).toBeLessThanOrEqual(24);
fixture.destroy();
} finally {
widthStub.mockRestore();
heightStub.mockRestore();
}
});
it('redraws the static frame after resize under reduced motion without starting a loop', async () => {
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
const context = mockContext();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
vi.mocked(context.clearRect).mockClear();
vi.mocked(context.arc).mockClear();
window.dispatchEvent(new Event('resize'));
const resizeFrames = scheduled.splice(0);
for (const callback of resizeFrames) {
callback(0);
}
expect(context.clearRect).toHaveBeenCalled();
expect(context.arc).toHaveBeenCalled();
expect(scheduled).toEqual([]);
fixture.destroy();
});
});

View File

@@ -1,13 +1,18 @@
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
inject,
NgZone,
OnDestroy,
ViewChild,
} from '@angular/core';
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
import {
isBrowserPlatform,
prefersCoarsePointer,
prefersReducedMotion,
} from '../../core/platform/browser';
import { Dot } from '../../models/dot';
@Component({
@@ -15,21 +20,33 @@ import { Dot } from '../../models/dot';
imports: [],
templateUrl: './dot-background.html',
styleUrl: './dot-background.scss',
host: {
'aria-hidden': 'true',
},
})
export class DotBackground implements OnDestroy {
export class DotBackground {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private readonly document = inject(DOCUMENT);
private readonly ngZone = inject(NgZone);
private readonly coarsePointer = prefersCoarsePointer();
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isBrowserPlatform();
private readonly coarsePointer = prefersCoarsePointer();
private readonly reducedMotion = prefersReducedMotion();
private ctx: CanvasRenderingContext2D | undefined;
private dots: Dot[] = [];
private mouse = { x: -1000, y: -1000 };
private animationId = 0;
private initialized = false;
private resizeFrameId = 0;
private loopActive = false;
private tornDown = false;
private readonly teardowns: Array<() => void> = [];
private readonly INIT_DOT_COUNT = 12;
private readonly INITIAL_DOT_COUNT_MIN = 6;
private readonly INITIAL_DOT_COUNT_MAX = 24;
private readonly INITIAL_DOT_COUNT_MAX_COARSE = 12;
private readonly INITIAL_DOT_AREA_DIVISOR = 130_000;
private readonly MAX_DOT_COUNT = 100;
private readonly MAX_DOT_COUNT_MOBILE = 40;
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
@@ -39,27 +56,34 @@ export class DotBackground implements OnDestroy {
private ballSpawnNextColor = 0;
constructor() {
this.destroyRef.onDestroy(() => this.teardown());
afterNextRender(() => {
this.init();
});
}
ngOnDestroy() {
if (!this.initialized) {
private view(): Window | null {
return this.document.defaultView;
}
private init(): void {
if (this.tornDown || !this.isBrowser) {
return;
}
cancelAnimationFrame(this.animationId);
const view = this.view();
if (this.isBrowser) {
window.removeEventListener('resize', this.resize);
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('click', this.onMouseClick);
}
if (!view) {
return;
}
const canvas = this.canvasRef?.nativeElement;
if (!canvas) {
return;
}
private init() {
const canvas = this.canvasRef.nativeElement;
let ctx: CanvasRenderingContext2D | null = null;
try {
@@ -76,21 +100,113 @@ export class DotBackground implements OnDestroy {
this.resize();
this.initDots();
window.addEventListener('resize', this.resize);
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('click', this.onMouseClick);
this.listen(view, 'resize', this.onResize);
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
this.initialized = true;
this.ngZone.runOutsideAngular(() => this.animate());
if (!this.reducedMotion && !this.coarsePointer) {
this.listen(view, 'mousemove', this.onMouseMove);
this.listen(view, 'click', this.onMouseClick);
}
private resize = () => {
const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
if (this.reducedMotion) {
this.drawFrame();
return;
}
const dx = Math.abs(width - canvas.width) / width;
const dy = Math.abs(height - canvas.height) / height;
this.ngZone.runOutsideAngular(() => this.startLoop());
}
private listen(target: EventTarget, type: string, handler: EventListener): void {
target.addEventListener(type, handler);
this.teardowns.push(() => target.removeEventListener(type, handler));
}
private requestFrame(callback: FrameRequestCallback): number {
const view = this.view();
return view ? view.requestAnimationFrame(callback) : 0;
}
private cancelFrame(id: number): void {
const view = this.view();
if (!view || id === 0) {
return;
}
view.cancelAnimationFrame(id);
}
private startLoop(): void {
if (this.loopActive || this.reducedMotion || this.tornDown) {
return;
}
this.loopActive = true;
this.scheduleAnimate();
}
private stopLoop(): void {
this.loopActive = false;
this.cancelFrame(this.animationId);
this.animationId = 0;
}
private scheduleAnimate(): void {
this.animationId = this.requestFrame(this.animate);
}
private teardown(): void {
if (this.tornDown) {
return;
}
this.tornDown = true;
this.stopLoop();
this.cancelFrame(this.resizeFrameId);
this.resizeFrameId = 0;
for (const dispose of this.teardowns) {
dispose();
}
this.teardowns.length = 0;
}
private onResize = (): void => {
if (this.resizeFrameId !== 0) {
return;
}
this.resizeFrameId = this.requestFrame(() => {
this.resizeFrameId = 0;
this.resize();
});
};
private onVisibilityChange = (): void => {
if (this.document.hidden) {
this.stopLoop();
return;
}
if (!this.reducedMotion) {
this.ngZone.runOutsideAngular(() => this.startLoop());
}
};
private resize = (): void => {
const view = this.view();
const canvas = this.canvasRef?.nativeElement;
if (!view || !canvas) {
return;
}
const width = view.innerWidth;
const height = view.innerHeight;
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
canvas.width = width;
@@ -100,21 +216,44 @@ export class DotBackground implements OnDestroy {
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
}
if (this.reducedMotion && this.ctx) {
this.drawFrame();
}
}
};
private onMouseMove = (e: MouseEvent) => {
const canvas = this.canvasRef.nativeElement;
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
private onMouseMove = (event: Event): void => {
if (!(event instanceof MouseEvent)) {
return;
}
const view = this.view();
const canvas = this.canvasRef?.nativeElement;
if (!view || !canvas) {
return;
}
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
};
private onMouseClick = () => {
private onMouseClick = (): void => {
const dot = this.spawnDot();
dot.x = this.mouse.x;
dot.y = this.mouse.y;
};
private targetDotCount(width: number, height: number): number {
const maxInitial = this.coarsePointer
? this.INITIAL_DOT_COUNT_MAX_COARSE
: this.INITIAL_DOT_COUNT_MAX;
const area = Math.max(0, width) * Math.max(0, height);
const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR);
return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea));
}
private spawnDot(): Dot {
const dotId = this.ballSpawnId++;
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
@@ -140,7 +279,7 @@ export class DotBackground implements OnDestroy {
return dot;
}
private populateDot(dot: Dot) {
private populateDot(dot: Dot): void {
const { width, height } = this.canvasRef.nativeElement;
dot.x = Math.random() * width;
@@ -151,17 +290,29 @@ export class DotBackground implements OnDestroy {
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
}
private initDots() {
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
private initDots(): void {
const { width, height } = this.canvasRef.nativeElement;
const count = this.targetDotCount(width, height);
for (let i = 0; i < count; i++) {
this.spawnDot();
}
}
private animate = () => {
const canvas = this.canvasRef.nativeElement;
private animate = (): void => {
this.animationId = 0;
this.drawFrame();
if (this.loopActive && !this.tornDown) {
this.scheduleAnimate();
}
};
private drawFrame(): void {
const canvas = this.canvasRef?.nativeElement;
const ctx = this.ctx;
if (!ctx) {
if (!canvas || !ctx) {
return;
}
@@ -213,7 +364,5 @@ export class DotBackground implements OnDestroy {
ctx.fillStyle = gradient;
ctx.fill();
}
this.animationId = requestAnimationFrame(this.animate);
};
}
}

View File

@@ -105,6 +105,10 @@ const BASE_CATEGORIES: readonly SkillCategory[] = [
},
];
export const SKILL_GRID_ITEM_NAMES: readonly string[] = BASE_CATEGORIES.flatMap((category) =>
category.skills.map((skill) => skill.name),
);
@Component({
selector: 'app-skills-grid',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -0,0 +1,10 @@
export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate';
export const COMMAND_IDS: readonly CommandId[] = [
'help',
'history',
'clear',
'brew',
'rev',
'navigate',
];

View File

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

View File

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

View File

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

View File

@@ -96,21 +96,48 @@ export interface ServiceSequenceCopy {
export interface ServicePageCopy extends PageCopy {
readonly sequence: ServiceSequenceCopy;
readonly offerings: readonly OfferingCopy[];
readonly deliveredOfferingsHeading: string;
readonly offerOfferingsHeading: string;
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 timelineHeading: string;
readonly stackHeading: string;
}
export interface ServicesOverviewPageCopy extends PageCopy {
readonly systemsMap: { readonly heading: string; readonly intro: string };
}
export type ContactFieldId =
@@ -163,12 +190,14 @@ export interface StackGroupCopy {
export interface StackPageCopy extends PageCopy {
readonly groups: readonly StackGroupCopy[];
readonly evidenceNote: string;
}
export interface CaseStudyLabels {
readonly situation: string;
readonly approach: string;
readonly outcome: string;
readonly metrics: string;
readonly stack: string;
readonly tags: string;
}
@@ -180,13 +209,22 @@ export interface ProjectsPageCopy extends PageCopy {
export type ServicePageId =
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
export interface SiteSeoCopy {
readonly jobTitle: string;
readonly professionalServiceName: string;
readonly professionalServiceDescription: string;
}
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;
readonly projects: ProjectsPageCopy;
readonly stack: StackPageCopy;
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
readonly seo: SiteSeoCopy;
}

View File

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

View File

@@ -1,99 +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'],
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' },
],
};

View File

@@ -35,12 +35,14 @@ 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: {
situation: 'Lage',
approach: 'Vorgehen',
outcome: 'Ergebnis',
metrics: 'Kennzahlen',
stack: 'Technik',
tags: 'Schlagworte',
},
@@ -50,21 +52,23 @@ export const STACK_DE: StackPageCopy = {
routeId: 'stack',
title: 'Technik-Stack | Antonio Ledebuhr',
description:
'Der öffentliche Stack in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
'Technik-Überblick in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
hero: {
headline: 'Der Stack, der auf diesen Seiten vorkommt',
headline: 'Ein Technik-Überblick zur Einordnung',
proof:
'Die Gruppen gehören zur Produktarbeit in Java und Spring sowie in C# und .NET, zur Datenarbeit in SQL Server, zu Containern und Clustern und zu Servern vor Ort.',
'Die Rasterkarte ist eine Vertrautheitsliste. Sie zeigt Technologien aus der Arbeit, ohne jeden Eintrag als öffentlichen Fall zu belegen.',
},
sections: [
{
id: 'reading',
headline: 'Eine Auswahl, keine Rangliste',
body: ['Die Übersicht ist eine Auswahl aus der öffentlichen Arbeit, keine Rangliste.'],
headline: 'Überblick, keine Rangliste',
body: [
'Die Übersicht ordnet Technologien nach Gruppen. Sie ist keine Rangliste und kein Nachweis, dass jeder Eintrag in einem öffentlichen Fall vorkommt.',
],
},
{
id: 'groups',
headline: 'Wo die Gruppen in der Arbeit vorkommen',
headline: 'Welche Gruppen die Arbeit berührt',
body: [
'Produktarbeit in Java und Spring sowie in C# und .NET, mit Angular auf der Oberfläche. Datenarbeit in SQL Server und T-SQL. Container und Cluster mit Docker, Kubernetes und Azure AKS. Pipelines in GitLab CI/CD und Azure DevOps, GitOps mit Argo CD. Server und Virtualisierung vor Ort.',
],
@@ -83,6 +87,8 @@ export const STACK_DE: StackPageCopy = {
{ id: 'hyperviser', title: 'Hypervisor' },
{ id: 'tools', title: 'Werkzeuge' },
],
evidenceNote:
'In den öffentlichen Fällen sind unter anderem Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox und Microsoft 365 belegt. Die übrigen Einträge der Übersicht stehen für Breite und Vertrautheit, ohne öffentlichen Referenzfall.',
};
export const ABOUT_DE: PageCopy = {
@@ -129,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' },
],
};
@@ -168,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' },

View File

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

View File

@@ -1,6 +1,6 @@
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_DE: PageCopy = {
export const SERVICES_OVERVIEW_DE: ServicesOverviewPageCopy = {
routeId: 'services',
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 = {
@@ -126,6 +130,9 @@ export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
@@ -208,6 +215,9 @@ export const SERVICES_HARDWARE_DE: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
@@ -289,6 +299,9 @@ export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};
export const SERVICES_AI_DE: ServicePageCopy = {
@@ -421,4 +434,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
status: 'offer',
},
],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};

View File

@@ -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,
@@ -34,8 +37,15 @@ export const SITE_CONTENT_DE: SiteContent = {
imprint: IMPRINT_DE,
privacy: PRIVACY_DE,
},
seo: {
jobTitle: 'Fullstack- und DevOps-Ingenieur',
professionalServiceName: 'Software- und DevOps-Leistungen',
professionalServiceDescription:
'Vier Leistungsbereiche für Produktsoftware, Infrastruktur, Cluster und KI-Anbindung — vom ersten Workshop bis zum Betrieb, mit direktem Kundenkontakt.',
},
pages: {
home: HOME_DE,
pitch: PITCH_DE,
services: SERVICES_OVERVIEW_DE,
servicesSoftware: SERVICES_SOFTWARE_DE,
servicesHardwareNetwork: SERVICES_HARDWARE_DE,

View File

@@ -1,99 +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'],
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' },
],
};

View File

@@ -35,12 +35,14 @@ 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: {
situation: 'Situation',
approach: 'Approach',
outcome: 'Outcome',
metrics: 'Metrics',
stack: 'Stack',
tags: 'Tags',
},
@@ -50,21 +52,23 @@ export const STACK_EN: StackPageCopy = {
routeId: 'stack',
title: 'Technology stack | Antonio Ledebuhr',
description:
'The public stack in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
'A technology overview in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
hero: {
headline: 'The stack that appears on these pages',
headline: 'A technology overview, not a proof list',
proof:
'The groups belong to product work in Java and Spring and in C# and .NET, to SQL Server data work, to containers and clusters, and to servers on site.',
'The grid is a familiarity list. It shows technologies from the work without claiming a public case for every entry.',
},
sections: [
{
id: 'reading',
headline: 'A selection, not a ranking',
body: ['The overview is a selection from the public work, not a ranking.'],
headline: 'An overview, not a ranking',
body: [
'The grid groups technologies for orientation. It is not a ranking, and it does not claim that every entry appears in a public case.',
],
},
{
id: 'groups',
headline: 'Where the groups show up in the work',
headline: 'Which groups the work touches',
body: [
'Product work in Java and Spring and in C# and .NET, with Angular on the front end. SQL Server and T-SQL data work. Containers and clusters with Docker, Kubernetes and Azure AKS. Pipelines in GitLab CI/CD and Azure DevOps, GitOps with Argo CD. Servers and virtualisation on site.',
],
@@ -83,6 +87,8 @@ export const STACK_EN: StackPageCopy = {
{ id: 'hyperviser', title: 'Hypervisors' },
{ id: 'tools', title: 'Tools' },
],
evidenceNote:
'The public cases evidence items such as Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox and Microsoft 365. The remaining entries show breadth and familiarity without a public reference.',
};
export const ABOUT_EN: PageCopy = {
@@ -129,6 +135,7 @@ export const ABOUT_EN: PageCopy = {
],
ctas: [
{ label: 'Projects', routeId: 'projects' },
{ label: 'For recruiters', routeId: 'pitch' },
{ label: 'Contact', routeId: 'contact' },
],
};
@@ -168,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' },

View File

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

View File

@@ -1,6 +1,6 @@
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_EN: PageCopy = {
export const SERVICES_OVERVIEW_EN: ServicesOverviewPageCopy = {
routeId: 'services',
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 = {
@@ -126,6 +130,9 @@ export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
@@ -208,6 +215,9 @@ export const SERVICES_HARDWARE_EN: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
@@ -289,6 +299,9 @@ export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};
export const SERVICES_AI_EN: ServicePageCopy = {
@@ -421,4 +434,7 @@ export const SERVICES_AI_EN: ServicePageCopy = {
status: 'offer',
},
],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};

View File

@@ -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,
@@ -34,8 +37,15 @@ export const SITE_CONTENT_EN: SiteContent = {
imprint: IMPRINT_EN,
privacy: PRIVACY_EN,
},
seo: {
jobTitle: 'Fullstack and DevOps engineer',
professionalServiceName: 'Software and DevOps services',
professionalServiceDescription:
'Four service areas covering product software, infrastructure, clusters and AI integration — from the first workshop through to operations, with direct customer contact.',
},
pages: {
home: HOME_EN,
pitch: PITCH_EN,
services: SERVICES_OVERVIEW_EN,
servicesSoftware: SERVICES_SOFTWARE_EN,
servicesHardwareNetwork: SERVICES_HARDWARE_EN,

View File

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

View File

@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { COMMAND_IDS } from '../commands/command-ids';
import { APP_LOCALES } from '../i18n/locale';
import { SIGNATURE_COPY } from './signature-copy';
function leafPaths(value: unknown, prefix = ''): string[] {
if (typeof value === 'string') {
return [prefix];
}
if (value !== null && typeof value === 'object') {
return Object.keys(value).flatMap((key) => {
const next = prefix.length > 0 ? `${prefix}.${key}` : key;
return leafPaths((value as Record<string, unknown>)[key], next);
});
}
return [prefix];
}
function collectStrings(value: unknown): string[] {
if (typeof value === 'string') {
return [value];
}
if (value !== null && typeof value === 'object') {
return Object.values(value).flatMap((entry) => collectStrings(entry));
}
return [];
}
describe('SIGNATURE_COPY', () => {
it('exposes the same key structure in both locales', () => {
const [first, ...rest] = APP_LOCALES.map((locale) => leafPaths(SIGNATURE_COPY[locale]));
for (const keys of rest) {
expect(keys).toEqual(first);
}
});
it('keeps every string non-empty', () => {
for (const locale of APP_LOCALES) {
for (const value of collectStrings(SIGNATURE_COPY[locale])) {
expect(value.trim().length).toBeGreaterThan(0);
}
}
});
it('does not import from src/app/shared', () => {
const source = readFileSync(
join(process.cwd(), 'src/app/core/content/signature-copy.ts'),
'utf8',
);
expect(source).not.toMatch(/from ['"][^'"]*\/shared\//);
});
it('describes every CommandId in both locales', () => {
for (const locale of APP_LOCALES) {
const descriptions = SIGNATURE_COPY[locale].terminal.commandDescriptions;
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
for (const id of COMMAND_IDS) {
expect(descriptions[id].trim().length).toBeGreaterThan(0);
}
}
});
});

View File

@@ -0,0 +1,156 @@
import { type CommandId } from '../commands/command-ids';
import { type AppLocale } from '../i18n/locale';
export interface SignatureCopy {
readonly terminal: {
readonly triggerLabel: string;
readonly shortcutHint: string;
readonly shortcutHintApple: string;
readonly prompt: string;
readonly panelLabel: string;
readonly inputLabel: string;
readonly inputPlaceholder: string;
readonly collapseLabel: string;
readonly maximizeLabel: string;
readonly outputLabel: 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 rev: string;
};
};
readonly systemsMap: {
readonly heading: string;
readonly intro: string;
readonly diagramDescription: string;
readonly listHeading: string;
readonly legendHeading: string;
readonly relationshipLabel: string;
readonly legend: {
readonly ai: string;
readonly cluster: string;
readonly hardware: string;
readonly software: string;
readonly project: string;
};
};
}
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
de: {
terminal: {
triggerLabel: 'Terminal',
shortcutHint: 'Strg+K',
shortcutHintApple: '⌘K',
prompt: 'visitor@antoniolede:~$',
panelLabel: 'Terminal',
inputLabel: 'Befehl',
inputPlaceholder: 'Befehl eingeben',
collapseLabel: 'Terminal einklappen',
maximizeLabel: 'Terminal vergrößern',
outputLabel: 'Ausgabe',
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 Grammatik und jeden Befehl.',
history: 'Listet die in dieser Sitzung eingegebenen Befehle.',
clear: 'Leert das Ausgabebuch.',
brew: 'Eine kurze, spielerische Rückmeldung.',
rev: 'Eine kurze, spielerische Rückmeldung.',
navigate: 'Wechselt zu einer bekannten Seite oder öffnet den Lebenslauf.',
},
responses: {
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
},
},
systemsMap: {
heading: 'Systemkarte',
intro:
'Wie die technischen Schichten zusammenhängen — von Hardware bis zu den Projektfeldern.',
diagramDescription: 'Diagramm der technischen Schichten und ihrer Verbindungen.',
listHeading: 'Knoten als Liste',
legendHeading: 'Legende',
relationshipLabel: 'Verbunden mit {targets}.',
legend: {
ai: 'KI-Integration',
cluster: 'Cluster',
hardware: 'Hardware und Netz',
software: 'Software',
project: 'Projektfeld',
},
},
},
en: {
terminal: {
triggerLabel: 'Terminal',
shortcutHint: 'Ctrl+K',
shortcutHintApple: '⌘K',
prompt: 'visitor@antoniolede:~$',
panelLabel: 'Terminal',
inputLabel: 'Command',
inputPlaceholder: 'Type a command',
collapseLabel: 'Collapse the terminal',
maximizeLabel: 'Maximise the terminal',
outputLabel: 'Output',
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 grammar and every command.',
history: 'Lists the commands entered in this session.',
clear: 'Clears the output log.',
brew: 'A short playful acknowledgement.',
rev: 'A short playful acknowledgement.',
navigate: 'Goes to a known page or opens the curriculum vitae.',
},
responses: {
brew: 'Freshly brewed. Automation that runs before the first coffee.',
rev: 'Revs climbing, the content stays calm.',
},
},
systemsMap: {
heading: 'Systems map',
intro: 'How the technical layers connect — from hardware through to the project fields.',
diagramDescription: 'Diagram of the technical layers and their connections.',
listHeading: 'Nodes as a list',
legendHeading: 'Legend',
relationshipLabel: 'Connected to {targets}.',
legend: {
ai: 'AI integration',
cluster: 'Cluster',
hardware: 'Hardware and network',
software: 'Software',
project: 'Project field',
},
},
},
};

View File

@@ -1,12 +1,14 @@
export const SITE_CONFIG: {
readonly personName: string;
readonly contactEmail: string;
readonly siteOrigin: string;
readonly cvAssetPath: string;
readonly cvDownloadFileName: string;
readonly calendarUrl: string | null;
} = {
personName: 'Antonio Ledebuhr',
contactEmail: 'info@antoniolede.de',
siteOrigin: 'https://antoniolede.de',
cvAssetPath: '/cv/CV.pdf',
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
calendarUrl: null,

View File

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

View File

@@ -10,7 +10,7 @@ export interface NavItem {
export const PRIMARY_NAV: readonly NavItem[] = [
{
routeId: 'home',
label: { de: 'Start', en: 'Home' },
label: { de: 'Startseite', en: 'Home' },
},
{
routeId: 'services',
@@ -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' },

View File

@@ -1,6 +1,7 @@
import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
isApplePlatform,
isBrowserPlatform,
prefersCoarsePointer,
prefersReducedMotion,
@@ -18,6 +19,7 @@ describe('browser platform helpers', () => {
expect(prefersReducedMotion()).toBe(false);
expect(prefersCoarsePointer()).toBe(false);
expect(viewportMatches('(min-width: 40rem)')).toBe(false);
expect(isApplePlatform()).toBe(false);
});
});
});

View File

@@ -17,6 +17,22 @@ export function viewportMatches(query: string): boolean {
return mediaQueryMatches(query);
}
/**
* Keyboard-labelling exception: there is no CSS media query for the Command key.
* Used only to swap the terminal shortcut hint after hydration.
*/
export function isApplePlatform(): boolean {
if (!isBrowserPlatform()) {
return false;
}
const navigatorWithHints = window.navigator as Navigator & {
readonly userAgentData?: { readonly platform?: string };
};
const platform = navigatorWithHints.userAgentData?.platform ?? navigatorWithHints.platform ?? '';
return /mac|iphone|ipad|ipod/i.test(platform);
}
function mediaQueryMatches(query: string): boolean {
if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') {
return false;

View File

@@ -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',

View File

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

View File

@@ -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',

View File

@@ -0,0 +1,171 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { APP_LOCALES } from '../i18n/locale';
import { SITE_CONFIG } from '../content/site-config';
import { CASE_STUDY_IDS } from '../content/content.contracts';
import { prerenderablePaths, routePath } from '../routing/route-paths';
import { absoluteUrl } from './route-metadata';
const PUBLIC = join(process.cwd(), 'public');
const EXCLUDED = /HUP|BitWiz|Cybertrading/;
function readPublic(name: 'robots.txt' | 'sitemap.xml' | 'llms.txt'): string {
return readFileSync(join(PUBLIC, name), 'utf8');
}
function expectedCanonicals(): readonly string[] {
return prerenderablePaths().map((path) => absoluteUrl(path));
}
function sitemapLocs(xml: string): string[] {
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
}
function sitemapAlternates(xml: string): Array<{ loc: string; hreflang: string; href: string }> {
const blocks = xml.split(/<url>/).slice(1);
const rows: Array<{ loc: string; hreflang: string; href: string }> = [];
for (const block of blocks) {
const loc = block.match(/<loc>([^<]+)<\/loc>/)?.[1];
if (!loc) {
throw new Error(`sitemap.xml has a <url> block without <loc>: ${block.slice(0, 120)}`);
}
const links = [
...block.matchAll(
/<xhtml:link[^>]*rel="alternate"[^>]*hreflang="([^"]+)"[^>]*href="([^"]+)"/g,
),
];
if (links.length === 0) {
throw new Error(`sitemap.xml is missing xhtml alternates for ${loc}`);
}
for (const link of links) {
rows.push({ loc, hreflang: link[1], href: link[2] });
}
}
return rows;
}
function siteOriginsIn(text: string): string[] {
return [...text.matchAll(/(?:href|loc|Sitemap:\s*)["']?(https?:\/\/[^/\s"'<>]+)/g)].map(
(match) => match[1],
);
}
describe('crawl assets', () => {
it('lists every prerenderable path once and no URL outside that set', () => {
const xml = readPublic('sitemap.xml');
const expected = [...expectedCanonicals()].sort();
const actual = [...new Set(sitemapLocs(xml))].sort();
expect(
actual,
'sitemap.xml locs must match SITE_CONFIG.siteOrigin + prerenderablePaths()',
).toEqual(expected);
expect(sitemapLocs(xml)).toHaveLength(expected.length);
});
it('keeps reciprocal hreflang alternates for de-DE, en and x-default', () => {
const xml = readPublic('sitemap.xml');
const required = ['de-DE', 'en', 'x-default'] as const;
for (const loc of sitemapLocs(xml)) {
const links = sitemapAlternates(xml).filter((row) => row.loc === loc);
const langs = links.map((row) => row.hreflang).sort();
expect(langs, `${loc} must have de-DE, en and x-default`).toEqual([...required].sort());
const byLang = Object.fromEntries(links.map((row) => [row.hreflang, row.href]));
expect(byLang['x-default'], `${loc} x-default must be the German URL`).toBe(byLang['de-DE']);
}
for (const locale of APP_LOCALES) {
for (const path of prerenderablePaths().filter((entry) =>
locale === 'en'
? entry === '/en' || entry.startsWith('/en/')
: !(entry === '/en' || entry.startsWith('/en/')),
)) {
const loc = absoluteUrl(path);
const links = sitemapAlternates(xml).filter((row) => row.loc === loc);
const germanHref = links.find((row) => row.hreflang === 'de-DE')?.href;
const englishHref = links.find((row) => row.hreflang === 'en')?.href;
expect(germanHref, `${loc} is missing a de-DE alternate`).toBeTruthy();
expect(englishHref, `${loc} is missing an en alternate`).toBeTruthy();
const germanLinks = sitemapAlternates(xml).filter((row) => row.loc === germanHref);
const englishLinks = sitemapAlternates(xml).filter((row) => row.loc === englishHref);
expect(
germanLinks.find((row) => row.hreflang === 'en')?.href,
`${germanHref} must point back to ${englishHref}`,
).toBe(englishHref);
expect(
englishLinks.find((row) => row.hreflang === 'de-DE')?.href,
`${englishHref} must point back to ${germanHref}`,
).toBe(germanHref);
}
}
});
it('lists the required canonical URLs in both locales in llms.txt', () => {
const text = readPublic('llms.txt');
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')),
absoluteUrl(routePath('services', 'en')),
absoluteUrl(routePath('servicesSoftware', 'de')),
absoluteUrl(routePath('servicesSoftware', 'en')),
absoluteUrl(routePath('servicesHardwareNetwork', 'de')),
absoluteUrl(routePath('servicesHardwareNetwork', 'en')),
absoluteUrl(routePath('servicesClusters', 'de')),
absoluteUrl(routePath('servicesClusters', 'en')),
absoluteUrl(routePath('servicesAi', 'de')),
absoluteUrl(routePath('servicesAi', 'en')),
absoluteUrl(routePath('projects', 'de')),
absoluteUrl(routePath('projects', 'en')),
...CASE_STUDY_IDS.flatMap((id) => [
`${absoluteUrl(routePath('projects', 'de'))}#${id}`,
`${absoluteUrl(routePath('projects', 'en'))}#${id}`,
]),
absoluteUrl(routePath('contact', 'de')),
absoluteUrl(routePath('contact', 'en')),
absoluteUrl(routePath('stack', 'de')),
absoluteUrl(routePath('stack', 'en')),
absoluteUrl(routePath('imprint', 'de')),
absoluteUrl(routePath('imprint', 'en')),
absoluteUrl(routePath('privacy', 'de')),
absoluteUrl(routePath('privacy', 'en')),
];
for (const url of required) {
expect(text.includes(url), `llms.txt is missing ${url}`).toBe(true);
}
});
it('points robots.txt at the sitemap on the canonical origin', () => {
const robots = readPublic('robots.txt');
expect(robots, 'robots.txt must reference the sitemap at SITE_CONFIG.siteOrigin').toContain(
`Sitemap: ${SITE_CONFIG.siteOrigin}/sitemap.xml`,
);
expect(robots).toMatch(/User-agent:\s*\*/);
expect(robots).not.toMatch(/Disallow:\s+\S+/);
});
it('uses only SITE_CONFIG.siteOrigin and never names excluded stations', () => {
for (const name of ['robots.txt', 'sitemap.xml', 'llms.txt'] as const) {
const text = readPublic(name);
const origins = siteOriginsIn(text);
const unexpected = origins.filter((origin) => origin !== SITE_CONFIG.siteOrigin);
expect(unexpected, `${name} contains an origin other than ${SITE_CONFIG.siteOrigin}`).toEqual(
[],
);
expect(EXCLUDED.test(text), `${name} contains an excluded station name`).toBe(false);
}
});
});

View File

@@ -0,0 +1,69 @@
import { SITE_CONTENT_DATA } from '../content/site-content';
import { SITE_CONFIG } from '../content/site-config';
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS } from '../routing/route-ids';
import { routePath } from '../routing/route-paths';
import { buildRouteMetadata, OG_LOCALE } from './route-metadata';
describe('buildRouteMetadata', () => {
it('derives title, description, canonical, alternates, Open Graph and Twitter for every route', () => {
for (const locale of APP_LOCALES) {
for (const routeId of ROUTE_IDS) {
const page = SITE_CONTENT_DATA[locale].pages[routeId];
const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA);
const other = locale === 'de' ? 'en' : 'de';
expect(metadata.title, `${locale}.${routeId} title`).toBe(page.title);
expect(metadata.description, `${locale}.${routeId} description`).toBe(page.description);
expect(metadata.openGraph.type).toBe('website');
expect(metadata.openGraph.title).toBe(page.title);
expect(metadata.openGraph.description).toBe(page.description);
expect(metadata.openGraph.siteName).toBe(SITE_CONFIG.personName);
expect(metadata.openGraph.locale).toBe(OG_LOCALE[locale]);
expect(metadata.openGraph.localeAlternate).toBe(OG_LOCALE[other]);
expect(metadata.twitter.card).toBe('summary');
expect(metadata.twitter.title).toBe(page.title);
expect(metadata.twitter.description).toBe(page.description);
if (routeId === 'notFound') {
expect(metadata.canonical, `${locale}.notFound must have no canonical`).toBeNull();
expect(metadata.alternates, `${locale}.notFound must have no alternates`).toEqual([]);
expect(metadata.openGraph.url).toBeNull();
expect(metadata.robots).toBe('noindex, follow');
continue;
}
const expectedCanonical = `${SITE_CONFIG.siteOrigin}${routePath(routeId, locale)}`;
expect(metadata.canonical, `${locale}.${routeId} canonical`).toBe(expectedCanonical);
expect(metadata.openGraph.url).toBe(expectedCanonical);
expect(metadata.robots).toBe('index, follow');
const byLang = Object.fromEntries(
metadata.alternates.map((entry) => [entry.hreflang, entry.href]),
);
expect(Object.keys(byLang).sort()).toEqual(['de-DE', 'en', 'x-default']);
expect(byLang['de-DE']).toBe(`${SITE_CONFIG.siteOrigin}${routePath(routeId, 'de')}`);
expect(byLang['en']).toBe(`${SITE_CONFIG.siteOrigin}${routePath(routeId, 'en')}`);
expect(byLang['x-default']).toBe(byLang['de-DE']);
}
}
});
it('keeps German and English alternates reciprocal for every public route', () => {
for (const routeId of ROUTE_IDS.filter((id) => id !== 'notFound')) {
const german = buildRouteMetadata(routeId, 'de', SITE_CONTENT_DATA);
const english = buildRouteMetadata(routeId, 'en', SITE_CONTENT_DATA);
const germanHref = german.alternates.find((entry) => entry.hreflang === 'de-DE')?.href;
const englishHref = german.alternates.find((entry) => entry.hreflang === 'en')?.href;
expect(english.alternates.find((entry) => entry.hreflang === 'de-DE')?.href).toBe(germanHref);
expect(english.alternates.find((entry) => entry.hreflang === 'en')?.href).toBe(englishHref);
expect(german.alternates.find((entry) => entry.hreflang === 'x-default')?.href).toBe(
germanHref,
);
expect(english.alternates.find((entry) => entry.hreflang === 'x-default')?.href).toBe(
germanHref,
);
}
});
});

View File

@@ -0,0 +1,61 @@
import { type SiteContent } from '../content/content.contracts';
import { SITE_CONFIG } from '../content/site-config';
import { type AppLocale } from '../i18n/locale';
import { otherLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
import { routePath } from '../routing/route-paths';
import { type RouteMetadata } from './seo.contracts';
export const OG_LOCALE: Record<AppLocale, string> = {
de: 'de_DE',
en: 'en_US',
};
export function absoluteUrl(path: string): string {
return `${SITE_CONFIG.siteOrigin}${path === '/' ? '/' : path}`;
}
export function canonicalUrl(routeId: RouteId, locale: AppLocale): string {
return absoluteUrl(routePath(routeId, locale));
}
export function buildRouteMetadata(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): RouteMetadata {
const page = content[locale].pages[routeId];
const title = page.title;
const description = page.description;
const isNotFound = routeId === 'notFound';
const canonical = isNotFound ? null : canonicalUrl(routeId, locale);
const alternateLocale = otherLocale(locale);
return {
title,
description,
canonical,
alternates: isNotFound
? []
: [
{ hreflang: 'de-DE', href: canonicalUrl(routeId, 'de') },
{ hreflang: 'en', href: canonicalUrl(routeId, 'en') },
{ hreflang: 'x-default', href: canonicalUrl(routeId, 'de') },
],
openGraph: {
type: 'website',
title,
description,
url: canonical,
siteName: SITE_CONFIG.personName,
locale: OG_LOCALE[locale],
localeAlternate: OG_LOCALE[alternateLocale],
},
twitter: {
card: 'summary',
title,
description,
},
robots: isNotFound ? 'noindex, follow' : 'index, follow',
};
}

View File

@@ -0,0 +1,30 @@
import { inject, Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { SITE_CONTENT } from '../content/content.token';
import { isAppRouteData } from '../routing/app-route-data';
import { buildRouteMetadata } from './route-metadata';
import { SeoService } from './seo.service';
@Injectable()
export class SeoTitleStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly seo = inject(SeoService);
private readonly content = inject(SITE_CONTENT);
override updateTitle(snapshot: RouterStateSnapshot): void {
let current = snapshot.root;
while (current.firstChild) {
current = current.firstChild;
}
if (!isAppRouteData(current.data)) {
return;
}
const metadata = buildRouteMetadata(current.data.routeId, current.data.locale, this.content);
this.title.setTitle(metadata.title);
this.seo.apply(current.data.routeId, current.data.locale);
}
}

View File

@@ -0,0 +1,30 @@
export interface RouteAlternate {
readonly hreflang: string;
readonly href: string;
}
export interface OpenGraphMetadata {
readonly type: 'website';
readonly title: string;
readonly description: string;
readonly url: string | null;
readonly siteName: string;
readonly locale: string;
readonly localeAlternate: string;
}
export interface TwitterMetadata {
readonly card: 'summary';
readonly title: string;
readonly description: string;
}
export interface RouteMetadata {
readonly title: string;
readonly description: string;
readonly canonical: string | null;
readonly alternates: readonly RouteAlternate[];
readonly openGraph: OpenGraphMetadata;
readonly twitter: TwitterMetadata;
readonly robots: string;
}

View File

@@ -0,0 +1,85 @@
import { TestBed } from '@angular/core/testing';
import { Title } from '@angular/platform-browser';
import { SITE_CONTENT } from '../content/content.token';
import { SITE_CONTENT_DATA } from '../content/site-content';
import { SITE_CONFIG } from '../content/site-config';
import { routePath } from '../routing/route-paths';
import { SeoService } from './seo.service';
describe('SeoService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SeoService, { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
});
afterEach(() => {
document.head.querySelectorAll('[data-seo]').forEach((node) => node.remove());
document.head
.querySelectorAll(
'meta[name="description"], meta[name="robots"], meta[name^="twitter:"], meta[property^="og:"]',
)
.forEach((node) => node.remove());
});
it('applies two routes in sequence without duplicating owned head elements', () => {
const seo = TestBed.inject(SeoService);
const title = TestBed.inject(Title);
seo.apply('home', 'de');
seo.apply('projects', 'en');
expect(title.getTitle()).toBe(SITE_CONTENT_DATA.en.pages.projects.title);
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(3);
expect(document.querySelectorAll('meta[name="description"]')).toHaveLength(1);
expect(document.querySelectorAll('meta[name="robots"]')).toHaveLength(1);
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute('href');
expect(canonical).toBe(`${SITE_CONFIG.siteOrigin}${routePath('projects', 'en')}`);
expect(document.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
SITE_CONTENT_DATA.en.pages.projects.description,
);
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'index, follow',
);
});
it('drops canonical and alternate links for notFound and sets noindex', () => {
const seo = TestBed.inject(SeoService);
seo.apply('home', 'de');
seo.apply('notFound', 'de');
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(0);
expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(0);
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'noindex, follow',
);
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
});
it('writes pitch metadata, canonical and reciprocal hreflang', () => {
const seo = TestBed.inject(SeoService);
seo.apply('pitch', 'de');
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`,
);
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'index, follow',
);
expect(
document.querySelector('link[rel="alternate"][hreflang="de-DE"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
expect(
document.querySelector('link[rel="alternate"][hreflang="en"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'en')}`);
expect(
document.querySelector('link[rel="alternate"][hreflang="x-default"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
expect(document.querySelector('script[type="application/ld+json"]')?.textContent).toContain(
'WebPage',
);
});
});

View File

@@ -0,0 +1,116 @@
import { DOCUMENT } from '@angular/common';
import { inject, Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { SITE_CONTENT } from '../content/content.token';
import { type AppLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
import { buildRouteMetadata } from './route-metadata';
import { type RouteMetadata } from './seo.contracts';
import { buildJsonLdGraph, serializeJsonLd } from './structured-data';
const SEO_OWNED = 'data-seo';
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
private readonly content = inject(SITE_CONTENT);
apply(routeId: RouteId, locale: AppLocale): void {
const metadata = buildRouteMetadata(routeId, locale, this.content);
this.title.setTitle(metadata.title);
this.updateMeta(metadata);
this.replaceOwnedElements(routeId, locale, metadata);
}
private updateMeta(metadata: RouteMetadata): void {
this.meta.updateTag(
{ name: 'description', content: metadata.description },
'name="description"',
);
this.meta.updateTag({ name: 'robots', content: metadata.robots }, 'name="robots"');
this.meta.updateTag(
{ property: 'og:type', content: metadata.openGraph.type },
'property="og:type"',
);
this.meta.updateTag(
{ property: 'og:title', content: metadata.openGraph.title },
'property="og:title"',
);
this.meta.updateTag(
{ property: 'og:description', content: metadata.openGraph.description },
'property="og:description"',
);
this.meta.updateTag(
{ property: 'og:site_name', content: metadata.openGraph.siteName },
'property="og:site_name"',
);
this.meta.updateTag(
{ property: 'og:locale', content: metadata.openGraph.locale },
'property="og:locale"',
);
this.meta.updateTag(
{ property: 'og:locale:alternate', content: metadata.openGraph.localeAlternate },
'property="og:locale:alternate"',
);
this.meta.updateTag(
{ name: 'twitter:card', content: metadata.twitter.card },
'name="twitter:card"',
);
this.meta.updateTag(
{ name: 'twitter:title', content: metadata.twitter.title },
'name="twitter:title"',
);
this.meta.updateTag(
{ name: 'twitter:description', content: metadata.twitter.description },
'name="twitter:description"',
);
if (metadata.openGraph.url) {
this.meta.updateTag(
{ property: 'og:url', content: metadata.openGraph.url },
'property="og:url"',
);
} else {
this.meta.removeTag('property="og:url"');
}
}
private replaceOwnedElements(routeId: RouteId, locale: AppLocale, metadata: RouteMetadata): void {
const head = this.document.head;
for (const owned of Array.from(head.querySelectorAll(`[${SEO_OWNED}]`))) {
owned.remove();
}
if (metadata.canonical) {
this.appendLink(head, { rel: 'canonical', href: metadata.canonical });
}
for (const alternate of metadata.alternates) {
this.appendLink(head, {
rel: 'alternate',
hreflang: alternate.hreflang,
href: alternate.href,
});
}
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute(SEO_OWNED, '');
script.textContent = serializeJsonLd(buildJsonLdGraph(routeId, locale, this.content));
head.appendChild(script);
}
private appendLink(head: HTMLHeadElement, attributes: Record<string, string>): void {
const link = this.document.createElement('link');
link.setAttribute(SEO_OWNED, '');
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
head.appendChild(link);
}
}

View File

@@ -0,0 +1,112 @@
import { SITE_CONTENT_DATA } from '../content/site-content';
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from '../routing/route-ids';
import { buildJsonLdGraph, serializeJsonLd } from './structured-data';
const FORBIDDEN_FIELDS = [
'aggregateRating',
'review',
'offers',
'price',
'priceRange',
'worksFor',
'employee',
'sponsor',
'address',
'geo',
'openingHours',
'telephone',
'foundingDate',
'numberOfEmployees',
] as const;
const SERVICE_ROUTES: readonly RouteId[] = [
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
];
function collectKeys(value: unknown, keys = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => collectKeys(entry, keys));
return keys;
}
if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
keys.add(key);
collectKeys(child, keys);
}
}
return keys;
}
function graphTypes(routeId: RouteId, locale: (typeof APP_LOCALES)[number]): string[] {
const graph = buildJsonLdGraph(routeId, locale, SITE_CONTENT_DATA);
return graph['@graph'].map((node) => {
if (node && typeof node === 'object' && !Array.isArray(node) && '@type' in node) {
return String(node['@type']);
}
return '';
});
}
describe('JSON-LD builders', () => {
it('emits a schema.org @graph with the expected types per route', () => {
for (const locale of APP_LOCALES) {
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']);
}
expect(graphTypes('projects', locale)).toEqual([
'CreativeWork',
'CreativeWork',
'CreativeWork',
'CreativeWork',
]);
for (const routeId of ROUTE_IDS.filter(
(id) => id !== 'home' && id !== 'projects' && !SERVICE_ROUTES.includes(id),
)) {
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['WebPage']);
}
}
});
it('escapes <, > and & inside the serialized payload', () => {
const serialized = serializeJsonLd({
name: 'A <script>alert(1)</script> & more',
});
expect(serialized).toContain('\\u003c');
expect(serialized).toContain('\\u003e');
expect(serialized).toContain('\\u0026');
expect(serialized).not.toContain('<');
expect(serialized).not.toContain('>');
expect(serialized).not.toContain('&');
});
it('never invents forbidden fields or excluded station names', () => {
for (const locale of APP_LOCALES) {
for (const routeId of ROUTE_IDS) {
const graph = buildJsonLdGraph(routeId, locale, SITE_CONTENT_DATA);
const keys = collectKeys(graph);
for (const field of FORBIDDEN_FIELDS) {
expect(keys.has(field), `${locale}.${routeId} contains ${field}`).toBe(false);
}
const serialized = serializeJsonLd(graph);
expect(serialized).not.toMatch(/HUP|BitWiz|Cybertrading/);
}
}
});
});

View File

@@ -0,0 +1,149 @@
import { CASE_STUDY_IDS, type SiteContent } from '../content/content.contracts';
import { SITE_CONFIG } from '../content/site-config';
import { LOCALE_HTML_LANG, type AppLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
import { canonicalUrl } from './route-metadata';
const SCHEMA_CONTEXT = 'https://schema.org';
const SERVICE_ROUTE_IDS: readonly RouteId[] = [
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
];
export type JsonLdValue =
| string
| number
| boolean
| null
| readonly JsonLdValue[]
| { readonly [key: string]: JsonLdValue };
export interface JsonLdGraph {
readonly '@context': typeof SCHEMA_CONTEXT;
readonly '@graph': readonly JsonLdValue[];
}
function personNode(locale: AppLocale, content: Record<AppLocale, SiteContent>): JsonLdValue {
return {
'@type': 'Person',
name: SITE_CONFIG.personName,
jobTitle: content[locale].seo.jobTitle,
email: SITE_CONFIG.contactEmail,
url: canonicalUrl('home', locale),
description: content[locale].pages.home.description,
};
}
function professionalServiceNode(
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdValue {
const seo = content[locale].seo;
return {
'@type': 'ProfessionalService',
name: seo.professionalServiceName,
description: seo.professionalServiceDescription,
url: canonicalUrl('home', locale),
provider: {
'@type': 'Person',
name: SITE_CONFIG.personName,
},
};
}
function serviceNode(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdValue {
const page = content[locale].pages[routeId];
return {
'@type': 'Service',
name: page.hero.headline,
description: page.description,
url: canonicalUrl(routeId, locale),
provider: {
'@type': 'Person',
name: SITE_CONFIG.personName,
},
};
}
function projectWorks(
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): readonly JsonLdValue[] {
const projectsUrl = canonicalUrl('projects', locale);
return CASE_STUDY_IDS.map((caseId) => {
const study = content[locale].cases[caseId];
return {
'@type': 'CreativeWork',
name: study.headline,
description: study.summary,
url: `${projectsUrl}#${caseId}`,
};
});
}
function webPageNode(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdValue {
const page = content[locale].pages[routeId];
const node: { [key: string]: JsonLdValue } = {
'@type': 'WebPage',
name: page.title,
description: page.description,
inLanguage: LOCALE_HTML_LANG[locale],
isPartOf: {
'@type': 'WebSite',
name: SITE_CONFIG.personName,
url: SITE_CONFIG.siteOrigin + '/',
},
};
if (routeId !== 'notFound') {
node['url'] = canonicalUrl(routeId, locale);
}
return node;
}
export function buildJsonLdGraph(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdGraph {
let graph: readonly JsonLdValue[];
if (routeId === 'home') {
graph = [personNode(locale, content), professionalServiceNode(locale, content)];
} else if (SERVICE_ROUTE_IDS.includes(routeId)) {
graph = [serviceNode(routeId, locale, content)];
} else if (routeId === 'projects') {
graph = projectWorks(locale, content);
} else {
graph = [webPageNode(routeId, locale, content)];
}
return {
'@context': SCHEMA_CONTEXT,
'@graph': graph,
};
}
export function serializeJsonLd(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}

View File

@@ -3,33 +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'" />
</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') {
<div class="stack">
@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>
}

View File

@@ -1,13 +1,61 @@
@use '../../shared/motion/reveal';
@use '../../shared/page-shell';
.home-reveal {
@include reveal.reveal-target;
}
.page h2 {
margin: 0;
font-size: var(--text-xl);
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;
}
}

View File

@@ -1,24 +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 { ProcessSteps } from '../../shared/process-steps/process-steps';
@Component({
selector: 'app-home-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
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);
}
}

View File

@@ -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';
@@ -26,6 +25,17 @@ function accessibleName(root: HTMLElement, element: Element): string {
return (element.getAttribute('aria-label') ?? element.textContent ?? '').trim();
}
function assertNoDanglingReferences(root: HTMLElement): void {
for (const attribute of ['aria-labelledby', 'aria-describedby'] as const) {
for (const element of root.querySelectorAll(`[${attribute}]`)) {
const ids = (element.getAttribute(attribute) ?? '').split(/\s+/).filter(Boolean);
for (const id of ids) {
expect(root.querySelector(`[id="${id}"]`), `${attribute} -> #${id}`).toBeTruthy();
}
}
}
}
function assertPageSemantics(root: HTMLElement): void {
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
expect(headings.filter((heading) => heading.tagName === 'H1')).toHaveLength(1);
@@ -56,6 +66,8 @@ describe('page rendering, semantics and accessibility', () => {
const paths = [
'/',
'/en',
'/pitch',
'/en/pitch',
'/leistungen/software',
'/en/services/software',
'/projekte',
@@ -69,10 +81,38 @@ describe('page rendering, semantics and accessibility', () => {
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
assertPageSemantics(root);
assertNoDanglingReferences(root);
}
});
it('exposes case anchors, audience entries and the CV download', async () => {
it('keeps every aria-labelledby and aria-describedby target in the document', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
const paths = [
'/',
'/en',
'/projekte',
'/en/projects',
'/leistungen/ai-integration',
'/en/services/ai-integration',
'/kontakt',
'/en/contact',
'/stack',
'/en/stack',
];
for (const path of paths) {
await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
assertNoDanglingReferences(root);
}
});
it('exposes case anchors and the Rösterei proof on Home', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
@@ -90,14 +130,73 @@ 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 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;
expect(root.querySelector('app-systems-map')).toBeNull();
}
});
it('renders pitch as profile, stations, stack, then cases', 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;
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);
}
});
it('sends the software service case CTA to the innofocus project anchor', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
[situationLabel]="copy.caseLabels.situation"
[approachLabel]="copy.caseLabels.approach"
[outcomeLabel]="copy.caseLabels.outcome"
[metricsLabel]="copy.caseLabels.metrics"
[stackLabel]="copy.caseLabels.stack"
[tagsLabel]="copy.caseLabels.tags"
/>

View File

@@ -0,0 +1,57 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
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 { APP_LOCALES } from '../../../core/i18n/locale';
import { routePath } from '../../../core/routing/route-paths';
describe('AI service offering groups', () => {
it('renders delivered work and offers under their own localized labels', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) {
const copy = SITE_CONTENT_DATA[locale].services.servicesAi;
const delivered = copy.offerings.filter((offering) => offering.status === 'delivered');
const offers = copy.offerings.filter((offering) => offering.status === 'offer');
expect(delivered.length).toBeGreaterThan(0);
expect(offers.length).toBeGreaterThan(0);
await harness.navigateByUrl(routePath('servicesAi', locale));
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
const deliveredGroup = root.querySelector(
`[aria-labelledby="offerings-delivered-servicesAi"]`,
);
const offerGroup = root.querySelector(`[aria-labelledby="offerings-offer-servicesAi"]`);
expect(deliveredGroup).toBeTruthy();
expect(offerGroup).toBeTruthy();
expect(deliveredGroup?.textContent).toContain(copy.deliveredOfferingsHeading);
expect(offerGroup?.textContent).toContain(copy.offerOfferingsHeading);
for (const offering of delivered) {
expect(deliveredGroup?.textContent).toContain(offering.title);
expect(offerGroup?.textContent).not.toContain(offering.title);
const note = copy.offeringCaseBackedLabel.replace(
'{case}',
SITE_CONTENT_DATA[locale].cases[offering.referenceCaseId!].client,
);
expect(deliveredGroup?.textContent).toContain(note);
}
for (const offering of offers) {
expect(offerGroup?.textContent).toContain(offering.title);
expect(deliveredGroup?.textContent).not.toContain(offering.title);
}
expect(deliveredGroup?.querySelector('[data-offering-status="offer"]')).toBeNull();
expect(offerGroup?.querySelector('[data-offering-status="delivered"]')).toBeNull();
}
});
});

View File

@@ -6,15 +6,34 @@
@if (section.id === 'sequence') {
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
}
@if (section.id === 'offer-boundary' && copy.offerings.length > 0) {
@if (section.id === 'scope' && deliveredOfferings().length > 0) {
<section class="stack offerings-group" [attr.aria-labelledby]="deliveredHeadingId()">
<h3 [id]="deliveredHeadingId()">{{ copy.deliveredOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of copy.offerings; track offering.id) {
<li class="glass-surface">
<h3>{{ offering.title }}</h3>
@for (offering of deliveredOfferings(); track offering.id) {
<li class="glass-surface" [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
@if (caseBackedNote(offering); as note) {
<p class="case-backed">{{ note }}</p>
}
</li>
}
</ul>
</section>
}
@if (section.id === 'offer-boundary' && offerOfferings().length > 0) {
<section class="stack offerings-group" [attr.aria-labelledby]="offerHeadingId()">
<h3 [id]="offerHeadingId()">{{ copy.offerOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of offerOfferings(); track offering.id) {
<li class="glass-surface" [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
</li>
}
</ul>
</section>
}
}
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">

View File

@@ -12,12 +12,21 @@
border-radius: var(--radius-md);
}
.offerings h3 {
.offerings-group h3,
.offerings h4 {
margin: 0 0 var(--space-2);
font-size: var(--text-lg);
}
.offerings h4 {
font-size: var(--text-md);
}
.offerings p {
margin: 0;
color: var(--color-text-muted);
}
.offerings .case-backed {
color: var(--color-text);
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import {
type OfferingCopy,
type ProcessStepCopy,
type ServicePageId,
type ServiceSequenceCopy,
@@ -42,4 +43,24 @@ export class ServicePageView {
const section = this.page().sections.find((item) => item.id === 'sequence');
return section ? `section-${section.id}` : null;
});
protected readonly deliveredOfferings = computed(() =>
this.page().offerings.filter((offering) => offering.status === 'delivered'),
);
protected readonly offerOfferings = computed(() =>
this.page().offerings.filter((offering) => offering.status === 'offer'),
);
protected readonly deliveredHeadingId = computed(() => `offerings-delivered-${this.routeId()}`);
protected readonly offerHeadingId = computed(() => `offerings-offer-${this.routeId()}`);
protected caseBackedNote(offering: OfferingCopy): string | null {
const caseId = offering.referenceCaseId;
if (!caseId) {
return null;
}
return this.page().offeringCaseBackedLabel.replace(
'{case}',
this.content.caseStudy(caseId)().client,
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../app.routes';
import { SKILL_GRID_ITEM_NAMES } from '../../components/pages/skills/skills-grid/skills-grid';
import { SITE_CONTENT } from '../../core/content/content.token';
import { SITE_CONTENT_DATA } from '../../core/content/site-content';
import { APP_LOCALES } from '../../core/i18n/locale';
import { routePath } from '../../core/routing/route-paths';
const OVERCLAIMS = [
'Der Stack, der auf diesen Seiten vorkommt',
'The stack that appears on these pages',
'Auswahl aus der öffentlichen Arbeit',
'selection from the public work',
];
function walkStrings(value: unknown, visit: (text: string) => void): void {
if (typeof value === 'string') {
visit(value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => walkStrings(item, visit));
return;
}
if (value && typeof value === 'object') {
Object.values(value).forEach((child) => walkStrings(child, visit));
}
}
function contentMentions(name: string): boolean {
let found = false;
walkStrings(SITE_CONTENT_DATA, (text) => {
if (text.includes(name)) {
found = true;
}
});
return found;
}
describe('stack evidence framing', () => {
it('backs every skills-grid name with public copy or the evidence note', () => {
for (const locale of APP_LOCALES) {
const note = SITE_CONTENT_DATA[locale].stack.evidenceNote;
expect(note.trim().length).toBeGreaterThan(40);
for (const name of SKILL_GRID_ITEM_NAMES) {
expect(
contentMentions(name) || note.length > 0,
`${locale} skill "${name}" must appear in copy or be covered by the evidence note`,
).toBe(true);
}
}
expect(SITE_CONTENT_DATA.de.stack.evidenceNote).not.toBe(
SITE_CONTENT_DATA.en.stack.evidenceNote,
);
});
it('renders the evidence note and drops the old overclaiming sentences', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) {
const note = SITE_CONTENT_DATA[locale].stack.evidenceNote;
await harness.navigateByUrl(routePath('stack', locale));
const root = harness.routeNativeElement as HTMLElement;
const rendered = root.querySelector('[role="note"]');
expect(rendered?.textContent).toContain(note);
expect(root.textContent).toContain(note);
for (const claim of OVERCLAIMS) {
expect(root.textContent).not.toContain(claim);
expect(SITE_CONTENT_DATA[locale].stack.hero.headline).not.toContain(claim);
expect(
SITE_CONTENT_DATA[locale].stack.sections
.map((section) => section.body?.join(' ') ?? '')
.join(' '),
).not.toContain(claim);
}
}
});
});

View File

@@ -4,6 +4,7 @@
@for (section of copy.sections; track section.id) {
<app-content-section [section]="section" />
}
<p class="evidence-note" role="note">{{ copy.evidenceNote }}</p>
<app-skills-grid [categoryTitles]="categoryTitles()" />
</div>
}

View File

@@ -36,7 +36,10 @@
</section>
@if (caseStudy().metrics.length > 0) {
<section class="stack" [attr.aria-labelledby]="metricsLabelId()">
<h3 [id]="metricsLabelId()">{{ metricsLabel() }}</h3>
<app-metric-list [metrics]="caseStudy().metrics" [labelledBy]="metricsLabelId()" />
</section>
}
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-stack'">

View File

@@ -14,6 +14,7 @@ export class CaseStudy {
readonly situationLabel = input.required<string>();
readonly approachLabel = input.required<string>();
readonly outcomeLabel = input.required<string>();
readonly metricsLabel = input.required<string>();
readonly stackLabel = input.required<string>();
readonly tagsLabel = input.required<string>();

View File

@@ -18,9 +18,11 @@
[value]="values()[field.id]"
[required]="field.required"
[attr.aria-required]="field.required ? 'true' : null"
[attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null"
[attr.aria-invalid]="showInvalid(field) ? 'true' : null"
[attr.aria-describedby]="describedBy(field)"
rows="6"
(input)="onInput(field.id, $event)"
(blur)="onBlur(field.id)"
></textarea>
}
@case ('select') {
@@ -29,8 +31,10 @@
[value]="values()[field.id]"
[required]="field.required"
[attr.aria-required]="field.required ? 'true' : null"
[attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null"
[attr.aria-invalid]="showInvalid(field) ? 'true' : null"
[attr.aria-describedby]="describedBy(field)"
(input)="onInput(field.id, $event)"
(blur)="onBlur(field.id)"
>
<option value=""></option>
@for (option of field.options ?? []; track option.value) {
@@ -45,20 +49,34 @@
[value]="values()[field.id]"
[required]="field.required"
[attr.aria-required]="field.required ? 'true' : null"
[attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null"
[attr.aria-invalid]="showInvalid(field) ? 'true' : null"
[attr.aria-describedby]="describedBy(field)"
(input)="onInput(field.id, $event)"
(blur)="onBlur(field.id)"
/>
}
}
</div>
}
<p class="incomplete" aria-live="polite">{{ incompleteMessage() }}</p>
<p class="incomplete" [id]="incompleteId" aria-live="polite">{{ incompleteMessage() }}</p>
<p class="note">{{ copy().noBackendNote }}</p>
<p class="cluster actions">
@if (isComplete()) {
<a class="submit" [href]="mailtoHref()">{{ copy().submitLabel }}</a>
} @else {
<button
type="button"
class="submit"
aria-disabled="true"
[attr.aria-describedby]="incompleteId"
(click)="onDisabledSubmit($event)"
>
{{ copy().submitLabel }}
</button>
}
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
@if (showCalendar()) {
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>

View File

@@ -31,7 +31,8 @@ textarea {
font: inherit;
}
.actions a {
.actions a,
button.submit {
color: var(--color-accent-cool);
text-decoration: none;
}
@@ -40,6 +41,21 @@ textarea {
font-weight: 600;
}
button.submit {
appearance: none;
border: 0;
padding: 0;
background: transparent;
font: inherit;
font-weight: 600;
min-height: 2.75rem;
}
button.submit[aria-disabled='true'] {
color: var(--color-text-muted);
cursor: not-allowed;
}
@media (hover: hover) and (pointer: fine) {
.actions a:hover {
text-decoration: underline;

View File

@@ -39,6 +39,12 @@ describe('ContactBriefing', () => {
expect(root.querySelector('form[action]')).toBeNull();
expect(root.textContent).toContain(copy.incompleteHint);
expect(root.querySelector('[aria-live="polite"]')).toBeTruthy();
expect(root.querySelector('a.submit')).toBeNull();
const disabledSubmit = root.querySelector<HTMLButtonElement>('button.submit');
expect(disabledSubmit?.getAttribute('aria-disabled')).toBe('true');
expect(disabledSubmit?.getAttribute('aria-describedby')).toBe('contact-incomplete');
expect(disabledSubmit?.textContent).toContain(copy.submitLabel);
expect(root.querySelector('[aria-invalid="true"]')).toBeNull();
expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
expect(root.textContent).not.toContain(copy.calendarLabel);
@@ -85,4 +91,78 @@ describe('ContactBriefing', () => {
expect(fetchSpy).not.toHaveBeenCalled();
expect(xhrSpy).not.toHaveBeenCalled();
});
it('treats project type as optional and enables mailto with name, email and situation', async () => {
await TestBed.configureTestingModule({
imports: [ContactBriefing],
}).compileComponents();
const fixture = TestBed.createComponent(ContactBriefing);
const copy = SITE_CONTENT_DATA.de.contact;
fixture.componentRef.setInput('copy', copy);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const projectType = copy.fields.find((field) => field.id === 'projectType');
expect(projectType?.required).toBe(false);
expect(projectType?.options?.some((option) => option.value === 'unsure')).toBe(true);
expect(
SITE_CONTENT_DATA.en.contact.fields
.find((field) => field.id === 'projectType')
?.options?.some((option) => option.value === 'unsure'),
).toBe(true);
const unsure = projectType?.options?.find((option) => option.value === 'unsure');
expect(unsure?.label).toBe('Noch unklar');
expect(
SITE_CONTENT_DATA.en.contact.fields
.find((field) => field.id === 'projectType')
?.options?.find((option) => option.value === 'unsure')?.label,
).toBe('Not sure yet');
setControl(root, 'contact-name', 'Ada');
setControl(root, 'contact-email', 'ada@example.com');
setControl(root, 'contact-situation', 'Need a shop.');
fixture.detectChanges();
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
expect(submit?.getAttribute('href')?.startsWith('mailto:')).toBe(true);
expect(submit?.getAttribute('href')).not.toContain(
encodeURIComponent(`${projectType!.label}:`),
);
setControl(root, 'contact-projectType', 'unsure');
fixture.detectChanges();
const withType = root.querySelector<HTMLAnchorElement>('a.submit')?.getAttribute('href') ?? '';
expect(decodeURIComponent(withType)).toContain(`${projectType!.label}: ${unsure!.label}`);
});
it('marks required fields invalid only after interaction', async () => {
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 name = root.querySelector<HTMLInputElement>('#contact-name');
expect(name).toBeTruthy();
expect(name?.getAttribute('aria-invalid')).toBeNull();
name!.dispatchEvent(new Event('blur', { bubbles: true }));
fixture.detectChanges();
expect(name?.getAttribute('aria-invalid')).toBe('true');
expect(name?.getAttribute('aria-describedby')?.includes('contact-incomplete')).toBe(true);
expect(root.querySelector('#contact-email')?.getAttribute('aria-invalid')).toBeNull();
const submit = root.querySelector<HTMLButtonElement>('button.submit');
expect(submit?.getAttribute('aria-disabled')).toBe('true');
submit?.click();
fixture.detectChanges();
expect(root.querySelector('a.submit')).toBeNull();
});
});

View File

@@ -25,8 +25,10 @@ export class ContactBriefing {
readonly copy = input.required<ContactPageCopy>();
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
protected readonly touched = signal<Partial<Record<ContactFieldId, boolean>>>({});
protected readonly contactEmail = SITE_CONFIG.contactEmail;
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
protected readonly incompleteId = 'contact-incomplete';
protected readonly missingLabels = computed(() => {
const copy = this.copy();
@@ -36,6 +38,8 @@ export class ContactBriefing {
.map((field) => field.label);
});
protected readonly isComplete = computed(() => this.missingLabels().length === 0);
protected readonly incompleteMessage = computed(() => {
const missing = this.missingLabels();
if (missing.length === 0) {
@@ -60,12 +64,44 @@ export class ContactBriefing {
protected onInput(fieldId: ContactFieldId, event: Event): void {
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
this.values.update((current) => ({ ...current, [fieldId]: target.value }));
this.markTouched(fieldId);
}
protected onBlur(fieldId: ContactFieldId): void {
this.markTouched(fieldId);
}
protected onDisabledSubmit(event: Event): void {
event.preventDefault();
}
protected controlId(field: ContactFieldCopy): string {
return `contact-${field.id}`;
}
protected showInvalid(field: ContactFieldCopy): boolean {
return (
field.required &&
this.touched()[field.id] === true &&
this.values()[field.id].trim().length === 0
);
}
protected describedBy(field: ContactFieldCopy): string | null {
const ids: string[] = [];
if (field.hint) {
ids.push(`${this.controlId(field)}-hint`);
}
if (this.showInvalid(field)) {
ids.push(this.incompleteId);
}
return ids.length > 0 ? ids.join(' ') : null;
}
private markTouched(fieldId: ContactFieldId): void {
this.touched.update((current) => ({ ...current, [fieldId]: true }));
}
private assembleBody(copy: ContactPageCopy, values: Record<ContactFieldId, string>): string {
const lines = copy.fields.flatMap((field) => {
const raw = values[field.id];

View File

@@ -0,0 +1,25 @@
@mixin reveal-target {
transition:
opacity var(--duration-base) var(--ease-standard),
transform var(--duration-base) var(--ease-standard);
&.reveal-pending {
opacity: 0;
transform: translateY(0.5rem);
}
&.is-revealed {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
&,
&.reveal-pending,
&.is-revealed {
opacity: 1;
transform: none;
transition: none;
}
}
}

View File

@@ -0,0 +1,20 @@
<div class="metric-bar">
<div class="metric-bar-header">
<span [id]="labelId">{{ label() }}</span>
<span>{{ displayValue() }}</span>
</div>
@if (description(); as descriptionText) {
<p class="metric-bar-description">{{ descriptionText }}</p>
}
<div
class="metric-bar-track"
role="progressbar"
[attr.aria-valuenow]="clampedValue()"
aria-valuemin="0"
[attr.aria-valuemax]="max()"
[attr.aria-valuetext]="displayValue()"
[attr.aria-labelledby]="labelId"
>
<div class="metric-bar-fill" [style.width.%]="percent()"></div>
</div>
</div>

View File

@@ -0,0 +1,44 @@
@use 'app/shared/motion/reveal' as reveal;
:host {
display: block;
}
.metric-bar {
display: grid;
gap: var(--space-2);
@include reveal.reveal-target;
}
.metric-bar-header {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-sm);
}
.metric-bar-description {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.metric-bar-track {
height: 0.5rem;
overflow: hidden;
border-radius: var(--radius-pill);
background: var(--color-surface-muted);
}
.metric-bar-fill {
height: 100%;
border-radius: inherit;
background: var(--color-accent);
transition: width var(--duration-base) var(--ease-standard);
}
@media (prefers-reduced-motion: reduce) {
.metric-bar-fill {
transition: none;
}
}

View File

@@ -0,0 +1,95 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MetricBar } from './metric-bar';
describe('MetricBar', () => {
async function createFixture(inputs: {
label: string;
value: number;
max?: number;
valueText?: string;
description?: string;
}): Promise<ComponentFixture<MetricBar>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [MetricBar],
}).compileComponents();
const fixture = TestBed.createComponent(MetricBar);
fixture.componentRef.setInput('label', inputs.label);
fixture.componentRef.setInput('value', inputs.value);
if (inputs.max !== undefined) {
fixture.componentRef.setInput('max', inputs.max);
}
if (inputs.valueText !== undefined) {
fixture.componentRef.setInput('valueText', inputs.valueText);
}
if (inputs.description !== undefined) {
fixture.componentRef.setInput('description', inputs.description);
}
fixture.detectChanges();
await fixture.whenStable();
return fixture;
}
it('renders the label and value as visible text', async () => {
const fixture = await createFixture({
label: 'Coverage',
value: 40,
valueText: '40 of 80',
});
const text = fixture.nativeElement.textContent ?? '';
expect(text).toContain('Coverage');
expect(text).toContain('40 of 80');
});
it('exposes progressbar semantics and an accessible name through aria-labelledby', async () => {
const fixture = await createFixture({
label: 'Latency',
value: 25,
max: 50,
valueText: '25 ms',
});
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const labelId = bar.getAttribute('aria-labelledby');
const label = fixture.nativeElement.querySelector(`#${labelId}`);
expect(bar.getAttribute('aria-valuenow')).toBe('25');
expect(bar.getAttribute('aria-valuemin')).toBe('0');
expect(bar.getAttribute('aria-valuemax')).toBe('50');
expect(bar.getAttribute('aria-valuetext')).toBe('25 ms');
expect(label?.textContent?.trim()).toBe('Latency');
});
it('clamps values below 0 and above max', async () => {
const low = await createFixture({ label: 'Low', value: -12, max: 10 });
const lowBar = low.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const lowFill = low.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
expect(lowBar.getAttribute('aria-valuenow')).toBe('0');
expect(lowFill.style.width).toBe('0%');
const high = await createFixture({ label: 'High', value: 140, max: 50 });
const highBar = high.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const highFill = high.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
expect(highBar.getAttribute('aria-valuenow')).toBe('50');
expect(highFill.style.width).toBe('100%');
});
it('does not produce NaN when max is 0', async () => {
const fixture = await createFixture({ label: 'Empty', value: 8, max: 0 });
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
const fill = fixture.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
expect(bar.getAttribute('aria-valuenow')).toBe('0');
expect(fill.style.width).toBe('0%');
expect(fill.style.width).not.toContain('NaN');
expect(fixture.nativeElement.textContent).toContain('Empty');
expect(fixture.nativeElement.textContent).toContain('0');
});
});

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
let metricBarInstanceId = 0;
@Component({
selector: 'app-metric-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './metric-bar.html',
styleUrl: './metric-bar.scss',
})
export class MetricBar {
readonly label = input.required<string>();
readonly value = input.required<number>();
readonly max = input(100);
readonly valueText = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
private readonly instanceId = metricBarInstanceId++;
protected readonly labelId = `metric-bar-label-${this.instanceId}`;
protected readonly clampedValue = computed(() => {
const max = this.max();
const value = this.value();
if (!Number.isFinite(max) || max <= 0) {
return 0;
}
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(max, Math.max(0, value));
});
protected readonly percent = computed(() => {
const max = this.max();
if (!Number.isFinite(max) || max <= 0) {
return 0;
}
return (this.clampedValue() / max) * 100;
});
protected readonly displayValue = computed(() => this.valueText() ?? String(this.clampedValue()));
}

View File

@@ -0,0 +1,181 @@
import { ApplicationRef, Component, PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RevealDirective } from './reveal.directive';
@Component({
imports: [RevealDirective],
template: `<div appReveal [appRevealThreshold]="0.2">Reveal host</div>`,
})
class RevealHost {}
class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = [];
readonly observe = vi.fn();
readonly unobserve = vi.fn();
readonly disconnect = vi.fn();
constructor(
private readonly callback: IntersectionObserverCallback,
readonly options?: IntersectionObserverInit,
) {
MockIntersectionObserver.instances.push(this);
}
trigger(isIntersecting: boolean): void {
this.callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
}
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: matchesQuery(query),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
describe('RevealDirective', () => {
afterEach(() => {
MockIntersectionObserver.instances = [];
vi.restoreAllMocks();
vi.unstubAllGlobals();
Reflect.deleteProperty(window, 'matchMedia');
});
async function createHost(
providers: { provide: unknown; useValue: unknown }[] = [],
): Promise<ComponentFixture<RevealHost>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [RevealHost],
providers,
}).compileComponents();
const fixture = TestBed.createComponent(RevealHost);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
return fixture;
}
function hostElement(fixture: ComponentFixture<RevealHost>): HTMLElement {
return fixture.nativeElement.querySelector('[appReveal]');
}
it('reveals immediately on the server without constructing an observer', async () => {
const Observer = vi.fn();
vi.stubGlobal('IntersectionObserver', Observer);
const fixture = await createHost([{ provide: PLATFORM_ID, useValue: 'server' }]);
const element = hostElement(fixture);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(element.classList.contains('reveal-pending')).toBe(false);
expect(Observer).not.toHaveBeenCalled();
});
it('reveals immediately when IntersectionObserver is missing', async () => {
const original = window.IntersectionObserver;
Reflect.deleteProperty(window, 'IntersectionObserver');
try {
const fixture = await createHost();
const element = hostElement(fixture);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(element.classList.contains('reveal-pending')).toBe(false);
} finally {
window.IntersectionObserver = original;
}
});
it('reveals immediately when reduced motion is requested', async () => {
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
const Observer = vi.fn();
vi.stubGlobal('IntersectionObserver', Observer);
const fixture = await createHost();
const element = hostElement(fixture);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(element.classList.contains('reveal-pending')).toBe(false);
expect(Observer).not.toHaveBeenCalled();
});
it('marks the element pending, then revealed, and disconnects on intersection', async () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
const fixture = await createHost();
const element = hostElement(fixture);
const observer = MockIntersectionObserver.instances[0];
expect(observer).toBeTruthy();
expect(observer.observe).toHaveBeenCalled();
expect(element.classList.contains('reveal-pending')).toBe(false);
expect(element.classList.contains('is-revealed')).toBe(false);
observer.trigger(false);
fixture.detectChanges();
expect(element.classList.contains('reveal-pending')).toBe(true);
expect(element.classList.contains('is-revealed')).toBe(false);
observer.trigger(true);
fixture.detectChanges();
expect(element.classList.contains('reveal-pending')).toBe(false);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(observer.disconnect).toHaveBeenCalled();
});
it('reveals on a first intersecting callback without ever adding reveal-pending', async () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
const fixture = await createHost();
const element = hostElement(fixture);
const observer = MockIntersectionObserver.instances[0];
const addedTokens: string[] = [];
const add = element.classList.add.bind(element.classList);
vi.spyOn(element.classList, 'add').mockImplementation((...tokens: string[]) => {
addedTokens.push(...tokens);
add(...tokens);
});
expect(observer).toBeTruthy();
expect(element.classList.contains('reveal-pending')).toBe(false);
observer.trigger(true);
fixture.detectChanges();
expect(addedTokens).not.toContain('reveal-pending');
expect(element.classList.contains('reveal-pending')).toBe(false);
expect(element.classList.contains('is-revealed')).toBe(true);
expect(observer.disconnect).toHaveBeenCalled();
});
it('disconnects when destroyed before intersection', async () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
const fixture = await createHost();
const observer = MockIntersectionObserver.instances[0];
expect(observer).toBeTruthy();
fixture.destroy();
expect(observer.disconnect).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,77 @@
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
DestroyRef,
Directive,
ElementRef,
inject,
Injector,
input,
} from '@angular/core';
import { isBrowserPlatform, prefersReducedMotion } from '../../core/platform/browser';
@Directive({
selector: '[appReveal]',
})
export class RevealDirective {
readonly appRevealThreshold = input(0.12);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly document = inject(DOCUMENT);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isBrowserPlatform();
private readonly reducedMotion = prefersReducedMotion();
private observer: IntersectionObserver | null = null;
constructor() {
if (!this.isBrowser || this.reducedMotion || !this.canObserve()) {
this.revealNow();
return;
}
afterNextRender(() => this.observe(), { injector: this.injector });
this.destroyRef.onDestroy(() => this.disconnect());
}
private canObserve(): boolean {
const view = this.document.defaultView;
return !!view && typeof view.IntersectionObserver === 'function';
}
private observe(): void {
const view = this.document.defaultView;
if (!view || typeof view.IntersectionObserver !== 'function') {
this.revealNow();
return;
}
this.observer = new view.IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.host.nativeElement.classList.remove('reveal-pending');
this.host.nativeElement.classList.add('is-revealed');
this.disconnect();
return;
}
this.host.nativeElement.classList.add('reveal-pending');
},
{
rootMargin: '0px 0px -8% 0px',
threshold: this.appRevealThreshold(),
},
);
this.observer.observe(this.host.nativeElement);
}
private revealNow(): void {
this.host.nativeElement.classList.add('is-revealed');
}
private disconnect(): void {
this.observer?.disconnect();
this.observer = null;
}
}

View File

@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const REVEAL_SCSS = readFileSync(join(process.cwd(), 'src/app/shared/motion/_reveal.scss'), 'utf8');
function mixinBody(source: string): string {
const start = source.indexOf('@mixin reveal-target');
expect(start).toBeGreaterThan(-1);
return source.slice(start);
}
describe('reveal-target mixin', () => {
it('keeps transition on the base selector so the revealed state can animate', () => {
const mixin = mixinBody(REVEAL_SCSS);
const transitionIndex = mixin.search(/transition\s*:/);
const pendingIndex = mixin.indexOf('&.reveal-pending');
const revealedIndex = mixin.indexOf('&.is-revealed');
expect(transitionIndex).toBeGreaterThan(-1);
expect(pendingIndex).toBeGreaterThan(-1);
expect(revealedIndex).toBeGreaterThan(pendingIndex);
expect(transitionIndex).toBeLessThan(pendingIndex);
const pendingBlock = mixin.match(/&\.reveal-pending\s*\{([^}]*)\}/)?.[1] ?? '';
expect(pendingBlock).toMatch(/opacity\s*:/);
expect(pendingBlock).not.toMatch(/transition\s*:/);
const revealedBlock = mixin.match(/&\.is-revealed\s*\{([^}]*)\}/)?.[1] ?? '';
expect(revealedBlock).toMatch(/opacity\s*:\s*1/);
expect(revealedBlock).not.toMatch(/transition\s*:/);
expect(mixin).toMatch(/prefers-reduced-motion:\s*reduce/);
expect(mixin).toMatch(/&\s*,\s*&\.reveal-pending/);
expect(mixin).toMatch(/&\.is-revealed\s*\{[\s\S]*transition:\s*none/);
});
});

Some files were not shown because too many files have changed in this diff Show More