Compare commits
22 Commits
orchestrat
...
e63b4edade
| Author | SHA1 | Date | |
|---|---|---|---|
| e63b4edade | |||
| b8b9e57d06 | |||
| 19d00da9bb | |||
| 35a575dba9 | |||
| 255f0eace6 | |||
| 359e887a7a | |||
| 5921fc5ede | |||
| 162bd790da | |||
| 646b39b200 | |||
| 86d6dd085d | |||
| b15dfd7801 | |||
| 575ba06bb9 | |||
| 1ff26be61b | |||
| 1de146c650 | |||
| 1d2965a7ff | |||
| 271df12334 | |||
| 34367181d2 | |||
| 46c86447d1 | |||
| 2ccac6b28a | |||
| da38cc3624 | |||
| 6c1572bf3c | |||
| 7813499ee3 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -41,3 +41,9 @@ __screenshots__/
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Browser and audit artifacts
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
/.lighthouseci
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ public/**/*.svg
|
|||||||
Dockerfile
|
Dockerfile
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
.cursor
|
.cursor
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.lighthouseci
|
||||||
|
blob-report
|
||||||
|
|||||||
18
AGENTS.md
18
AGENTS.md
@@ -4,7 +4,7 @@ Contributor and agent guide for this repository. Read this before changing routi
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Every public page exists in both locales.
|
This is a bilingual portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Home is the direct-customer experience. `/pitch` is the recruiter-oriented page and is reachable without sitting in the primary navigation. Every public page exists in both locales.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -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 format:check` | Prettier check (CI). |
|
||||||
| `npm run serve:ssr` | Serve the production SSR bundle. |
|
| `npm run serve:ssr` | Serve the production SSR bundle. |
|
||||||
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr` for existing tooling. |
|
| `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. |
|
| `npm run ci` | lint, format:check, test:ci, then build. |
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
@@ -90,6 +93,7 @@ German and English are written idiomatically per language, never machine-transla
|
|||||||
## i18n rules
|
## i18n rules
|
||||||
|
|
||||||
- German at `/`, English under `/en`
|
- German at `/`, English under `/en`
|
||||||
|
- Primary navigation is Home, Services (with four children), Projects, About and Contact. Stack and Pitch stay reachable from content and the terminal, not from the primary nav.
|
||||||
- Every route id exists in both locales
|
- Every route id exists in both locales
|
||||||
- New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths
|
- New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths
|
||||||
- Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService`
|
- Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService`
|
||||||
@@ -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
|
- 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)
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- Real locale-prefixed URLs
|
||||||
- Server-rendered core text
|
- Server-rendered core text
|
||||||
- Per-route titles from the content layer
|
- 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
|
## 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)
|
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
|
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
|
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
|
## Domain boundaries
|
||||||
|
|
||||||
@@ -143,7 +155,7 @@ A change is not done until:
|
|||||||
| ----------- | -------------------------------------------------------------------------------- |
|
| ----------- | -------------------------------------------------------------------------------- |
|
||||||
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
|
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
|
||||||
| Content | Bilingual copy data and page composition |
|
| Content | Bilingual copy data and page composition |
|
||||||
| Signature | Systems Map, terminal, dot background and motion |
|
| Signature | Systems Map, terminal dock, dot background and motion |
|
||||||
| Integration | SEO and cross-cutting a11y/performance hardening |
|
| Integration | SEO and cross-cutting a11y/performance hardening |
|
||||||
|
|
||||||
Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract.
|
Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract.
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Portfolio
|
# Portfolio
|
||||||
|
|
||||||
Bilingual recruiter and B2B portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`.
|
Bilingual portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`. Home speaks to small-company customers. `/pitch` is the recruiter-oriented page and is not in the primary navigation.
|
||||||
|
|
||||||
This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build.
|
This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build.
|
||||||
|
|
||||||
@@ -17,20 +17,24 @@ npm install
|
|||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Description |
|
| Script | Description |
|
||||||
| ----------------------------- | ------------------------------------------------------------------------- |
|
| ----------------------------- | -------------------------------------------------------------------------- |
|
||||||
| `npm start` | Start the Angular dev server. |
|
| `npm start` | Start the Angular dev server. |
|
||||||
| `npm run build` | Production build (default configuration), including SSR and prerendering. |
|
| `npm run build` | Production build (default configuration), including SSR and prerendering. |
|
||||||
| `npm run watch` | Development rebuild on change. |
|
| `npm run watch` | Development rebuild on change. |
|
||||||
| `npm test` | Unit tests in watch mode. |
|
| `npm test` | Unit tests in watch mode. |
|
||||||
| `npm run test:ci` | Unit tests once; exits when finished. |
|
| `npm run test:ci` | Unit tests once; exits when finished. |
|
||||||
| `npm run lint` | Run ESLint. |
|
| `npm run lint` | Run ESLint. |
|
||||||
| `npm run lint:fix` | Run ESLint with autofix. |
|
| `npm run lint:fix` | Run ESLint with autofix. |
|
||||||
| `npm run format` | Format the workspace with Prettier. |
|
| `npm run format` | Format the workspace with Prettier. |
|
||||||
| `npm run format:check` | Check formatting without writing. |
|
| `npm run format:check` | Check formatting without writing. |
|
||||||
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
|
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
|
||||||
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
|
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
|
||||||
| `npm run ci` | lint, format check, tests, then production build. |
|
| `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
|
## Local SSR build
|
||||||
|
|
||||||
@@ -39,7 +43,9 @@ npm run build
|
|||||||
npm run serve:ssr
|
npm run serve:ssr
|
||||||
```
|
```
|
||||||
|
|
||||||
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`.
|
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`. The header no longer offers that download; Pitch and the terminal `navigate cv` command still do. The lower-right terminal dock replaces the old centered command palette (`Ctrl+K` / `⌘K`).
|
||||||
|
|
||||||
|
`npm run e2e` builds the SSR bundle and serves it on port 4173. `npm run lighthouse` builds, then starts the SSR server on port 4000. Browser binaries and generated reports (`test-results`, `playwright-report`, `.lighthouseci`) are not committed.
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
|
|||||||
53
e2e/a11y.e2e.ts
Normal file
53
e2e/a11y.e2e.ts
Normal 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
41
e2e/contact.e2e.ts
Normal 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
79
e2e/fragments.e2e.ts
Normal 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
97
e2e/helpers.ts
Normal 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
118
e2e/keyboard.e2e.ts
Normal 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
81
e2e/layout.e2e.ts
Normal 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
119
e2e/seo.e2e.ts
Normal 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
66
e2e/systems-map.e2e.ts
Normal 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
145
e2e/terminal.e2e.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
|
||||||
|
|
||||||
|
test.describe('terminal dock', () => {
|
||||||
|
test('walks collapsed, expanded and maximized and runs the grammar', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const copy = SIGNATURE_COPY.de.terminal;
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const trigger = page.locator('.terminal-dock-trigger');
|
||||||
|
const panel = page.locator('.terminal-dock-panel');
|
||||||
|
const input = page.locator('#terminal-dock-input');
|
||||||
|
const log = page.locator('.terminal-dock-log');
|
||||||
|
const maximize = page.locator('.terminal-dock-control[aria-pressed]');
|
||||||
|
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt);
|
||||||
|
|
||||||
|
if (testInfo.project.name === 'mobile') {
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
expect(box, 'bottom-sheet panel').toBeTruthy();
|
||||||
|
expect(box!.width).toBeGreaterThan(300);
|
||||||
|
} else {
|
||||||
|
await maximize.click();
|
||||||
|
await expect(maximize).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await maximize.click();
|
||||||
|
await expect(maximize).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
await input.fill('navigate pitch');
|
||||||
|
await input.press('Enter');
|
||||||
|
await page.waitForURL('**/pitch');
|
||||||
|
await expect(page).toHaveURL(/\/pitch$/);
|
||||||
|
await expect(log).toContainText(`${copy.prompt} navigate pitch`);
|
||||||
|
|
||||||
|
await input.press('ArrowUp');
|
||||||
|
await expect(input).toHaveValue('navigate pitch');
|
||||||
|
|
||||||
|
await input.fill('history');
|
||||||
|
await input.press('Enter');
|
||||||
|
await expect(log).toContainText(copy.historyIntro);
|
||||||
|
await expect(log).toContainText('navigate pitch');
|
||||||
|
|
||||||
|
await input.fill('clear');
|
||||||
|
await input.press('Enter');
|
||||||
|
await expect(log).toHaveText(copy.clearedMessage);
|
||||||
|
|
||||||
|
await input.fill('navigate p');
|
||||||
|
await input.press('Tab');
|
||||||
|
await expect(log).toContainText('pitch');
|
||||||
|
await expect(log).toContainText('projects');
|
||||||
|
|
||||||
|
await input.fill('navigate nowhere');
|
||||||
|
await input.press('Enter');
|
||||||
|
await expect(log).toContainText(copy.validTargetsLabel);
|
||||||
|
await expect(log).toContainText('pitch');
|
||||||
|
|
||||||
|
await input.fill('nav');
|
||||||
|
await input.press('Tab');
|
||||||
|
await expect(input).toHaveValue('navigate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the visible desktop trigger while open collapses the panel', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name === 'mobile',
|
||||||
|
'Below md the trigger is hidden while the panel is open.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const trigger = page.locator('.terminal-dock-trigger');
|
||||||
|
const panel = page.locator('.terminal-dock-panel');
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the newest echoed line in the log scrollport after overflow', async ({ page }) => {
|
||||||
|
const copy = SIGNATURE_COPY.de.terminal;
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const trigger = page.locator('.terminal-dock-trigger');
|
||||||
|
const input = page.locator('#terminal-dock-input');
|
||||||
|
const log = page.locator('.terminal-dock-log');
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(log).toBeVisible();
|
||||||
|
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
await input.fill('help');
|
||||||
|
await input.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(log.locator('p.echo').last()).toHaveText(`${copy.prompt} help`);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
log.evaluate((element) => {
|
||||||
|
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const last = log.locator('p').last();
|
||||||
|
const logBox = await log.boundingBox();
|
||||||
|
const lastBox = await last.boundingBox();
|
||||||
|
expect(logBox && lastBox).toBeTruthy();
|
||||||
|
expect(lastBox!.y).toBeGreaterThanOrEqual(logBox!.y - 1);
|
||||||
|
expect(lastBox!.y + lastBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const copy = SIGNATURE_COPY.de.terminal;
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const trigger = page.locator('.terminal-dock-trigger');
|
||||||
|
const panel = page.locator('.terminal-dock-panel');
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
|
||||||
|
const collapse = page.getByRole('button', { name: copy.collapseLabel });
|
||||||
|
await collapse.focus();
|
||||||
|
await expect(collapse).toBeFocused();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,10 +6,19 @@ const prettier = require('eslint-config-prettier');
|
|||||||
|
|
||||||
module.exports = tseslint.config(
|
module.exports = tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'coverage/**'],
|
ignores: [
|
||||||
|
'dist/**',
|
||||||
|
'.angular/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'coverage/**',
|
||||||
|
'playwright-report/**',
|
||||||
|
'test-results/**',
|
||||||
|
'.lighthouseci/**',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts'],
|
files: ['**/*.ts'],
|
||||||
|
ignores: ['e2e/**/*.ts', 'playwright.config.ts'],
|
||||||
extends: [
|
extends: [
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
@@ -39,5 +48,15 @@ module.exports = tseslint.config(
|
|||||||
files: ['**/*.html'],
|
files: ['**/*.html'],
|
||||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
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,
|
prettier,
|
||||||
);
|
);
|
||||||
|
|||||||
33
lighthouserc.json
Normal file
33
lighthouserc.json
Normal 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
3225
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -14,7 +14,11 @@
|
|||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"serve:ssr": "node dist/Portfolio/server/server.mjs",
|
"serve:ssr": "node dist/Portfolio/server/server.mjs",
|
||||||
"serve:ssr:Portfolio": "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": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
@@ -47,13 +51,18 @@
|
|||||||
"@angular/build": "^21.0.5",
|
"@angular/build": "^21.0.5",
|
||||||
"@angular/cli": "^21.0.5",
|
"@angular/cli": "^21.0.5",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
|
"@lhci/cli": "^0.15.1",
|
||||||
|
"@playwright/test": "1.62.1",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"angular-eslint": "^21.4.0",
|
"angular-eslint": "^21.4.0",
|
||||||
|
"axe-core": "^4.13.0",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"jsdom": "^27.1.0",
|
"jsdom": "^27.1.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.68.0",
|
"typescript-eslint": "^8.68.0",
|
||||||
|
|||||||
40
playwright.config.ts
Normal file
40
playwright.config.ts
Normal 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
53
public/llms.txt
Normal 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
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://antoniolede.de/sitemap.xml
|
||||||
159
public/sitemap.xml
Normal file
159
public/sitemap.xml
Normal 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>
|
||||||
123
scripts/add-cv-pitch-link.mjs
Normal file
123
scripts/add-cv-pitch-link.mjs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { PDFDocument, PDFName, PDFString, rgb, StandardFonts } from 'pdf-lib';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const CV_PATH = join(ROOT, 'CV.pdf');
|
||||||
|
const PITCH_URI = 'https://antoniolede.de/pitch';
|
||||||
|
const VISIBLE_TEXT = 'antoniolede.de/pitch';
|
||||||
|
const TEXT_X = 59.6;
|
||||||
|
const TEXT_Y = 215.4;
|
||||||
|
const FONT_SIZE = 10.5;
|
||||||
|
const PADDING = 2;
|
||||||
|
const ORIGINAL_TITLE = 'CV';
|
||||||
|
const ORIGINAL_CREATOR = 'Pages';
|
||||||
|
// Producer from the unmodified Pages/Quartz export of CV.pdf.
|
||||||
|
const ORIGINAL_PRODUCER = 'macOS Version 26.5.2 (Build 25F84) Quartz PDFContext';
|
||||||
|
|
||||||
|
function asString(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value.decodeText === 'function') {
|
||||||
|
return value.decodeText();
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationUri(annotation) {
|
||||||
|
const action = annotation.lookup(PDFName.of('A'));
|
||||||
|
if (!action || typeof action.lookup !== 'function') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = action.lookup(PDFName.of('URI'));
|
||||||
|
return asString(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHasPitchLink(page) {
|
||||||
|
const annots = page.node.lookup(PDFName.of('Annots'));
|
||||||
|
if (!annots || typeof annots.size !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < annots.size(); index += 1) {
|
||||||
|
const annotation = annots.lookup(index);
|
||||||
|
if (annotationUri(annotation) === PITCH_URI) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOriginalMetadata(pdfDoc) {
|
||||||
|
return (
|
||||||
|
pdfDoc.getTitle() === ORIGINAL_TITLE &&
|
||||||
|
pdfDoc.getCreator() === ORIGINAL_CREATOR &&
|
||||||
|
pdfDoc.getProducer() === ORIGINAL_PRODUCER
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = readFileSync(CV_PATH);
|
||||||
|
const pdfDoc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||||
|
const page = pdfDoc.getPage(0);
|
||||||
|
const needsLink = !pageHasPitchLink(page);
|
||||||
|
const needsMetadata = !hasOriginalMetadata(pdfDoc);
|
||||||
|
|
||||||
|
if (!needsLink && !needsMetadata) {
|
||||||
|
console.log('Pitch link and original metadata already present; leaving CV.pdf unchanged.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsLink) {
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||||
|
const textWidth = font.widthOfTextAtSize(VISIBLE_TEXT, FONT_SIZE);
|
||||||
|
|
||||||
|
page.drawText(VISIBLE_TEXT, {
|
||||||
|
x: TEXT_X,
|
||||||
|
y: TEXT_Y,
|
||||||
|
size: FONT_SIZE,
|
||||||
|
font,
|
||||||
|
color: rgb(54 / 255, 125 / 255, 162 / 255),
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = pdfDoc.context.register(
|
||||||
|
pdfDoc.context.obj({
|
||||||
|
Type: 'Annot',
|
||||||
|
Subtype: 'Link',
|
||||||
|
Rect: [
|
||||||
|
TEXT_X - PADDING,
|
||||||
|
TEXT_Y - PADDING,
|
||||||
|
TEXT_X + textWidth + PADDING,
|
||||||
|
TEXT_Y + FONT_SIZE + PADDING,
|
||||||
|
],
|
||||||
|
Border: [0, 0, 0],
|
||||||
|
A: {
|
||||||
|
S: 'URI',
|
||||||
|
URI: PDFString.of(PITCH_URI),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
page.node.addAnnot(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsMetadata) {
|
||||||
|
pdfDoc.setTitle(ORIGINAL_TITLE);
|
||||||
|
pdfDoc.setCreator(ORIGINAL_CREATOR);
|
||||||
|
pdfDoc.setProducer(ORIGINAL_PRODUCER);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await pdfDoc.save({ useObjectStreams: false });
|
||||||
|
writeFileSync(CV_PATH, saved);
|
||||||
|
|
||||||
|
if (needsLink && needsMetadata) {
|
||||||
|
console.log('Added the pitch link and restored the original CV.pdf metadata.');
|
||||||
|
} else if (needsLink) {
|
||||||
|
console.log('Added visible pitch URL and link annotation to page 1 of CV.pdf.');
|
||||||
|
} else {
|
||||||
|
console.log('Restored the original Title, Creator and Producer on CV.pdf.');
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
|
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
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 } from './core/content/content.token';
|
||||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
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 = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -18,5 +24,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
),
|
),
|
||||||
provideClientHydration(withEventReplay()),
|
provideClientHydration(withEventReplay()),
|
||||||
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
|
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
|
||||||
|
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,16 +6,18 @@
|
|||||||
<a class="site-identity" [routerLink]="navigation.link('home')">
|
<a class="site-identity" [routerLink]="navigation.link('home')">
|
||||||
{{ siteConfig.personName }}
|
{{ siteConfig.personName }}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<div class="site-toolbar cluster">
|
||||||
type="button"
|
<button
|
||||||
class="nav-toggle"
|
type="button"
|
||||||
[attr.aria-expanded]="navOpen()"
|
class="nav-toggle"
|
||||||
aria-controls="primary-nav"
|
[attr.aria-expanded]="navOpen()"
|
||||||
[attr.aria-label]="menuLabel()"
|
aria-controls="primary-nav"
|
||||||
(click)="toggleNav()"
|
[attr.aria-label]="menuLabel()"
|
||||||
>
|
(click)="toggleNav()"
|
||||||
<span aria-hidden="true"></span>
|
>
|
||||||
</button>
|
<span aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<nav class="site-nav" [attr.aria-label]="shell().primaryNavLabel">
|
<nav class="site-nav" [attr.aria-label]="shell().primaryNavLabel">
|
||||||
<ul class="primary-nav" id="primary-nav">
|
<ul class="primary-nav" id="primary-nav">
|
||||||
@for (item of navigation.primaryNav(); track item.routeId) {
|
@for (item of navigation.primaryNav(); track item.routeId) {
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
>{{ item.label }}</a
|
>{{ item.label }}</a
|
||||||
>
|
>
|
||||||
@if (item.children; as children) {
|
@if (item.children; as children) {
|
||||||
<ul>
|
<ul class="submenu">
|
||||||
@for (child of children; track child.routeId) {
|
@for (child of children; track child.routeId) {
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@@ -55,13 +57,6 @@
|
|||||||
>
|
>
|
||||||
{{ shell().otherLocaleName }}
|
{{ shell().otherLocaleName }}
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
[href]="siteConfig.cvAssetPath"
|
|
||||||
[attr.download]="siteConfig.cvDownloadFileName"
|
|
||||||
type="application/pdf"
|
|
||||||
>
|
|
||||||
{{ shell().cvLabel }}
|
|
||||||
</a>
|
|
||||||
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
|
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,3 +86,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<app-terminal-dock></app-terminal-dock>
|
||||||
|
|||||||
22
src/app/app.routes.server.spec.ts
Normal file
22
src/app/app.routes.server.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,5 +9,6 @@ export const serverRoutes: ServerRoute[] = [
|
|||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
renderMode: RenderMode.Server,
|
renderMode: RenderMode.Server,
|
||||||
|
status: 404,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ describe('app routes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers pitch as a lazy route in both locales', () => {
|
||||||
|
const flattened = flattenRoutes(routes);
|
||||||
|
const german = flattened.find((entry) => entry.path === 'pitch');
|
||||||
|
const english = flattened.find((entry) => entry.path === 'en/pitch');
|
||||||
|
const pitchRoute = routes.find((route) => route.path === 'pitch');
|
||||||
|
|
||||||
|
expect(german?.data).toEqual({ routeId: 'pitch', locale: 'de' });
|
||||||
|
expect(english?.data).toEqual({ routeId: 'pitch', locale: 'en' });
|
||||||
|
expect(pitchRoute?.loadComponent).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
it('attaches the final page title for every locale route', () => {
|
it('attaches the final page title for every locale route', () => {
|
||||||
const flattened = flattenRoutes(routes);
|
const flattened = flattenRoutes(routes);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type LazyPage = () => Promise<Type<unknown>>;
|
|||||||
|
|
||||||
const PAGE_LOADERS: Record<RouteId, LazyPage> = {
|
const PAGE_LOADERS: Record<RouteId, LazyPage> = {
|
||||||
home: () => import('./features/home/home').then((module) => module.HomePage),
|
home: () => import('./features/home/home').then((module) => module.HomePage),
|
||||||
|
pitch: () => import('./features/pitch/pitch').then((module) => module.PitchPage),
|
||||||
services: () => import('./features/services/services').then((module) => module.ServicesPage),
|
services: () => import('./features/services/services').then((module) => module.ServicesPage),
|
||||||
servicesSoftware: () =>
|
servicesSoftware: () =>
|
||||||
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),
|
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
position: sticky;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 30;
|
||||||
padding-block: var(--space-3);
|
padding-block: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +37,34 @@
|
|||||||
text-decoration: none;
|
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 {
|
.site-identity {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle {
|
.site-toolbar {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
width: 2.5rem;
|
align-items: center;
|
||||||
height: 2.5rem;
|
}
|
||||||
|
|
||||||
|
.nav-toggle {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
border: 1px solid var(--surface-glass-border);
|
border: 1px solid var(--surface-glass-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -116,7 +136,11 @@
|
|||||||
|
|
||||||
.site-main {
|
.site-main {
|
||||||
flex: 1;
|
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 {
|
.site-footer {
|
||||||
@@ -137,27 +161,75 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
padding-block: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.site-header-inner {
|
.site-header-inner {
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-block: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav,
|
.site-nav,
|
||||||
|
.nav-collapsed .site-nav {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
order: 5;
|
||||||
|
}
|
||||||
|
|
||||||
.site-actions,
|
.site-actions,
|
||||||
.nav-collapsed .site-nav,
|
|
||||||
.nav-collapsed .site-actions {
|
.nav-collapsed .site-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-toolbar {
|
||||||
|
order: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-nav {
|
.primary-nav {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-nav ul {
|
.primary-nav > li {
|
||||||
padding-inline-start: 0;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,67 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||||
import { App } from './app';
|
import { provideRouter, Router, TitleStrategy } from '@angular/router';
|
||||||
|
import { App, WIDE_NAV_QUERY } from './app';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { SITE_CONTENT } from './core/content/content.token';
|
import { SITE_CONTENT } from './core/content/content.token';
|
||||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||||
|
import { 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', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||||
|
mockViewport(true);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await configureApp();
|
||||||
imports: [App],
|
|
||||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
Reflect.deleteProperty(window, 'matchMedia');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create the shell', async () => {
|
it('should create the shell', async () => {
|
||||||
@@ -43,4 +88,86 @@ describe('App', () => {
|
|||||||
expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy();
|
expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy();
|
||||||
expect(compiled.querySelector('footer')).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
import { DOCUMENT, ViewportScroller } from '@angular/common';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
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 { DotBackground } from './components/dot-background/dot-background';
|
||||||
import { SHELL_COPY } from './core/content/shell-copy';
|
import { SHELL_COPY } from './core/content/shell-copy';
|
||||||
import { SITE_CONFIG } from './core/content/site-config';
|
import { SITE_CONFIG } from './core/content/site-config';
|
||||||
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
||||||
import { LocaleService } from './core/i18n/locale.service';
|
import { LocaleService } from './core/i18n/locale.service';
|
||||||
import { NavigationService } from './core/navigation/navigation.service';
|
import { NavigationService } from './core/navigation/navigation.service';
|
||||||
|
import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
|
||||||
|
import { TerminalDock } from './shared/terminal/terminal-dock';
|
||||||
|
|
||||||
|
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
|
||||||
|
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground],
|
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly navigation = inject(NavigationService);
|
protected readonly navigation = inject(NavigationService);
|
||||||
protected readonly localeService = inject(LocaleService);
|
protected readonly localeService = inject(LocaleService);
|
||||||
|
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 siteConfig = SITE_CONFIG;
|
||||||
protected readonly navOpen = signal(true);
|
protected readonly navOpen = signal(true);
|
||||||
|
|
||||||
@@ -27,7 +49,61 @@ export class App {
|
|||||||
this.navOpen() ? this.shell().menuClose : this.shell().menuOpen,
|
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 {
|
protected toggleNav(): void {
|
||||||
this.navOpen.update((open) => !open);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,281 @@
|
|||||||
|
import { ApplicationRef } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { DotBackground } from './dot-background';
|
import { DotBackground } from './dot-background';
|
||||||
|
|
||||||
|
function mockContext(): CanvasRenderingContext2D {
|
||||||
|
const gradient = { addColorStop: vi.fn() };
|
||||||
|
|
||||||
|
return {
|
||||||
|
clearRect: vi.fn(),
|
||||||
|
beginPath: vi.fn(),
|
||||||
|
arc: vi.fn(),
|
||||||
|
fill: vi.fn(),
|
||||||
|
createRadialGradient: vi.fn(() => gradient),
|
||||||
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('DotBackground', () => {
|
describe('DotBackground', () => {
|
||||||
let component: DotBackground;
|
afterEach(() => {
|
||||||
let fixture: ComponentFixture<DotBackground>;
|
vi.restoreAllMocks();
|
||||||
|
Reflect.deleteProperty(window, 'matchMedia');
|
||||||
beforeEach(async () => {
|
Object.defineProperty(document, 'hidden', {
|
||||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
configurable: true,
|
||||||
|
get: () => false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [DotBackground],
|
imports: [DotBackground],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DotBackground);
|
const fixture = TestBed.createComponent(DotBackground);
|
||||||
component = fixture.componentInstance;
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
});
|
TestBed.inject(ApplicationRef).tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
it('should create', async () => {
|
||||||
vi.restoreAllMocks();
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
const fixture = await createFixture();
|
||||||
expect(component).toBeTruthy();
|
expect(fixture.componentInstance).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when destroyed after browser initialization', async () => {
|
it('does not throw when destroyed after browser initialization', async () => {
|
||||||
const gradient = { addColorStop: vi.fn() };
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||||
const context = {
|
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||||
clearRect: vi.fn(),
|
|
||||||
beginPath: vi.fn(),
|
|
||||||
arc: vi.fn(),
|
|
||||||
fill: vi.fn(),
|
|
||||||
createRadialGradient: vi.fn(() => gradient),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
const fixture = await createFixture();
|
||||||
context as unknown as CanvasRenderingContext2D,
|
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 animationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0);
|
const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange');
|
||||||
|
|
||||||
const initializedFixture = TestBed.createComponent(DotBackground);
|
expect(windowAdds.length).toBeGreaterThan(0);
|
||||||
initializedFixture.detectChanges();
|
expect(documentAdds.length).toBeGreaterThan(0);
|
||||||
await initializedFixture.whenStable();
|
|
||||||
|
|
||||||
expect(() => initializedFixture.destroy()).not.toThrow();
|
fixture.destroy();
|
||||||
|
|
||||||
animationFrameSpy.mockRestore();
|
expect(cancel).toHaveBeenCalled();
|
||||||
vi.restoreAllMocks();
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
afterNextRender,
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
NgZone,
|
NgZone,
|
||||||
OnDestroy,
|
|
||||||
ViewChild,
|
ViewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
|
import {
|
||||||
|
isBrowserPlatform,
|
||||||
|
prefersCoarsePointer,
|
||||||
|
prefersReducedMotion,
|
||||||
|
} from '../../core/platform/browser';
|
||||||
import { Dot } from '../../models/dot';
|
import { Dot } from '../../models/dot';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -15,21 +20,33 @@ import { Dot } from '../../models/dot';
|
|||||||
imports: [],
|
imports: [],
|
||||||
templateUrl: './dot-background.html',
|
templateUrl: './dot-background.html',
|
||||||
styleUrl: './dot-background.scss',
|
styleUrl: './dot-background.scss',
|
||||||
|
host: {
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class DotBackground implements OnDestroy {
|
export class DotBackground {
|
||||||
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly ngZone = inject(NgZone);
|
private readonly ngZone = inject(NgZone);
|
||||||
private readonly coarsePointer = prefersCoarsePointer();
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly isBrowser = isBrowserPlatform();
|
private readonly isBrowser = isBrowserPlatform();
|
||||||
|
private readonly coarsePointer = prefersCoarsePointer();
|
||||||
|
private readonly reducedMotion = prefersReducedMotion();
|
||||||
|
|
||||||
private ctx: CanvasRenderingContext2D | undefined;
|
private ctx: CanvasRenderingContext2D | undefined;
|
||||||
private dots: Dot[] = [];
|
private dots: Dot[] = [];
|
||||||
private mouse = { x: -1000, y: -1000 };
|
private mouse = { x: -1000, y: -1000 };
|
||||||
private animationId = 0;
|
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 = 100;
|
||||||
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
||||||
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
||||||
@@ -39,27 +56,34 @@ export class DotBackground implements OnDestroy {
|
|||||||
private ballSpawnNextColor = 0;
|
private ballSpawnNextColor = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.destroyRef.onDestroy(() => this.teardown());
|
||||||
|
|
||||||
afterNextRender(() => {
|
afterNextRender(() => {
|
||||||
this.init();
|
this.init();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
private view(): Window | null {
|
||||||
if (!this.initialized) {
|
return this.document.defaultView;
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(): void {
|
||||||
|
if (this.tornDown || !this.isBrowser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelAnimationFrame(this.animationId);
|
const view = this.view();
|
||||||
|
|
||||||
if (this.isBrowser) {
|
if (!view) {
|
||||||
window.removeEventListener('resize', this.resize);
|
return;
|
||||||
window.removeEventListener('mousemove', this.onMouseMove);
|
}
|
||||||
window.removeEventListener('click', this.onMouseClick);
|
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
const canvas = this.canvasRef.nativeElement;
|
|
||||||
let ctx: CanvasRenderingContext2D | null = null;
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -76,21 +100,113 @@ export class DotBackground implements OnDestroy {
|
|||||||
this.resize();
|
this.resize();
|
||||||
this.initDots();
|
this.initDots();
|
||||||
|
|
||||||
window.addEventListener('resize', this.resize);
|
this.listen(view, 'resize', this.onResize);
|
||||||
window.addEventListener('mousemove', this.onMouseMove);
|
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
|
||||||
window.addEventListener('click', this.onMouseClick);
|
|
||||||
|
|
||||||
this.initialized = true;
|
if (!this.reducedMotion && !this.coarsePointer) {
|
||||||
this.ngZone.runOutsideAngular(() => this.animate());
|
this.listen(view, 'mousemove', this.onMouseMove);
|
||||||
|
this.listen(view, 'click', this.onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reducedMotion) {
|
||||||
|
this.drawFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||||
}
|
}
|
||||||
|
|
||||||
private resize = () => {
|
private listen(target: EventTarget, type: string, handler: EventListener): void {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
target.addEventListener(type, handler);
|
||||||
const width = window.innerWidth;
|
this.teardowns.push(() => target.removeEventListener(type, handler));
|
||||||
const height = window.innerHeight;
|
}
|
||||||
|
|
||||||
const dx = Math.abs(width - canvas.width) / width;
|
private requestFrame(callback: FrameRequestCallback): number {
|
||||||
const dy = Math.abs(height - canvas.height) / height;
|
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) {
|
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
|
||||||
canvas.width = width;
|
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.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));
|
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.reducedMotion && this.ctx) {
|
||||||
|
this.drawFrame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMouseMove = (e: MouseEvent) => {
|
private onMouseMove = (event: Event): void => {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
if (!(event instanceof MouseEvent)) {
|
||||||
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
|
return;
|
||||||
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
|
}
|
||||||
|
|
||||||
|
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();
|
const dot = this.spawnDot();
|
||||||
dot.x = this.mouse.x;
|
dot.x = this.mouse.x;
|
||||||
dot.y = this.mouse.y;
|
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 {
|
private spawnDot(): Dot {
|
||||||
const dotId = this.ballSpawnId++;
|
const dotId = this.ballSpawnId++;
|
||||||
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
||||||
@@ -140,7 +279,7 @@ export class DotBackground implements OnDestroy {
|
|||||||
return dot;
|
return dot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private populateDot(dot: Dot) {
|
private populateDot(dot: Dot): void {
|
||||||
const { width, height } = this.canvasRef.nativeElement;
|
const { width, height } = this.canvasRef.nativeElement;
|
||||||
|
|
||||||
dot.x = Math.random() * width;
|
dot.x = Math.random() * width;
|
||||||
@@ -151,17 +290,29 @@ export class DotBackground implements OnDestroy {
|
|||||||
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
private initDots() {
|
private initDots(): void {
|
||||||
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
|
const { width, height } = this.canvasRef.nativeElement;
|
||||||
|
const count = this.targetDotCount(width, height);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
this.spawnDot();
|
this.spawnDot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private animate = () => {
|
private animate = (): void => {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
this.animationId = 0;
|
||||||
|
this.drawFrame();
|
||||||
|
|
||||||
|
if (this.loopActive && !this.tornDown) {
|
||||||
|
this.scheduleAnimate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private drawFrame(): void {
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
|
|
||||||
if (!ctx) {
|
if (!canvas || !ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +364,5 @@ export class DotBackground implements OnDestroy {
|
|||||||
ctx.fillStyle = gradient;
|
ctx.fillStyle = gradient;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.animationId = requestAnimationFrame(this.animate);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
@Component({
|
||||||
selector: 'app-skills-grid',
|
selector: 'app-skills-grid',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
10
src/app/core/commands/command-ids.ts
Normal file
10
src/app/core/commands/command-ids.ts
Normal 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',
|
||||||
|
];
|
||||||
@@ -11,8 +11,10 @@ import { SITE_CONTENT_DATA } from './site-content';
|
|||||||
const DELIVERED_OFFER_WORDING =
|
const DELIVERED_OFFER_WORDING =
|
||||||
/für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i;
|
/für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i;
|
||||||
|
|
||||||
|
const UNLIMITED_SCOPE = /\balles\b|\banything\b|any problem|jedes Problem|end-to-end für alles/i;
|
||||||
|
|
||||||
function allMetrics(site: SiteContent): readonly MetricCopy[] {
|
function allMetrics(site: SiteContent): readonly MetricCopy[] {
|
||||||
return [...site.home.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
|
return [...site.pitch.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function allOfferings(site: SiteContent): readonly OfferingCopy[] {
|
function allOfferings(site: SiteContent): readonly OfferingCopy[] {
|
||||||
@@ -41,7 +43,7 @@ describe('content claims and attribution', () => {
|
|||||||
/8/.test(metric.value) && /90/.test(metric.value);
|
/8/.test(metric.value) && /90/.test(metric.value);
|
||||||
const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
|
const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
|
||||||
expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true);
|
expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true);
|
||||||
expect(site.home.metrics.filter(matchesRuntime)).toHaveLength(1);
|
expect(site.pitch.metrics.filter(matchesRuntime)).toHaveLength(1);
|
||||||
expect(site.cases.innofocus.metrics.filter(matchesRuntime)).toHaveLength(1);
|
expect(site.cases.innofocus.metrics.filter(matchesRuntime)).toHaveLength(1);
|
||||||
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) {
|
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) {
|
||||||
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
|
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
|
||||||
@@ -83,4 +85,53 @@ describe('content claims and attribution', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps Home free of unlimited-scope promises', () => {
|
||||||
|
for (const locale of APP_LOCALES) {
|
||||||
|
const home = SITE_CONTENT_DATA[locale].home;
|
||||||
|
const text = [
|
||||||
|
home.title,
|
||||||
|
home.description,
|
||||||
|
home.hero.headline,
|
||||||
|
home.hero.proof ?? '',
|
||||||
|
home.hero.playfulLine ?? '',
|
||||||
|
...(home.hero.body ?? []),
|
||||||
|
...home.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
|
||||||
|
...home.serviceAreas.flatMap((area) => [area.title, area.body]),
|
||||||
|
...home.process.flatMap((step) => [step.title, step.body]),
|
||||||
|
home.proofNote,
|
||||||
|
...home.ctas.map((cta) => cta.label),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(UNLIMITED_SCOPE.test(text), `${locale} home contains unlimited-scope wording`).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pitch timeline roles free of the period date', () => {
|
||||||
|
for (const locale of APP_LOCALES) {
|
||||||
|
for (const entry of SITE_CONTENT_DATA[locale].pitch.timeline) {
|
||||||
|
expect(entry.role, `${locale}.${entry.id}`).not.toMatch(/\d{2}\/\d{4}/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Pitch and Home free of editorial omission commentary', () => {
|
||||||
|
const editorial =
|
||||||
|
/not listed on this site|auf dieser Seite zu listen|This page shows the public selection|Diese Seite zeigt die öffentliche Auswahl|What this page covers|Was diese Seite abdeckt/i;
|
||||||
|
|
||||||
|
for (const locale of APP_LOCALES) {
|
||||||
|
const site = SITE_CONTENT_DATA[locale];
|
||||||
|
const text = [
|
||||||
|
...site.pitch.profile,
|
||||||
|
...site.pitch.timeline.map((entry) => entry.body),
|
||||||
|
...site.pitch.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
|
||||||
|
...site.home.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
|
||||||
|
...site.home.serviceAreas.map((area) => area.body),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(editorial.test(text), locale).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ describe('content completeness', () => {
|
|||||||
const site = SITE_CONTENT_DATA[locale];
|
const site = SITE_CONTENT_DATA[locale];
|
||||||
|
|
||||||
expect(site.pages.home).toBe(site.home);
|
expect(site.pages.home).toBe(site.home);
|
||||||
|
expect(site.pages.pitch).toBe(site.pitch);
|
||||||
|
expect(site.pages.services).toBe(site.servicesOverview);
|
||||||
expect(site.pages.contact).toBe(site.contact);
|
expect(site.pages.contact).toBe(site.contact);
|
||||||
expect(site.pages.projects).toBe(site.projects);
|
expect(site.pages.projects).toBe(site.projects);
|
||||||
expect(site.pages.stack).toBe(site.stack);
|
expect(site.pages.stack).toBe(site.stack);
|
||||||
|
|||||||
@@ -52,7 +52,16 @@ describe('content exclusions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const harness = await RouterTestingHarness.create();
|
const harness = await RouterTestingHarness.create();
|
||||||
const paths = ['/', '/en', '/projekte', '/en/projects', '/ueber-mich', '/en/about'];
|
const paths = [
|
||||||
|
'/',
|
||||||
|
'/en',
|
||||||
|
'/pitch',
|
||||||
|
'/en/pitch',
|
||||||
|
'/projekte',
|
||||||
|
'/en/projects',
|
||||||
|
'/ueber-mich',
|
||||||
|
'/en/about',
|
||||||
|
];
|
||||||
|
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
await harness.navigateByUrl(path);
|
await harness.navigateByUrl(path);
|
||||||
|
|||||||
@@ -96,21 +96,48 @@ export interface ServiceSequenceCopy {
|
|||||||
export interface ServicePageCopy extends PageCopy {
|
export interface ServicePageCopy extends PageCopy {
|
||||||
readonly sequence: ServiceSequenceCopy;
|
readonly sequence: ServiceSequenceCopy;
|
||||||
readonly offerings: readonly OfferingCopy[];
|
readonly offerings: readonly OfferingCopy[];
|
||||||
|
readonly deliveredOfferingsHeading: string;
|
||||||
|
readonly offerOfferingsHeading: string;
|
||||||
|
readonly offeringCaseBackedLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudienceEntryCopy {
|
export interface HomeServiceAreaCopy {
|
||||||
readonly id: 'recruiters' | 'companies';
|
readonly id: 'workplaces' | 'servers' | 'software' | 'ai';
|
||||||
readonly headline: string;
|
readonly title: string;
|
||||||
readonly body: string;
|
readonly body: string;
|
||||||
readonly bullets: readonly string[];
|
readonly routeId: RouteId;
|
||||||
readonly ctas: readonly CtaCopy[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomePageCopy extends PageCopy {
|
export interface HomePageCopy extends PageCopy {
|
||||||
|
readonly serviceAreas: readonly HomeServiceAreaCopy[];
|
||||||
|
readonly serviceAreasHeading: string;
|
||||||
|
readonly process: readonly ProcessStepCopy[];
|
||||||
|
readonly processHeading: string;
|
||||||
|
readonly proofCaseId: CaseStudyId;
|
||||||
|
readonly proofHeading: string;
|
||||||
|
readonly proofNote: string;
|
||||||
|
readonly featuredCaseIds: readonly CaseStudyId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PitchTimelineEntryCopy {
|
||||||
|
readonly id: string;
|
||||||
|
readonly period: string;
|
||||||
|
readonly role: string;
|
||||||
|
readonly body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PitchPageCopy extends PageCopy {
|
||||||
readonly profile: readonly string[];
|
readonly profile: readonly string[];
|
||||||
readonly metrics: readonly MetricCopy[];
|
readonly metrics: readonly MetricCopy[];
|
||||||
readonly audiences: readonly AudienceEntryCopy[];
|
readonly timeline: readonly PitchTimelineEntryCopy[];
|
||||||
|
readonly coreStack: readonly StackGroupCopy[];
|
||||||
readonly featuredCaseIds: readonly CaseStudyId[];
|
readonly featuredCaseIds: readonly CaseStudyId[];
|
||||||
|
readonly timelineHeading: string;
|
||||||
|
readonly stackHeading: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServicesOverviewPageCopy extends PageCopy {
|
||||||
|
readonly systemsMap: { readonly heading: string; readonly intro: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContactFieldId =
|
export type ContactFieldId =
|
||||||
@@ -163,12 +190,14 @@ export interface StackGroupCopy {
|
|||||||
|
|
||||||
export interface StackPageCopy extends PageCopy {
|
export interface StackPageCopy extends PageCopy {
|
||||||
readonly groups: readonly StackGroupCopy[];
|
readonly groups: readonly StackGroupCopy[];
|
||||||
|
readonly evidenceNote: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaseStudyLabels {
|
export interface CaseStudyLabels {
|
||||||
readonly situation: string;
|
readonly situation: string;
|
||||||
readonly approach: string;
|
readonly approach: string;
|
||||||
readonly outcome: string;
|
readonly outcome: string;
|
||||||
|
readonly metrics: string;
|
||||||
readonly stack: string;
|
readonly stack: string;
|
||||||
readonly tags: string;
|
readonly tags: string;
|
||||||
}
|
}
|
||||||
@@ -180,13 +209,22 @@ export interface ProjectsPageCopy extends PageCopy {
|
|||||||
export type ServicePageId =
|
export type ServicePageId =
|
||||||
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
|
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
|
||||||
|
|
||||||
|
export interface SiteSeoCopy {
|
||||||
|
readonly jobTitle: string;
|
||||||
|
readonly professionalServiceName: string;
|
||||||
|
readonly professionalServiceDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SiteContent {
|
export interface SiteContent {
|
||||||
readonly pages: Record<RouteId, PageCopy>;
|
readonly pages: Record<RouteId, PageCopy>;
|
||||||
readonly home: HomePageCopy;
|
readonly home: HomePageCopy;
|
||||||
|
readonly pitch: PitchPageCopy;
|
||||||
|
readonly servicesOverview: ServicesOverviewPageCopy;
|
||||||
readonly services: Record<ServicePageId, ServicePageCopy>;
|
readonly services: Record<ServicePageId, ServicePageCopy>;
|
||||||
readonly cases: Record<CaseStudyId, CaseStudyCopy>;
|
readonly cases: Record<CaseStudyId, CaseStudyCopy>;
|
||||||
readonly contact: ContactPageCopy;
|
readonly contact: ContactPageCopy;
|
||||||
readonly projects: ProjectsPageCopy;
|
readonly projects: ProjectsPageCopy;
|
||||||
readonly stack: StackPageCopy;
|
readonly stack: StackPageCopy;
|
||||||
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
|
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
|
||||||
|
readonly seo: SiteSeoCopy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
type HomePageCopy,
|
type HomePageCopy,
|
||||||
type LegalPageCopy,
|
type LegalPageCopy,
|
||||||
type PageCopy,
|
type PageCopy,
|
||||||
|
type PitchPageCopy,
|
||||||
type ProjectsPageCopy,
|
type ProjectsPageCopy,
|
||||||
type ServicePageCopy,
|
type ServicePageCopy,
|
||||||
type ServicePageId,
|
type ServicePageId,
|
||||||
|
type ServicesOverviewPageCopy,
|
||||||
type StackPageCopy,
|
type StackPageCopy,
|
||||||
} from './content.contracts';
|
} from './content.contracts';
|
||||||
import { SITE_CONTENT } from './content.token';
|
import { SITE_CONTENT } from './content.token';
|
||||||
@@ -29,6 +31,14 @@ export class ContentService {
|
|||||||
return computed(() => this.content[this.localeService.locale()].home);
|
return computed(() => this.content[this.localeService.locale()].home);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pitch(): Signal<PitchPageCopy> {
|
||||||
|
return computed(() => this.content[this.localeService.locale()].pitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesOverview(): Signal<ServicesOverviewPageCopy> {
|
||||||
|
return computed(() => this.content[this.localeService.locale()].servicesOverview);
|
||||||
|
}
|
||||||
|
|
||||||
service(id: ServicePageId): Signal<ServicePageCopy> {
|
service(id: ServicePageId): Signal<ServicePageCopy> {
|
||||||
return computed(() => this.content[this.localeService.locale()].services[id]);
|
return computed(() => this.content[this.localeService.locale()].services[id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,93 @@
|
|||||||
import { SITE_CONFIG } from '../site-config';
|
|
||||||
import { type HomePageCopy } from '../content.contracts';
|
import { type HomePageCopy } from '../content.contracts';
|
||||||
|
|
||||||
export const HOME_DE: HomePageCopy = {
|
export const HOME_DE: HomePageCopy = {
|
||||||
routeId: 'home',
|
routeId: 'home',
|
||||||
title: 'Startseite | Antonio Ledebuhr',
|
title: 'Startseite | Antonio Ledebuhr',
|
||||||
description:
|
description:
|
||||||
'Fullstack- und DevOps-Ingenieur in Tangermünde: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung, freelance seit 04/2023.',
|
'Du beschreibst, was Dein Unternehmen erreichen soll. Ein technischer Ansprechpartner übernimmt den Weg von der Diagnose bis zum Betrieb oder zur Übergabe.',
|
||||||
hero: {
|
hero: {
|
||||||
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
|
headline:
|
||||||
|
'Du beschreibst, was Dein Unternehmen erreichen soll. Ich kümmere mich um den technischen Weg.',
|
||||||
proof:
|
proof:
|
||||||
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freelance seit 04/2023.',
|
'Bei der Rösterei Tangermünde reicht die öffentliche Arbeit vom Netz und den Arbeitsplätzen bis zum Shop und zwei internen KI-Werkzeugen.',
|
||||||
playfulLine: 'IT mit Drehmoment',
|
playfulLine: 'IT mit Drehmoment',
|
||||||
|
body: [
|
||||||
|
'Du schilderst das Ziel, den Engpass oder den wiederkehrenden Ablauf in Deinen Worten. Ein Ansprechpartner führt von der Diagnose über die Umsetzung bis zum Betrieb oder zur Übergabe.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
profile: [
|
serviceAreasHeading: 'Wobei ich helfen kann',
|
||||||
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.',
|
serviceAreas: [
|
||||||
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
|
|
||||||
'Freelance seit 04/2023, mit Sitz in Tangermünde.',
|
|
||||||
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
|
|
||||||
],
|
|
||||||
metrics: [
|
|
||||||
{
|
{
|
||||||
id: 'experience-years',
|
id: 'workplaces',
|
||||||
value: 'ca. 7 Jahre',
|
title: 'Arbeitsplätze und Netz',
|
||||||
label: 'berufliche Erfahrung mit Webanwendungen',
|
body: 'Geräte, Netz und die Dienste, mit denen Dein Team täglich arbeitet, bleiben zuverlässig.',
|
||||||
|
routeId: 'servicesHardwareNetwork',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'innofocus-migration-runtime',
|
id: 'servers',
|
||||||
value: '8 h → ca. 90 min',
|
title: 'Server und Cluster',
|
||||||
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
|
body: 'Einzelne Hosts, Virtualisierung oder ein Cluster. Der Weg dorthin bleibt nachvollziehbar, vor Ort oder auf Azure AKS.',
|
||||||
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
|
routeId: 'servicesClusters',
|
||||||
caseId: 'innofocus',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'myspa-team-lead',
|
id: 'software',
|
||||||
value: '4 + 2',
|
title: 'Software und Daten',
|
||||||
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
|
body: 'Produktsoftware und Datenarbeit: von der Fachlichkeit über die API bis zur Oberfläche, inklusive SQL.',
|
||||||
caseId: 'myspa',
|
routeId: 'servicesSoftware',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
title: 'KI-Abläufe',
|
||||||
|
body: 'Zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde. Weitere Bausteine — lokale Modelle, Suche in eigenen Unterlagen, Prüfung durch einen Menschen — werden je Auftrag geprüft.',
|
||||||
|
routeId: 'servicesAi',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
audiences: [
|
processHeading: 'So läuft die Zusammenarbeit',
|
||||||
|
process: [
|
||||||
{
|
{
|
||||||
id: 'recruiters',
|
id: 'describe',
|
||||||
headline: 'Für Recruiterinnen und Recruiter',
|
title: 'Lage in Deinen Worten',
|
||||||
body: 'Ein kurzer Überblick über Erfahrung, ausgewählte Fälle, den technischen Stack und den Lebenslauf.',
|
body: 'Du beschreibst das Ziel, den Schmerz oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
|
||||||
bullets: [
|
|
||||||
'Rund sieben Jahre Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt und Teamverantwortung.',
|
|
||||||
'Vier öffentlich dargestellte Fälle aus eCommerce, interner IT, IoT und Versicherung.',
|
|
||||||
'Heimspiel Java und Spring, dazu C#/.NET, Angular, SQL sowie Docker und Kubernetes.',
|
|
||||||
'Lebenslauf als PDF zum direkten Download.',
|
|
||||||
],
|
|
||||||
ctas: [
|
|
||||||
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
|
|
||||||
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
|
||||||
{
|
|
||||||
label: 'Lebenslauf als PDF',
|
|
||||||
href: SITE_CONFIG.cvAssetPath,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'companies',
|
id: 'diagnose',
|
||||||
headline: 'Für Unternehmen',
|
title: 'Technische Diagnose',
|
||||||
body: 'Vier Leistungsbereiche, vom ersten Workshop bis zum Betrieb — und ein kurzes Briefing für die Anfrage.',
|
body: 'Ich ordne die Lage den vier Bereichen zu, nenne die Grenze und sage, was außerhalb liegt.',
|
||||||
bullets: [
|
},
|
||||||
'Software: Fullstack-Produkte mit Java, .NET, Angular und Datenarbeit in SQL.',
|
{
|
||||||
'Hardware und Netzwerk: Server, Geräte, Microsoft 365 und wartbare Infrastruktur.',
|
id: 'implement',
|
||||||
'Cluster: Docker und Kubernetes on-premise und auf Azure AKS, inklusive Pipelines.',
|
title: 'Umsetzung',
|
||||||
'KI-Integration: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde, weitere Bausteine als Angebot.',
|
body: 'Ein Ansprechpartner setzt um: Software, Infrastruktur, Cluster oder ein klar umrissenes KI-Werkzeug, je nach Briefing.',
|
||||||
],
|
},
|
||||||
ctas: [
|
{
|
||||||
{ label: 'Leistungen ansehen', routeId: 'services' },
|
id: 'operate',
|
||||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
title: 'Betrieb oder Übergabe',
|
||||||
],
|
body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen, kein undokumentierter Zwischenstand.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
proofCaseId: 'roesterei',
|
||||||
|
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: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'profile',
|
id: 'scope',
|
||||||
headline: 'In dreißig Sekunden',
|
headline: 'Vier Arbeitsbereiche',
|
||||||
body: [
|
body: [
|
||||||
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Diese Seite zeigt eine kuratierte Auswahl von Stationen; der vollständige Lebenslauf steht als PDF bereit.',
|
'Vier Bereiche: Arbeitsplätze und Netz, Server und Cluster, Software und Daten sowie KI-Abläufe. Was nicht dazu gehört, wird im Briefing benannt — nicht stillschweigend mitverkauft.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'featured-cases',
|
id: 'proof',
|
||||||
headline: 'Ausgewählte Fälle',
|
headline: 'Warum die Rösterei als Beleg steht',
|
||||||
body: [
|
body: [
|
||||||
'Die vier öffentlichen Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
|
'Die Rösterei Tangermünde ist der öffentliche Fall, der die Kette vom Netz bis zum Shop zeigt. Die übrigen drei Fälle stützen einzelne Bereiche.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Leistungen', routeId: 'services' },
|
{ label: 'Projektbriefing starten', routeId: 'contact' },
|
||||||
{ label: 'Projekte', routeId: 'projects' },
|
{ label: 'Leistungen ansehen', routeId: 'services' },
|
||||||
{ label: 'Kontakt', routeId: 'contact' },
|
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ export const PROJECTS_DE: ProjectsPageCopy = {
|
|||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||||
|
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||||
{ label: 'Kontakt', routeId: 'contact' },
|
{ label: 'Kontakt', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
caseLabels: {
|
caseLabels: {
|
||||||
situation: 'Lage',
|
situation: 'Lage',
|
||||||
approach: 'Vorgehen',
|
approach: 'Vorgehen',
|
||||||
outcome: 'Ergebnis',
|
outcome: 'Ergebnis',
|
||||||
|
metrics: 'Kennzahlen',
|
||||||
stack: 'Technik',
|
stack: 'Technik',
|
||||||
tags: 'Schlagworte',
|
tags: 'Schlagworte',
|
||||||
},
|
},
|
||||||
@@ -50,21 +52,23 @@ export const STACK_DE: StackPageCopy = {
|
|||||||
routeId: 'stack',
|
routeId: 'stack',
|
||||||
title: 'Technik-Stack | Antonio Ledebuhr',
|
title: 'Technik-Stack | Antonio Ledebuhr',
|
||||||
description:
|
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: {
|
hero: {
|
||||||
headline: 'Der Stack, der auf diesen Seiten vorkommt',
|
headline: 'Ein Technik-Überblick zur Einordnung',
|
||||||
proof:
|
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: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'Eine Auswahl, keine Rangliste',
|
headline: 'Überblick, keine Rangliste',
|
||||||
body: ['Die Übersicht ist eine Auswahl aus der öffentlichen Arbeit, 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',
|
id: 'groups',
|
||||||
headline: 'Wo die Gruppen in der Arbeit vorkommen',
|
headline: 'Welche Gruppen die Arbeit berührt',
|
||||||
body: [
|
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.',
|
'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: 'hyperviser', title: 'Hypervisor' },
|
||||||
{ id: 'tools', title: 'Werkzeuge' },
|
{ 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 = {
|
export const ABOUT_DE: PageCopy = {
|
||||||
@@ -129,6 +135,7 @@ export const ABOUT_DE: PageCopy = {
|
|||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Projekte', routeId: 'projects' },
|
{ label: 'Projekte', routeId: 'projects' },
|
||||||
|
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
|
||||||
{ label: 'Kontakt', routeId: 'contact' },
|
{ label: 'Kontakt', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -168,8 +175,10 @@ export const CONTACT_DE: ContactPageCopy = {
|
|||||||
id: 'projectType',
|
id: 'projectType',
|
||||||
control: 'select',
|
control: 'select',
|
||||||
label: 'Art des Vorhabens',
|
label: 'Art des Vorhabens',
|
||||||
required: true,
|
hint: 'freiwillig — wenn noch unklar, einfach offen lassen oder „Noch unklar“ wählen',
|
||||||
|
required: false,
|
||||||
options: [
|
options: [
|
||||||
|
{ value: 'unsure', label: 'Noch unklar' },
|
||||||
{ value: 'software', label: 'Software' },
|
{ value: 'software', label: 'Software' },
|
||||||
{ value: 'hardware-network', label: 'Hardware und Netzwerk' },
|
{ value: 'hardware-network', label: 'Hardware und Netzwerk' },
|
||||||
{ value: 'clusters', label: 'Cluster' },
|
{ value: 'clusters', label: 'Cluster' },
|
||||||
|
|||||||
133
src/app/core/content/de/pitch.ts
Normal file
133
src/app/core/content/de/pitch.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { SITE_CONFIG } from '../site-config';
|
||||||
|
import { type PitchPageCopy } from '../content.contracts';
|
||||||
|
|
||||||
|
export const PITCH_DE: PitchPageCopy = {
|
||||||
|
routeId: 'pitch',
|
||||||
|
title: 'Profil | Antonio Ledebuhr',
|
||||||
|
description:
|
||||||
|
'Fullstack- und DevOps-Ingenieur in Tangermünde: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung. Freiberuflich seit 04/2023.',
|
||||||
|
hero: {
|
||||||
|
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
|
||||||
|
proof:
|
||||||
|
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freiberuflich seit 04/2023.',
|
||||||
|
playfulLine: 'IT mit Drehmoment',
|
||||||
|
},
|
||||||
|
profile: [
|
||||||
|
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.',
|
||||||
|
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
|
||||||
|
'Freiberuflich seit 04/2023, mit Sitz in Tangermünde.',
|
||||||
|
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
|
||||||
|
],
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'experience-years',
|
||||||
|
value: 'ca. 7 Jahre',
|
||||||
|
label: 'berufliche Erfahrung mit Webanwendungen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'innofocus-migration-runtime',
|
||||||
|
value: '8 h → ca. 90 min',
|
||||||
|
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
|
||||||
|
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
|
||||||
|
caseId: 'innofocus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'myspa-team-lead',
|
||||||
|
value: '4 + 2',
|
||||||
|
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
|
||||||
|
caseId: 'myspa',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timelineHeading: 'Stationen',
|
||||||
|
timeline: [
|
||||||
|
{
|
||||||
|
id: 'self-employed',
|
||||||
|
period: 'seit 12/2021',
|
||||||
|
role: 'unternehmerisch selbständig',
|
||||||
|
body: 'Eigene Verantwortung für Auftrag, Technik und Betrieb; eigene Rechnungsstellung und Kundenbeziehungen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'freelance',
|
||||||
|
period: 'seit 04/2023',
|
||||||
|
role: 'freiberuflich',
|
||||||
|
body: 'Freiberufliche Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt, vom Anforderungsworkshop bis in den Produktivbetrieb.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hdi',
|
||||||
|
period: '05/2023 – 01/2024',
|
||||||
|
role: 'Senior Fullstack / DevOps bei HDI Specialty',
|
||||||
|
body: 'Fullstack- und DevOps-Verantwortung in einem regulierten Versicherungsumfeld.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roesterei',
|
||||||
|
period: 'seit 10/2023',
|
||||||
|
role: 'Gesamtverantwortung für die Technik der Rösterei Tangermünde',
|
||||||
|
body: 'Ein Ansprechpartner für die gesamte Betriebstechnik; die wirtschaftliche Beteiligung bleibt offengelegt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'myspa',
|
||||||
|
period: '03/2024 – 09/2024',
|
||||||
|
role: 'Lead Backend / DevOps bei Aracom IT Services / MySpa',
|
||||||
|
body: 'Führung von Backend und DevOps bis zum Softwarestart, Anforderungen direkt vom Kunden.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'innofocus',
|
||||||
|
period: 'seit 09/2025',
|
||||||
|
role: 'Senior Backend- und Datenbankingenieur bei Innofocus / reifen.com',
|
||||||
|
body: 'Alleinverantwortung für die Migration, als einziger Freiberufler im direkten Kontakt mit dem Endkunden.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stackHeading: 'Kernstack',
|
||||||
|
coreStack: [
|
||||||
|
{
|
||||||
|
id: 'java',
|
||||||
|
title: 'Java und Spring',
|
||||||
|
body: 'Heimspiel: Java mit Spring Boot, Spring Data und Tests.',
|
||||||
|
},
|
||||||
|
{ id: 'dotnet', title: 'C# und .NET', body: 'ASP.NET Core, Entity Framework und xUnit.' },
|
||||||
|
{
|
||||||
|
id: 'angular',
|
||||||
|
title: 'Angular und TypeScript',
|
||||||
|
body: 'Angular-SPAs, RxJS und SCSS.',
|
||||||
|
},
|
||||||
|
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL und MariaDB.' },
|
||||||
|
{
|
||||||
|
id: 'containers',
|
||||||
|
title: 'Docker und Kubernetes',
|
||||||
|
body: 'Container und Cluster vor Ort und in der Cloud.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'azure',
|
||||||
|
title: 'Azure einschließlich AKS',
|
||||||
|
body: 'Azure AKS, Azure DevOps und verwandte Cloud-Dienste.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cicd',
|
||||||
|
title: 'GitLab CI/CD und Azure DevOps',
|
||||||
|
body: 'Pipelines, GitOps mit Argo CD wo es zum Auftrag passt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'profile',
|
||||||
|
headline: 'In dreißig Sekunden',
|
||||||
|
body: [
|
||||||
|
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Der Lebenslauf als PDF führt die Stationen vollständig.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'featured-cases',
|
||||||
|
headline: 'Ausgewählte Fälle',
|
||||||
|
body: [
|
||||||
|
'Vier Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ctas: [
|
||||||
|
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
|
||||||
|
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||||
|
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||||
|
{ label: 'E-Mail schreiben', href: `mailto:${SITE_CONFIG.contactEmail}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
|
import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
|
||||||
|
|
||||||
export const SERVICES_OVERVIEW_DE: PageCopy = {
|
export const SERVICES_OVERVIEW_DE: ServicesOverviewPageCopy = {
|
||||||
routeId: 'services',
|
routeId: 'services',
|
||||||
title: 'Leistungen | Antonio Ledebuhr',
|
title: 'Leistungen | Antonio Ledebuhr',
|
||||||
description:
|
description:
|
||||||
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_DE: PageCopy = {
|
|||||||
{ label: 'KI-Integration', routeId: 'servicesAi' },
|
{ label: 'KI-Integration', routeId: 'servicesAi' },
|
||||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
|
systemsMap: {
|
||||||
|
heading: 'Wie die Schichten zusammenhängen',
|
||||||
|
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
|
export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
|
||||||
@@ -126,6 +130,9 @@ export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
||||||
@@ -208,6 +215,9 @@ export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
||||||
@@ -289,6 +299,9 @@ export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_AI_DE: ServicePageCopy = {
|
export const SERVICES_AI_DE: ServicePageCopy = {
|
||||||
@@ -421,4 +434,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
|
|||||||
status: 'offer',
|
status: 'offer',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type SiteContent } from '../content.contracts';
|
import { type SiteContent } from '../content.contracts';
|
||||||
import { CASES_DE } from './cases';
|
import { CASES_DE } from './cases';
|
||||||
import { HOME_DE } from './home';
|
import { HOME_DE } from './home';
|
||||||
|
import { PITCH_DE } from './pitch';
|
||||||
import {
|
import {
|
||||||
ABOUT_DE,
|
ABOUT_DE,
|
||||||
CONTACT_DE,
|
CONTACT_DE,
|
||||||
@@ -20,6 +21,8 @@ import {
|
|||||||
|
|
||||||
export const SITE_CONTENT_DE: SiteContent = {
|
export const SITE_CONTENT_DE: SiteContent = {
|
||||||
home: HOME_DE,
|
home: HOME_DE,
|
||||||
|
pitch: PITCH_DE,
|
||||||
|
servicesOverview: SERVICES_OVERVIEW_DE,
|
||||||
services: {
|
services: {
|
||||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||||
@@ -34,8 +37,15 @@ export const SITE_CONTENT_DE: SiteContent = {
|
|||||||
imprint: IMPRINT_DE,
|
imprint: IMPRINT_DE,
|
||||||
privacy: PRIVACY_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: {
|
pages: {
|
||||||
home: HOME_DE,
|
home: HOME_DE,
|
||||||
|
pitch: PITCH_DE,
|
||||||
services: SERVICES_OVERVIEW_DE,
|
services: SERVICES_OVERVIEW_DE,
|
||||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||||
|
|||||||
@@ -1,99 +1,93 @@
|
|||||||
import { SITE_CONFIG } from '../site-config';
|
|
||||||
import { type HomePageCopy } from '../content.contracts';
|
import { type HomePageCopy } from '../content.contracts';
|
||||||
|
|
||||||
export const HOME_EN: HomePageCopy = {
|
export const HOME_EN: HomePageCopy = {
|
||||||
routeId: 'home',
|
routeId: 'home',
|
||||||
title: 'Home | Antonio Ledebuhr',
|
title: 'Home | Antonio Ledebuhr',
|
||||||
description:
|
description:
|
||||||
'Fullstack and DevOps engineer in Tangermünde: about seven years of web application work, direct customer contact and team responsibility, freelance since 04/2023.',
|
'You describe what the business needs to achieve. One technical contact takes the path from diagnosis through delivery to operations or handover.',
|
||||||
hero: {
|
hero: {
|
||||||
headline: 'Fullstack and DevOps engineer based in Tangermünde',
|
headline:
|
||||||
|
'You describe what the business needs to achieve. I take the technical path from there.',
|
||||||
proof:
|
proof:
|
||||||
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.',
|
'At Rösterei Tangermünde the public work runs from the network and workplaces through to the shop and two in-house AI tools.',
|
||||||
playfulLine: 'Full-stack, full throttle',
|
playfulLine: 'Full-stack, full throttle',
|
||||||
|
body: [
|
||||||
|
'You put the outcome, the bottleneck or the recurring process in your own words. One person then handles diagnosis, implementation and operations or handover.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
profile: [
|
serviceAreasHeading: 'What I can help with',
|
||||||
'About seven years of professional experience building and operating web applications.',
|
serviceAreas: [
|
||||||
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
|
|
||||||
'Freelance since 04/2023, based in Tangermünde.',
|
|
||||||
'Home ground is Java with Spring; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
|
|
||||||
],
|
|
||||||
metrics: [
|
|
||||||
{
|
{
|
||||||
id: 'experience-years',
|
id: 'workplaces',
|
||||||
value: 'about 7 years',
|
title: 'Workplaces and network',
|
||||||
label: 'professional experience with web applications',
|
body: 'Devices, the network and the everyday services your team uses stay reliable.',
|
||||||
|
routeId: 'servicesHardwareNetwork',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'innofocus-migration-runtime',
|
id: 'servers',
|
||||||
value: '8 h → about 90 min',
|
title: 'Servers and clusters',
|
||||||
label: 'data-migration run time at Innofocus / reifen.com',
|
body: 'A single host, virtualisation or a cluster. The path there stays clear, on-premise or on Azure AKS.',
|
||||||
note: 'with a larger feature scope and higher data quality',
|
routeId: 'servicesClusters',
|
||||||
caseId: 'innofocus',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'myspa-team-lead',
|
id: 'software',
|
||||||
value: '4 + 2',
|
title: 'Software and data',
|
||||||
label: 'led the backend team and the DevOps team through the MySpa launch',
|
body: 'Product software and data work: from the domain through the API to the interface, including SQL.',
|
||||||
caseId: 'myspa',
|
routeId: 'servicesSoftware',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
title: 'AI workflows',
|
||||||
|
body: 'Two delivered tools at Rösterei Tangermünde. Further building blocks — local models, search over your own documents, review by a person — are agreed for each assignment.',
|
||||||
|
routeId: 'servicesAi',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
audiences: [
|
processHeading: 'How the work runs',
|
||||||
|
process: [
|
||||||
{
|
{
|
||||||
id: 'recruiters',
|
id: 'describe',
|
||||||
headline: 'For recruiters',
|
title: 'The situation in your words',
|
||||||
body: 'A short path through experience, selected cases, the public stack and the curriculum vitae.',
|
body: 'You describe the goal, the pain or the recurring process. Technical language is not required.',
|
||||||
bullets: [
|
|
||||||
'About seven years of fullstack and DevOps work with direct customer contact and team responsibility.',
|
|
||||||
'Four public cases across eCommerce, in-house IT, IoT and insurance.',
|
|
||||||
'Home ground Java and Spring, plus C#/.NET, Angular, SQL, Docker and Kubernetes.',
|
|
||||||
'Curriculum vitae as a PDF download.',
|
|
||||||
],
|
|
||||||
ctas: [
|
|
||||||
{ label: 'Selected projects', routeId: 'projects' },
|
|
||||||
{ label: 'View the stack', routeId: 'stack' },
|
|
||||||
{
|
|
||||||
label: 'Curriculum vitae as PDF',
|
|
||||||
href: SITE_CONFIG.cvAssetPath,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'companies',
|
id: 'diagnose',
|
||||||
headline: 'For companies',
|
title: 'Technical diagnosis',
|
||||||
body: 'Four service areas, from the first workshop through to operations — and a short briefing for an enquiry.',
|
body: 'I map the brief onto the four areas, name the boundary and say what sits outside it.',
|
||||||
bullets: [
|
},
|
||||||
'Software: fullstack product work with Java, .NET, Angular and SQL data work.',
|
{
|
||||||
'Hardware and network: servers, devices, Microsoft 365 and maintainable infrastructure.',
|
id: 'implement',
|
||||||
'Clusters: Docker and Kubernetes on-premise and on Azure AKS, including pipelines.',
|
title: 'Implementation',
|
||||||
'AI integration: two delivered tools at Rösterei Tangermünde, further building blocks as an offer.',
|
body: 'One person delivers: software, infrastructure, a cluster or a clearly defined AI tool, according to the brief.',
|
||||||
],
|
},
|
||||||
ctas: [
|
{
|
||||||
{ label: 'Browse services', routeId: 'services' },
|
id: 'operate',
|
||||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
title: 'Operations or handover',
|
||||||
],
|
body: 'The result stays operable. Handover means structures you can follow, not an undocumented snapshot.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
proofCaseId: 'roesterei',
|
||||||
|
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: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'profile',
|
id: 'scope',
|
||||||
headline: 'Thirty seconds',
|
headline: 'Four areas of work',
|
||||||
body: [
|
body: [
|
||||||
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. This site shows a curated selection of stations; the full curriculum vitae is available as a PDF.',
|
'Four areas: workplaces and network, servers and clusters, software and data, and AI workflows. What sits outside that set is named in the briefing — it is not sold by implication.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'featured-cases',
|
id: 'proof',
|
||||||
headline: 'Selected cases',
|
headline: 'Why the roastery is the proof',
|
||||||
body: [
|
body: [
|
||||||
'The four public cases cover eCommerce and ERP, shop-floor IT, IoT and insurance. Each case is written in full on the projects page.',
|
'Rösterei Tangermünde is the public case that shows the chain from the network to the shop. The other three cases support individual areas.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Services', routeId: 'services' },
|
{ label: 'Start a project briefing', routeId: 'contact' },
|
||||||
{ label: 'Projects', routeId: 'projects' },
|
{ label: 'Browse services', routeId: 'services' },
|
||||||
{ label: 'Contact', routeId: 'contact' },
|
{ label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ export const PROJECTS_EN: ProjectsPageCopy = {
|
|||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||||
|
{ label: 'View the stack', routeId: 'stack' },
|
||||||
{ label: 'Contact', routeId: 'contact' },
|
{ label: 'Contact', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
caseLabels: {
|
caseLabels: {
|
||||||
situation: 'Situation',
|
situation: 'Situation',
|
||||||
approach: 'Approach',
|
approach: 'Approach',
|
||||||
outcome: 'Outcome',
|
outcome: 'Outcome',
|
||||||
|
metrics: 'Metrics',
|
||||||
stack: 'Stack',
|
stack: 'Stack',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
},
|
},
|
||||||
@@ -50,21 +52,23 @@ export const STACK_EN: StackPageCopy = {
|
|||||||
routeId: 'stack',
|
routeId: 'stack',
|
||||||
title: 'Technology stack | Antonio Ledebuhr',
|
title: 'Technology stack | Antonio Ledebuhr',
|
||||||
description:
|
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: {
|
hero: {
|
||||||
headline: 'The stack that appears on these pages',
|
headline: 'A technology overview, not a proof list',
|
||||||
proof:
|
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: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'A selection, not a ranking',
|
headline: 'An overview, not a ranking',
|
||||||
body: ['The overview is a selection from the public work, 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',
|
id: 'groups',
|
||||||
headline: 'Where the groups show up in the work',
|
headline: 'Which groups the work touches',
|
||||||
body: [
|
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.',
|
'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: 'hyperviser', title: 'Hypervisors' },
|
||||||
{ id: 'tools', title: 'Tools' },
|
{ 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 = {
|
export const ABOUT_EN: PageCopy = {
|
||||||
@@ -129,6 +135,7 @@ export const ABOUT_EN: PageCopy = {
|
|||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Projects', routeId: 'projects' },
|
{ label: 'Projects', routeId: 'projects' },
|
||||||
|
{ label: 'For recruiters', routeId: 'pitch' },
|
||||||
{ label: 'Contact', routeId: 'contact' },
|
{ label: 'Contact', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -168,8 +175,10 @@ export const CONTACT_EN: ContactPageCopy = {
|
|||||||
id: 'projectType',
|
id: 'projectType',
|
||||||
control: 'select',
|
control: 'select',
|
||||||
label: 'Type of work',
|
label: 'Type of work',
|
||||||
required: true,
|
hint: 'optional — leave blank or pick “Not sure yet” if the category is still open',
|
||||||
|
required: false,
|
||||||
options: [
|
options: [
|
||||||
|
{ value: 'unsure', label: 'Not sure yet' },
|
||||||
{ value: 'software', label: 'Software' },
|
{ value: 'software', label: 'Software' },
|
||||||
{ value: 'hardware-network', label: 'Hardware and network' },
|
{ value: 'hardware-network', label: 'Hardware and network' },
|
||||||
{ value: 'clusters', label: 'Clusters' },
|
{ value: 'clusters', label: 'Clusters' },
|
||||||
|
|||||||
133
src/app/core/content/en/pitch.ts
Normal file
133
src/app/core/content/en/pitch.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { SITE_CONFIG } from '../site-config';
|
||||||
|
import { type PitchPageCopy } from '../content.contracts';
|
||||||
|
|
||||||
|
export const PITCH_EN: PitchPageCopy = {
|
||||||
|
routeId: 'pitch',
|
||||||
|
title: 'Profile | Antonio Ledebuhr',
|
||||||
|
description:
|
||||||
|
'Fullstack and DevOps engineer in Tangermünde: about seven years of web application work, direct customer contact and team responsibility. Freelance since 04/2023.',
|
||||||
|
hero: {
|
||||||
|
headline: 'Fullstack and DevOps engineer based in Tangermünde',
|
||||||
|
proof:
|
||||||
|
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.',
|
||||||
|
playfulLine: 'Full-stack, full throttle',
|
||||||
|
},
|
||||||
|
profile: [
|
||||||
|
'About seven years of professional experience building and operating web applications.',
|
||||||
|
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and line management and team leadership.',
|
||||||
|
'Freelance since 04/2023, based in Tangermünde.',
|
||||||
|
'Java with Spring is the home stack; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
|
||||||
|
],
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'experience-years',
|
||||||
|
value: 'about 7 years',
|
||||||
|
label: 'professional experience with web applications',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'innofocus-migration-runtime',
|
||||||
|
value: '8 h → about 90 min',
|
||||||
|
label: 'data-migration run time at Innofocus / reifen.com',
|
||||||
|
note: 'with a larger feature scope and higher data quality',
|
||||||
|
caseId: 'innofocus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'myspa-team-lead',
|
||||||
|
value: '4 + 2',
|
||||||
|
label: 'led the backend team and the DevOps team through the MySpa launch',
|
||||||
|
caseId: 'myspa',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timelineHeading: 'Stations',
|
||||||
|
timeline: [
|
||||||
|
{
|
||||||
|
id: 'self-employed',
|
||||||
|
period: 'since 12/2021',
|
||||||
|
role: 'entrepreneurially self-employed',
|
||||||
|
body: 'Own responsibility for the brief, the technology and operations; own invoicing and client relationships.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'freelance',
|
||||||
|
period: 'since 04/2023',
|
||||||
|
role: 'freelance',
|
||||||
|
body: 'Freelance fullstack and DevOps work with direct customer contact, from the requirements workshop through to production.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hdi',
|
||||||
|
period: '05/2023 – 01/2024',
|
||||||
|
role: 'Senior fullstack / DevOps at HDI Specialty',
|
||||||
|
body: 'Fullstack and DevOps ownership in a regulated insurance setting.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roesterei',
|
||||||
|
period: 'since 10/2023',
|
||||||
|
role: 'Full technical responsibility at Rösterei Tangermünde',
|
||||||
|
body: 'One contact for the company’s technology as a whole; the economic stake stays disclosed.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'myspa',
|
||||||
|
period: '03/2024 – 09/2024',
|
||||||
|
role: 'Lead backend / DevOps at Aracom IT Services / MySpa',
|
||||||
|
body: 'Led backend and DevOps through go-live, taking requirements directly from the customer.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'innofocus',
|
||||||
|
period: 'since 09/2025',
|
||||||
|
role: 'Senior backend and database engineer at Innofocus / reifen.com',
|
||||||
|
body: 'Sole ownership of the migration, as the only freelancer in direct contact with the end customer.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stackHeading: 'Core stack',
|
||||||
|
coreStack: [
|
||||||
|
{
|
||||||
|
id: 'java',
|
||||||
|
title: 'Java and Spring',
|
||||||
|
body: 'Home stack: Java with Spring Boot, Spring Data and tests.',
|
||||||
|
},
|
||||||
|
{ id: 'dotnet', title: 'C# and .NET', body: 'ASP.NET Core, Entity Framework and xUnit.' },
|
||||||
|
{
|
||||||
|
id: 'angular',
|
||||||
|
title: 'Angular and TypeScript',
|
||||||
|
body: 'Angular SPAs with RxJS and SCSS.',
|
||||||
|
},
|
||||||
|
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL and MariaDB.' },
|
||||||
|
{
|
||||||
|
id: 'containers',
|
||||||
|
title: 'Docker and Kubernetes',
|
||||||
|
body: 'Containers and clusters on-premise and in the cloud.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'azure',
|
||||||
|
title: 'Azure including AKS',
|
||||||
|
body: 'Azure AKS, Azure DevOps and related cloud services.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cicd',
|
||||||
|
title: 'GitLab CI/CD and Azure DevOps',
|
||||||
|
body: 'Pipelines, and GitOps with Argo CD where the brief fits.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'profile',
|
||||||
|
headline: 'Thirty seconds',
|
||||||
|
body: [
|
||||||
|
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. The CV PDF carries the full record.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'featured-cases',
|
||||||
|
headline: 'Selected cases',
|
||||||
|
body: [
|
||||||
|
'Four cases cover eCommerce and ERP, shop IT, IoT and insurance. Each case is written in full on the projects page.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ctas: [
|
||||||
|
{ label: 'Selected projects', routeId: 'projects' },
|
||||||
|
{ label: 'View the stack', routeId: 'stack' },
|
||||||
|
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||||
|
{ label: 'Write an email', href: `mailto:${SITE_CONFIG.contactEmail}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
|
import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
|
||||||
|
|
||||||
export const SERVICES_OVERVIEW_EN: PageCopy = {
|
export const SERVICES_OVERVIEW_EN: ServicesOverviewPageCopy = {
|
||||||
routeId: 'services',
|
routeId: 'services',
|
||||||
title: 'Services | Antonio Ledebuhr',
|
title: 'Services | Antonio Ledebuhr',
|
||||||
description:
|
description:
|
||||||
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_EN: PageCopy = {
|
|||||||
{ label: 'AI integration', routeId: 'servicesAi' },
|
{ label: 'AI integration', routeId: 'servicesAi' },
|
||||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
|
systemsMap: {
|
||||||
|
heading: 'How the layers connect',
|
||||||
|
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
|
export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
|
||||||
@@ -126,6 +130,9 @@ export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
||||||
@@ -208,6 +215,9 @@ export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
||||||
@@ -289,6 +299,9 @@ export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_AI_EN: ServicePageCopy = {
|
export const SERVICES_AI_EN: ServicePageCopy = {
|
||||||
@@ -421,4 +434,7 @@ export const SERVICES_AI_EN: ServicePageCopy = {
|
|||||||
status: 'offer',
|
status: 'offer',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type SiteContent } from '../content.contracts';
|
import { type SiteContent } from '../content.contracts';
|
||||||
import { CASES_EN } from './cases';
|
import { CASES_EN } from './cases';
|
||||||
import { HOME_EN } from './home';
|
import { HOME_EN } from './home';
|
||||||
|
import { PITCH_EN } from './pitch';
|
||||||
import {
|
import {
|
||||||
ABOUT_EN,
|
ABOUT_EN,
|
||||||
CONTACT_EN,
|
CONTACT_EN,
|
||||||
@@ -20,6 +21,8 @@ import {
|
|||||||
|
|
||||||
export const SITE_CONTENT_EN: SiteContent = {
|
export const SITE_CONTENT_EN: SiteContent = {
|
||||||
home: HOME_EN,
|
home: HOME_EN,
|
||||||
|
pitch: PITCH_EN,
|
||||||
|
servicesOverview: SERVICES_OVERVIEW_EN,
|
||||||
services: {
|
services: {
|
||||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||||
@@ -34,8 +37,15 @@ export const SITE_CONTENT_EN: SiteContent = {
|
|||||||
imprint: IMPRINT_EN,
|
imprint: IMPRINT_EN,
|
||||||
privacy: PRIVACY_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: {
|
pages: {
|
||||||
home: HOME_EN,
|
home: HOME_EN,
|
||||||
|
pitch: PITCH_EN,
|
||||||
services: SERVICES_OVERVIEW_EN,
|
services: SERVICES_OVERVIEW_EN,
|
||||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export interface ShellCopy {
|
|||||||
readonly menuClose: string;
|
readonly menuClose: string;
|
||||||
readonly languageSwitch: string;
|
readonly languageSwitch: string;
|
||||||
readonly otherLocaleName: string;
|
readonly otherLocaleName: string;
|
||||||
readonly cvLabel: string;
|
|
||||||
readonly contactCta: string;
|
readonly contactCta: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
|||||||
menuClose: 'Menü schließen',
|
menuClose: 'Menü schließen',
|
||||||
languageSwitch: 'Zur englischen Version wechseln',
|
languageSwitch: 'Zur englischen Version wechseln',
|
||||||
otherLocaleName: 'English',
|
otherLocaleName: 'English',
|
||||||
cvLabel: 'Lebenslauf als PDF',
|
|
||||||
contactCta: 'Kontakt',
|
contactCta: 'Kontakt',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
@@ -32,7 +30,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
|||||||
menuClose: 'Close menu',
|
menuClose: 'Close menu',
|
||||||
languageSwitch: 'Switch to the German version',
|
languageSwitch: 'Switch to the German version',
|
||||||
otherLocaleName: 'Deutsch',
|
otherLocaleName: 'Deutsch',
|
||||||
cvLabel: 'Curriculum vitae as PDF',
|
|
||||||
contactCta: 'Contact',
|
contactCta: 'Contact',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
70
src/app/core/content/signature-copy.spec.ts
Normal file
70
src/app/core/content/signature-copy.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
156
src/app/core/content/signature-copy.ts
Normal file
156
src/app/core/content/signature-copy.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
export const SITE_CONFIG: {
|
export const SITE_CONFIG: {
|
||||||
readonly personName: string;
|
readonly personName: string;
|
||||||
readonly contactEmail: string;
|
readonly contactEmail: string;
|
||||||
|
readonly siteOrigin: string;
|
||||||
readonly cvAssetPath: string;
|
readonly cvAssetPath: string;
|
||||||
readonly cvDownloadFileName: string;
|
readonly cvDownloadFileName: string;
|
||||||
readonly calendarUrl: string | null;
|
readonly calendarUrl: string | null;
|
||||||
} = {
|
} = {
|
||||||
personName: 'Antonio Ledebuhr',
|
personName: 'Antonio Ledebuhr',
|
||||||
contactEmail: 'info@antoniolede.de',
|
contactEmail: 'info@antoniolede.de',
|
||||||
|
siteOrigin: 'https://antoniolede.de',
|
||||||
cvAssetPath: '/cv/CV.pdf',
|
cvAssetPath: '/cv/CV.pdf',
|
||||||
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
|
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
|
||||||
calendarUrl: null,
|
calendarUrl: null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { routes } from '../../app.routes';
|
|||||||
import { SITE_CONTENT } from '../content/content.token';
|
import { SITE_CONTENT } from '../content/content.token';
|
||||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||||
import { LocaleService } from '../i18n/locale.service';
|
import { LocaleService } from '../i18n/locale.service';
|
||||||
|
import { PRIMARY_NAV } from './navigation';
|
||||||
import { NavigationService } from './navigation.service';
|
import { NavigationService } from './navigation.service';
|
||||||
|
|
||||||
describe('NavigationService', () => {
|
describe('NavigationService', () => {
|
||||||
@@ -40,4 +41,11 @@ describe('NavigationService', () => {
|
|||||||
expect(navigation.link('home')).toEqual(['/', 'en']);
|
expect(navigation.link('home')).toEqual(['/', 'en']);
|
||||||
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
|
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps primary nav to home, services, projects, about and contact', () => {
|
||||||
|
const ids = PRIMARY_NAV.map((item) => item.routeId);
|
||||||
|
expect(ids).toEqual(['home', 'services', 'projects', 'about', 'contact']);
|
||||||
|
expect(ids).not.toContain('stack');
|
||||||
|
expect(ids).not.toContain('pitch');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface NavItem {
|
|||||||
export const PRIMARY_NAV: readonly NavItem[] = [
|
export const PRIMARY_NAV: readonly NavItem[] = [
|
||||||
{
|
{
|
||||||
routeId: 'home',
|
routeId: 'home',
|
||||||
label: { de: 'Start', en: 'Home' },
|
label: { de: 'Startseite', en: 'Home' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
routeId: 'services',
|
routeId: 'services',
|
||||||
@@ -38,10 +38,6 @@ export const PRIMARY_NAV: readonly NavItem[] = [
|
|||||||
routeId: 'projects',
|
routeId: 'projects',
|
||||||
label: { de: 'Projekte', en: 'Projects' },
|
label: { de: 'Projekte', en: 'Projects' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
routeId: 'stack',
|
|
||||||
label: { de: 'Stack', en: 'Stack' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
routeId: 'about',
|
routeId: 'about',
|
||||||
label: { de: 'Über mich', en: 'About' },
|
label: { de: 'Über mich', en: 'About' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PLATFORM_ID } from '@angular/core';
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import {
|
import {
|
||||||
|
isApplePlatform,
|
||||||
isBrowserPlatform,
|
isBrowserPlatform,
|
||||||
prefersCoarsePointer,
|
prefersCoarsePointer,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
@@ -18,6 +19,7 @@ describe('browser platform helpers', () => {
|
|||||||
expect(prefersReducedMotion()).toBe(false);
|
expect(prefersReducedMotion()).toBe(false);
|
||||||
expect(prefersCoarsePointer()).toBe(false);
|
expect(prefersCoarsePointer()).toBe(false);
|
||||||
expect(viewportMatches('(min-width: 40rem)')).toBe(false);
|
expect(viewportMatches('(min-width: 40rem)')).toBe(false);
|
||||||
|
expect(isApplePlatform()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ export function viewportMatches(query: string): boolean {
|
|||||||
return mediaQueryMatches(query);
|
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 {
|
function mediaQueryMatches(query: string): boolean {
|
||||||
if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') {
|
if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type RouteId =
|
export type RouteId =
|
||||||
| 'home'
|
| 'home'
|
||||||
|
| 'pitch'
|
||||||
| 'services'
|
| 'services'
|
||||||
| 'servicesSoftware'
|
| 'servicesSoftware'
|
||||||
| 'servicesHardwareNetwork'
|
| 'servicesHardwareNetwork'
|
||||||
@@ -15,6 +16,7 @@ export type RouteId =
|
|||||||
|
|
||||||
export const ROUTE_IDS: readonly RouteId[] = [
|
export const ROUTE_IDS: readonly RouteId[] = [
|
||||||
'home',
|
'home',
|
||||||
|
'pitch',
|
||||||
'services',
|
'services',
|
||||||
'servicesSoftware',
|
'servicesSoftware',
|
||||||
'servicesHardwareNetwork',
|
'servicesHardwareNetwork',
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ describe('route paths', () => {
|
|||||||
expect(routePath('projects', 'en')).toBe('/en/projects');
|
expect(routePath('projects', 'en')).toBe('/en/projects');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes pitch in both locales and keeps prerenderablePaths at 26', () => {
|
||||||
|
expect(ROUTE_SEGMENTS.de.pitch).toBe('pitch');
|
||||||
|
expect(ROUTE_SEGMENTS.en.pitch).toBe('pitch');
|
||||||
|
expect(routePath('pitch', 'de')).toBe('/pitch');
|
||||||
|
expect(routePath('pitch', 'en')).toBe('/en/pitch');
|
||||||
|
expect(prerenderablePaths()).toHaveLength(26);
|
||||||
|
});
|
||||||
|
|
||||||
it('includes both locales in prerenderable paths and excludes the wildcard', () => {
|
it('includes both locales in prerenderable paths and excludes the wildcard', () => {
|
||||||
const paths = prerenderablePaths();
|
const paths = prerenderablePaths();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const LOCALE_PREFIX: Record<AppLocale, string> = {
|
|||||||
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||||
de: {
|
de: {
|
||||||
home: '',
|
home: '',
|
||||||
|
pitch: 'pitch',
|
||||||
services: 'leistungen',
|
services: 'leistungen',
|
||||||
servicesSoftware: 'leistungen/software',
|
servicesSoftware: 'leistungen/software',
|
||||||
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
|
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
|
||||||
@@ -24,6 +25,7 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
|||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
home: '',
|
home: '',
|
||||||
|
pitch: 'pitch',
|
||||||
services: 'services',
|
services: 'services',
|
||||||
servicesSoftware: 'services/software',
|
servicesSoftware: 'services/software',
|
||||||
servicesHardwareNetwork: 'services/hardware-network',
|
servicesHardwareNetwork: 'services/hardware-network',
|
||||||
|
|||||||
171
src/app/core/seo/crawl-assets.spec.ts
Normal file
171
src/app/core/seo/crawl-assets.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/app/core/seo/route-metadata.spec.ts
Normal file
69
src/app/core/seo/route-metadata.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
61
src/app/core/seo/route-metadata.ts
Normal file
61
src/app/core/seo/route-metadata.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
30
src/app/core/seo/seo-title.strategy.ts
Normal file
30
src/app/core/seo/seo-title.strategy.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/core/seo/seo.contracts.ts
Normal file
30
src/app/core/seo/seo.contracts.ts
Normal 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;
|
||||||
|
}
|
||||||
85
src/app/core/seo/seo.service.spec.ts
Normal file
85
src/app/core/seo/seo.service.spec.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/app/core/seo/seo.service.ts
Normal file
116
src/app/core/seo/seo.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/app/core/seo/structured-data.spec.ts
Normal file
112
src/app/core/seo/structured-data.spec.ts
Normal 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/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
149
src/app/core/seo/structured-data.ts
Normal file
149
src/app/core/seo/structured-data.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -3,33 +3,39 @@
|
|||||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||||
@for (section of copy.sections; track section.id) {
|
@for (section of copy.sections; track section.id) {
|
||||||
<app-content-section [section]="section" />
|
<app-content-section [section]="section" />
|
||||||
@if (section.id === 'profile') {
|
@if (section.id === 'scope') {
|
||||||
<ul class="stack">
|
<section class="stack" [attr.aria-labelledby]="'home-service-areas'">
|
||||||
@for (line of copy.profile; track $index) {
|
<h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2>
|
||||||
<li>{{ line }}</li>
|
<ul class="home-service-areas">
|
||||||
}
|
@for (area of copy.serviceAreas; track area.id) {
|
||||||
</ul>
|
<li>
|
||||||
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
|
<a
|
||||||
|
class="home-service-card glass-surface stack"
|
||||||
|
[routerLink]="areaLink(area.routeId)"
|
||||||
|
>
|
||||||
|
<h3>{{ area.title }}</h3>
|
||||||
|
<p>{{ area.body }}</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="stack" [attr.aria-labelledby]="'home-process'">
|
||||||
|
<h2 id="home-process">{{ copy.processHeading }}</h2>
|
||||||
|
<app-process-steps [steps]="copy.process" [labelledBy]="'home-process'" />
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
@if (section.id === 'featured-cases') {
|
@if (section.id === 'proof') {
|
||||||
<div class="stack">
|
<div appReveal class="home-reveal stack">
|
||||||
|
<h2 id="home-proof">{{ copy.proofHeading }}</h2>
|
||||||
|
<app-case-card [caseStudy]="proofCase()" />
|
||||||
|
<p class="home-proof-note" role="note">{{ copy.proofNote }}</p>
|
||||||
@for (caseStudy of featuredCases(); track caseStudy.id) {
|
@for (caseStudy of featuredCases(); track caseStudy.id) {
|
||||||
<app-case-card [caseStudy]="caseStudy" />
|
<app-case-card [caseStudy]="caseStudy" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@for (audience of copy.audiences; track audience.id) {
|
<app-cta-row [ctas]="copy.ctas" />
|
||||||
<section class="stack" [id]="audience.id" [attr.aria-labelledby]="'audience-' + audience.id">
|
|
||||||
<h2 [id]="'audience-' + audience.id">{{ audience.headline }}</h2>
|
|
||||||
<p>{{ audience.body }}</p>
|
|
||||||
<ul>
|
|
||||||
@for (bullet of audience.bullets; track $index) {
|
|
||||||
<li>{{ bullet }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
<app-cta-row [ctas]="audience.ctas" />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,61 @@
|
|||||||
|
@use '../../shared/motion/reveal';
|
||||||
@use '../../shared/page-shell';
|
@use '../../shared/page-shell';
|
||||||
|
|
||||||
|
.home-reveal {
|
||||||
|
@include reveal.reveal-target;
|
||||||
|
}
|
||||||
|
|
||||||
.page h2 {
|
.page h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.page p,
|
.page p,
|
||||||
.page li {
|
.page li {
|
||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-service-areas {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-service-card {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-service-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-proof-note {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.home-service-card:hover {
|
||||||
|
border-color: var(--surface-glass-border-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.home-service-card {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
import { ContentService } from '../../core/content/content.service';
|
import { ContentService } from '../../core/content/content.service';
|
||||||
|
import { type RouteId } from '../../core/routing/route-ids';
|
||||||
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||||
import { CaseCard } from '../../shared/case-card/case-card';
|
import { CaseCard } from '../../shared/case-card/case-card';
|
||||||
import { ContentSection } from '../../shared/content-section/content-section';
|
import { ContentSection } from '../../shared/content-section/content-section';
|
||||||
import { CtaRow } from '../../shared/cta-row/cta-row';
|
import { CtaRow } from '../../shared/cta-row/cta-row';
|
||||||
import { MetricList } from '../../shared/metric-list/metric-list';
|
import { RevealDirective } from '../../shared/motion/reveal.directive';
|
||||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||||
|
import { ProcessSteps } from '../../shared/process-steps/process-steps';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home-page',
|
selector: 'app-home-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
|
imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow, RevealDirective, RouterLink],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
styleUrl: './home.scss',
|
styleUrl: './home.scss',
|
||||||
})
|
})
|
||||||
export class HomePage {
|
export class HomePage {
|
||||||
private readonly content = inject(ContentService);
|
private readonly content = inject(ContentService);
|
||||||
|
private readonly navigation = inject(NavigationService);
|
||||||
|
|
||||||
protected readonly page = this.content.home();
|
protected readonly page = this.content.home();
|
||||||
|
protected readonly proofCase = computed(() => this.content.caseStudy(this.page().proofCaseId)());
|
||||||
protected readonly featuredCases = computed(() => {
|
protected readonly featuredCases = computed(() => {
|
||||||
const ids = this.page().featuredCaseIds;
|
const ids = this.page().featuredCaseIds;
|
||||||
return ids.map((id) => this.content.caseStudy(id)());
|
return ids.map((id) => this.content.caseStudy(id)());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected areaLink(routeId: RouteId): unknown[] {
|
||||||
|
return this.navigation.link(routeId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { RouterTestingHarness } from '@angular/router/testing';
|
|||||||
import { routes } from '../app.routes';
|
import { routes } from '../app.routes';
|
||||||
import { SITE_CONTENT } from '../core/content/content.token';
|
import { SITE_CONTENT } from '../core/content/content.token';
|
||||||
import { SITE_CONTENT_DATA } from '../core/content/site-content';
|
import { SITE_CONTENT_DATA } from '../core/content/site-content';
|
||||||
import { SITE_CONFIG } from '../core/content/site-config';
|
|
||||||
import { CASE_STUDY_IDS } from '../core/content/content.contracts';
|
import { CASE_STUDY_IDS } from '../core/content/content.contracts';
|
||||||
import { APP_LOCALES } from '../core/i18n/locale';
|
import { APP_LOCALES } from '../core/i18n/locale';
|
||||||
import { routePath } from '../core/routing/route-paths';
|
import { routePath } from '../core/routing/route-paths';
|
||||||
@@ -26,6 +25,17 @@ function accessibleName(root: HTMLElement, element: Element): string {
|
|||||||
return (element.getAttribute('aria-label') ?? element.textContent ?? '').trim();
|
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 {
|
function assertPageSemantics(root: HTMLElement): void {
|
||||||
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
|
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
|
||||||
expect(headings.filter((heading) => heading.tagName === 'H1')).toHaveLength(1);
|
expect(headings.filter((heading) => heading.tagName === 'H1')).toHaveLength(1);
|
||||||
@@ -56,6 +66,8 @@ describe('page rendering, semantics and accessibility', () => {
|
|||||||
const paths = [
|
const paths = [
|
||||||
'/',
|
'/',
|
||||||
'/en',
|
'/en',
|
||||||
|
'/pitch',
|
||||||
|
'/en/pitch',
|
||||||
'/leistungen/software',
|
'/leistungen/software',
|
||||||
'/en/services/software',
|
'/en/services/software',
|
||||||
'/projekte',
|
'/projekte',
|
||||||
@@ -69,10 +81,38 @@ describe('page rendering, semantics and accessibility', () => {
|
|||||||
const root = harness.routeNativeElement as HTMLElement;
|
const root = harness.routeNativeElement as HTMLElement;
|
||||||
expect(root).toBeTruthy();
|
expect(root).toBeTruthy();
|
||||||
assertPageSemantics(root);
|
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({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
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']) {
|
for (const path of ['/', '/en']) {
|
||||||
await harness.navigateByUrl(path);
|
await harness.navigateByUrl(path);
|
||||||
const root = harness.routeNativeElement as HTMLElement;
|
const root = harness.routeNativeElement as HTMLElement;
|
||||||
expect(root.querySelector('#recruiters')).toBeTruthy();
|
expect(root.querySelector('#home-proof')).toBeTruthy();
|
||||||
expect(root.querySelector('#companies')).toBeTruthy();
|
expect(root.querySelector('app-case-card')).toBeTruthy();
|
||||||
expect(root.querySelector(`a[href="${SITE_CONFIG.cvAssetPath}"]`)).toBeTruthy();
|
expect(root.querySelector('#recruiters')).toBeNull();
|
||||||
|
expect(root.querySelector('#companies')).toBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
|
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('places the Systems Map 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 () => {
|
it('sends the software service case CTA to the innofocus project anchor', async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||||
|
|||||||
54
src/app/features/pitch/pitch.html
Normal file
54
src/app/features/pitch/pitch.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@if (page(); as copy) {
|
||||||
|
<div class="content-container stack page">
|
||||||
|
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||||
|
@for (section of copy.sections; track section.id) {
|
||||||
|
@if (section.id === 'profile') {
|
||||||
|
<app-content-section [section]="section" />
|
||||||
|
<ul class="stack">
|
||||||
|
@for (line of copy.profile; track $index) {
|
||||||
|
<li>{{ line }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
|
||||||
|
<section class="stack" [attr.aria-labelledby]="'pitch-timeline'">
|
||||||
|
<h2 id="pitch-timeline">{{ copy.timelineHeading }}</h2>
|
||||||
|
<ol class="stack">
|
||||||
|
@for (entry of copy.timeline; track entry.id) {
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>{{ entry.period }}</strong>
|
||||||
|
· {{ entry.role }}
|
||||||
|
</p>
|
||||||
|
<p>{{ entry.body }}</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section class="stack" [attr.aria-labelledby]="'pitch-stack'">
|
||||||
|
<h2 id="pitch-stack">{{ copy.stackHeading }}</h2>
|
||||||
|
<ul class="stack">
|
||||||
|
@for (group of copy.coreStack; track group.id) {
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
<strong>{{ group.title }}</strong>
|
||||||
|
@if (group.body) {
|
||||||
|
— {{ group.body }}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@if (section.id === 'featured-cases') {
|
||||||
|
<app-content-section [section]="section" />
|
||||||
|
<div class="stack">
|
||||||
|
@for (caseStudy of featuredCases(); track caseStudy.id) {
|
||||||
|
<app-case-card [caseStudy]="caseStudy" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<app-cta-row [ctas]="copy.ctas" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
24
src/app/features/pitch/pitch.ts
Normal file
24
src/app/features/pitch/pitch.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
|
import { ContentService } from '../../core/content/content.service';
|
||||||
|
import { CaseCard } from '../../shared/case-card/case-card';
|
||||||
|
import { ContentSection } from '../../shared/content-section/content-section';
|
||||||
|
import { CtaRow } from '../../shared/cta-row/cta-row';
|
||||||
|
import { MetricList } from '../../shared/metric-list/metric-list';
|
||||||
|
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-pitch-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
|
||||||
|
templateUrl: './pitch.html',
|
||||||
|
styleUrl: '../../shared/page-shell.scss',
|
||||||
|
})
|
||||||
|
export class PitchPage {
|
||||||
|
private readonly content = inject(ContentService);
|
||||||
|
|
||||||
|
protected readonly page = this.content.pitch();
|
||||||
|
protected readonly featuredCases = computed(() => {
|
||||||
|
const ids = this.page().featuredCaseIds;
|
||||||
|
return ids.map((id) => this.content.caseStudy(id)());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
[situationLabel]="copy.caseLabels.situation"
|
[situationLabel]="copy.caseLabels.situation"
|
||||||
[approachLabel]="copy.caseLabels.approach"
|
[approachLabel]="copy.caseLabels.approach"
|
||||||
[outcomeLabel]="copy.caseLabels.outcome"
|
[outcomeLabel]="copy.caseLabels.outcome"
|
||||||
|
[metricsLabel]="copy.caseLabels.metrics"
|
||||||
[stackLabel]="copy.caseLabels.stack"
|
[stackLabel]="copy.caseLabels.stack"
|
||||||
[tagsLabel]="copy.caseLabels.tags"
|
[tagsLabel]="copy.caseLabels.tags"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,15 +6,34 @@
|
|||||||
@if (section.id === 'sequence') {
|
@if (section.id === 'sequence') {
|
||||||
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
|
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
|
||||||
}
|
}
|
||||||
@if (section.id === 'offer-boundary' && copy.offerings.length > 0) {
|
@if (section.id === 'scope' && deliveredOfferings().length > 0) {
|
||||||
<ul class="stack offerings">
|
<section class="stack offerings-group" [attr.aria-labelledby]="deliveredHeadingId()">
|
||||||
@for (offering of copy.offerings; track offering.id) {
|
<h3 [id]="deliveredHeadingId()">{{ copy.deliveredOfferingsHeading }}</h3>
|
||||||
<li class="glass-surface">
|
<ul class="stack offerings">
|
||||||
<h3>{{ offering.title }}</h3>
|
@for (offering of deliveredOfferings(); track offering.id) {
|
||||||
<p>{{ offering.body }}</p>
|
<li class="glass-surface" [attr.data-offering-status]="offering.status">
|
||||||
</li>
|
<h4>{{ offering.title }}</h4>
|
||||||
}
|
<p>{{ offering.body }}</p>
|
||||||
</ul>
|
@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">
|
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">
|
||||||
|
|||||||
@@ -12,12 +12,21 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.offerings h3 {
|
.offerings-group h3,
|
||||||
|
.offerings h4 {
|
||||||
margin: 0 0 var(--space-2);
|
margin: 0 0 var(--space-2);
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offerings h4 {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
|
||||||
.offerings p {
|
.offerings p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offerings .case-backed {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
|
type OfferingCopy,
|
||||||
type ProcessStepCopy,
|
type ProcessStepCopy,
|
||||||
type ServicePageId,
|
type ServicePageId,
|
||||||
type ServiceSequenceCopy,
|
type ServiceSequenceCopy,
|
||||||
@@ -42,4 +43,24 @@ export class ServicePageView {
|
|||||||
const section = this.page().sections.find((item) => item.id === 'sequence');
|
const section = this.page().sections.find((item) => item.id === 'sequence');
|
||||||
return section ? `section-${section.id}` : null;
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,10 @@
|
|||||||
@for (section of copy.sections; track section.id) {
|
@for (section of copy.sections; track section.id) {
|
||||||
<app-content-section [section]="section" />
|
<app-content-section [section]="section" />
|
||||||
}
|
}
|
||||||
|
<app-systems-map
|
||||||
|
[heading]="copy.systemsMap.heading"
|
||||||
|
[intro]="copy.systemsMap.intro"
|
||||||
|
[headingLevel]="2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|||||||
import { ContentService } from '../../core/content/content.service';
|
import { ContentService } from '../../core/content/content.service';
|
||||||
import { ContentSection } from '../../shared/content-section/content-section';
|
import { ContentSection } from '../../shared/content-section/content-section';
|
||||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||||
|
import { SystemsMap } from '../../shared/systems-map/systems-map';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-services-page',
|
selector: 'app-services-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [PageHero, ContentSection],
|
imports: [PageHero, ContentSection, SystemsMap],
|
||||||
templateUrl: './services.html',
|
templateUrl: './services.html',
|
||||||
styleUrl: '../../shared/page-shell.scss',
|
styleUrl: '../../shared/page-shell.scss',
|
||||||
})
|
})
|
||||||
export class ServicesPage {
|
export class ServicesPage {
|
||||||
protected readonly page = inject(ContentService).page('services');
|
protected readonly page = inject(ContentService).servicesOverview();
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/app/features/stack/stack-evidence.spec.ts
Normal file
90
src/app/features/stack/stack-evidence.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@for (section of copy.sections; track section.id) {
|
@for (section of copy.sections; track section.id) {
|
||||||
<app-content-section [section]="section" />
|
<app-content-section [section]="section" />
|
||||||
}
|
}
|
||||||
|
<p class="evidence-note" role="note">{{ copy.evidenceNote }}</p>
|
||||||
<app-skills-grid [categoryTitles]="categoryTitles()" />
|
<app-skills-grid [categoryTitles]="categoryTitles()" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (caseStudy().metrics.length > 0) {
|
@if (caseStudy().metrics.length > 0) {
|
||||||
<app-metric-list [metrics]="caseStudy().metrics" [labelledBy]="metricsLabelId()" />
|
<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'">
|
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-stack'">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export class CaseStudy {
|
|||||||
readonly situationLabel = input.required<string>();
|
readonly situationLabel = input.required<string>();
|
||||||
readonly approachLabel = input.required<string>();
|
readonly approachLabel = input.required<string>();
|
||||||
readonly outcomeLabel = input.required<string>();
|
readonly outcomeLabel = input.required<string>();
|
||||||
|
readonly metricsLabel = input.required<string>();
|
||||||
readonly stackLabel = input.required<string>();
|
readonly stackLabel = input.required<string>();
|
||||||
readonly tagsLabel = input.required<string>();
|
readonly tagsLabel = input.required<string>();
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
[value]="values()[field.id]"
|
[value]="values()[field.id]"
|
||||||
[required]="field.required"
|
[required]="field.required"
|
||||||
[attr.aria-required]="field.required ? 'true' : null"
|
[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"
|
rows="6"
|
||||||
(input)="onInput(field.id, $event)"
|
(input)="onInput(field.id, $event)"
|
||||||
|
(blur)="onBlur(field.id)"
|
||||||
></textarea>
|
></textarea>
|
||||||
}
|
}
|
||||||
@case ('select') {
|
@case ('select') {
|
||||||
@@ -29,8 +31,10 @@
|
|||||||
[value]="values()[field.id]"
|
[value]="values()[field.id]"
|
||||||
[required]="field.required"
|
[required]="field.required"
|
||||||
[attr.aria-required]="field.required ? 'true' : null"
|
[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)"
|
(input)="onInput(field.id, $event)"
|
||||||
|
(blur)="onBlur(field.id)"
|
||||||
>
|
>
|
||||||
<option value=""></option>
|
<option value=""></option>
|
||||||
@for (option of field.options ?? []; track option.value) {
|
@for (option of field.options ?? []; track option.value) {
|
||||||
@@ -45,20 +49,34 @@
|
|||||||
[value]="values()[field.id]"
|
[value]="values()[field.id]"
|
||||||
[required]="field.required"
|
[required]="field.required"
|
||||||
[attr.aria-required]="field.required ? 'true' : null"
|
[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)"
|
(input)="onInput(field.id, $event)"
|
||||||
|
(blur)="onBlur(field.id)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</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="note">{{ copy().noBackendNote }}</p>
|
||||||
|
|
||||||
<p class="cluster actions">
|
<p class="cluster actions">
|
||||||
<a class="submit" [href]="mailtoHref()">{{ copy().submitLabel }}</a>
|
@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>
|
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
|
||||||
@if (showCalendar()) {
|
@if (showCalendar()) {
|
||||||
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ textarea {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions a {
|
.actions a,
|
||||||
|
button.submit {
|
||||||
color: var(--color-accent-cool);
|
color: var(--color-accent-cool);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,21 @@ textarea {
|
|||||||
font-weight: 600;
|
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) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.actions a:hover {
|
.actions a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ describe('ContactBriefing', () => {
|
|||||||
expect(root.querySelector('form[action]')).toBeNull();
|
expect(root.querySelector('form[action]')).toBeNull();
|
||||||
expect(root.textContent).toContain(copy.incompleteHint);
|
expect(root.textContent).toContain(copy.incompleteHint);
|
||||||
expect(root.querySelector('[aria-live="polite"]')).toBeTruthy();
|
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.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
|
||||||
expect(root.textContent).not.toContain(copy.calendarLabel);
|
expect(root.textContent).not.toContain(copy.calendarLabel);
|
||||||
|
|
||||||
@@ -85,4 +91,78 @@ describe('ContactBriefing', () => {
|
|||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
expect(xhrSpy).not.toHaveBeenCalled();
|
expect(xhrSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats project type as optional and enables mailto with name, email and situation', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ContactBriefing],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ContactBriefing);
|
||||||
|
const copy = SITE_CONTENT_DATA.de.contact;
|
||||||
|
fixture.componentRef.setInput('copy', copy);
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
||||||
|
expect(projectType?.required).toBe(false);
|
||||||
|
expect(projectType?.options?.some((option) => option.value === 'unsure')).toBe(true);
|
||||||
|
expect(
|
||||||
|
SITE_CONTENT_DATA.en.contact.fields
|
||||||
|
.find((field) => field.id === 'projectType')
|
||||||
|
?.options?.some((option) => option.value === 'unsure'),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const unsure = projectType?.options?.find((option) => option.value === 'unsure');
|
||||||
|
expect(unsure?.label).toBe('Noch unklar');
|
||||||
|
expect(
|
||||||
|
SITE_CONTENT_DATA.en.contact.fields
|
||||||
|
.find((field) => field.id === 'projectType')
|
||||||
|
?.options?.find((option) => option.value === 'unsure')?.label,
|
||||||
|
).toBe('Not sure yet');
|
||||||
|
|
||||||
|
setControl(root, 'contact-name', 'Ada');
|
||||||
|
setControl(root, 'contact-email', 'ada@example.com');
|
||||||
|
setControl(root, 'contact-situation', 'Need a shop.');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
|
||||||
|
expect(submit?.getAttribute('href')?.startsWith('mailto:')).toBe(true);
|
||||||
|
expect(submit?.getAttribute('href')).not.toContain(
|
||||||
|
encodeURIComponent(`${projectType!.label}:`),
|
||||||
|
);
|
||||||
|
|
||||||
|
setControl(root, 'contact-projectType', 'unsure');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const withType = root.querySelector<HTMLAnchorElement>('a.submit')?.getAttribute('href') ?? '';
|
||||||
|
expect(decodeURIComponent(withType)).toContain(`${projectType!.label}: ${unsure!.label}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks required fields invalid only after interaction', async () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ export class ContactBriefing {
|
|||||||
readonly copy = input.required<ContactPageCopy>();
|
readonly copy = input.required<ContactPageCopy>();
|
||||||
|
|
||||||
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
|
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 contactEmail = SITE_CONFIG.contactEmail;
|
||||||
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
|
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
|
||||||
|
protected readonly incompleteId = 'contact-incomplete';
|
||||||
|
|
||||||
protected readonly missingLabels = computed(() => {
|
protected readonly missingLabels = computed(() => {
|
||||||
const copy = this.copy();
|
const copy = this.copy();
|
||||||
@@ -36,6 +38,8 @@ export class ContactBriefing {
|
|||||||
.map((field) => field.label);
|
.map((field) => field.label);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected readonly isComplete = computed(() => this.missingLabels().length === 0);
|
||||||
|
|
||||||
protected readonly incompleteMessage = computed(() => {
|
protected readonly incompleteMessage = computed(() => {
|
||||||
const missing = this.missingLabels();
|
const missing = this.missingLabels();
|
||||||
if (missing.length === 0) {
|
if (missing.length === 0) {
|
||||||
@@ -60,12 +64,44 @@ export class ContactBriefing {
|
|||||||
protected onInput(fieldId: ContactFieldId, event: Event): void {
|
protected onInput(fieldId: ContactFieldId, event: Event): void {
|
||||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||||
this.values.update((current) => ({ ...current, [fieldId]: target.value }));
|
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 {
|
protected controlId(field: ContactFieldCopy): string {
|
||||||
return `contact-${field.id}`;
|
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 {
|
private assembleBody(copy: ContactPageCopy, values: Record<ContactFieldId, string>): string {
|
||||||
const lines = copy.fields.flatMap((field) => {
|
const lines = copy.fields.flatMap((field) => {
|
||||||
const raw = values[field.id];
|
const raw = values[field.id];
|
||||||
|
|||||||
25
src/app/shared/motion/_reveal.scss
Normal file
25
src/app/shared/motion/_reveal.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/shared/motion/metric-bar/metric-bar.html
Normal file
20
src/app/shared/motion/metric-bar/metric-bar.html
Normal 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>
|
||||||
44
src/app/shared/motion/metric-bar/metric-bar.scss
Normal file
44
src/app/shared/motion/metric-bar/metric-bar.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/app/shared/motion/metric-bar/metric-bar.spec.ts
Normal file
95
src/app/shared/motion/metric-bar/metric-bar.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/app/shared/motion/metric-bar/metric-bar.ts
Normal file
47
src/app/shared/motion/metric-bar/metric-bar.ts
Normal 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()));
|
||||||
|
}
|
||||||
181
src/app/shared/motion/reveal.directive.spec.ts
Normal file
181
src/app/shared/motion/reveal.directive.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/app/shared/motion/reveal.directive.ts
Normal file
77
src/app/shared/motion/reveal.directive.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/shared/motion/reveal.styles.spec.ts
Normal file
36
src/app/shared/motion/reveal.styles.spec.ts
Normal 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
Reference in New Issue
Block a user