Align tests and docs with Pitch, the customer Home, and the terminal dock.
Page, crawl, Playwright and Lighthouse coverage now include both pitch locales, and the contributor docs match the new route table and chrome. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,7 @@ Contributor and agent guide for this repository. Read this before changing routi
|
||||
|
||||
## Purpose
|
||||
|
||||
This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Every public page exists in both locales.
|
||||
This is a bilingual portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Home is the direct-customer experience. `/pitch` is the recruiter-oriented page and is reachable without sitting in the primary navigation. Every public page exists in both locales.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -93,6 +93,7 @@ German and English are written idiomatically per language, never machine-transla
|
||||
## i18n rules
|
||||
|
||||
- German at `/`, English under `/en`
|
||||
- Primary navigation is Home, Services (with four children), Projects, About and Contact. Stack and Pitch stay reachable from content and the terminal, not from the primary nav.
|
||||
- Every route id exists in both locales
|
||||
- New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths
|
||||
- Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService`
|
||||
@@ -154,7 +155,7 @@ A change is not done until:
|
||||
| ----------- | -------------------------------------------------------------------------------- |
|
||||
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
|
||||
| Content | Bilingual copy data and page composition |
|
||||
| Signature | Systems Map, terminal, dot background and motion |
|
||||
| Signature | Systems Map, terminal dock, dot background and motion |
|
||||
| Integration | SEO and cross-cutting a11y/performance hardening |
|
||||
|
||||
Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Portfolio
|
||||
|
||||
Bilingual recruiter and B2B portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`.
|
||||
Bilingual portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`. Home speaks to small-company customers. `/pitch` is the recruiter-oriented page and is not in the primary navigation.
|
||||
|
||||
This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build.
|
||||
|
||||
@@ -18,7 +18,7 @@ npm install
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
| ----------------------------- | ------------------------------------------------------------------------- |
|
||||
| ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| `npm start` | Start the Angular dev server. |
|
||||
| `npm run build` | Production build (default configuration), including SSR and prerendering. |
|
||||
| `npm run watch` | Development rebuild on change. |
|
||||
@@ -34,6 +34,7 @@ npm install
|
||||
| `npm run e2e:install` | Install the pinned Chromium build for Playwright. |
|
||||
| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). |
|
||||
| `npm run ci` | lint, format check, tests, then production build. |
|
||||
| `npm run cv:link` | Add the visible `/pitch` URL and link annotation to `CV.pdf` (idempotent). |
|
||||
|
||||
## Local SSR build
|
||||
|
||||
@@ -42,7 +43,7 @@ npm run build
|
||||
npm run serve:ssr
|
||||
```
|
||||
|
||||
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`.
|
||||
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`. The header no longer offers that download; Pitch and the terminal `navigate cv` command still do. The lower-right terminal dock replaces the old centered command palette (`Ctrl+K` / `⌘K`).
|
||||
|
||||
`npm run e2e` builds the SSR bundle and serves it on port 4173. `npm run lighthouse` builds, then starts the SSR server on port 4000. Browser binaries and generated reports (`test-results`, `playwright-report`, `.lighthouseci`) are not committed.
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { pagePath } from './helpers';
|
||||
const AXE_PATHS = [
|
||||
pagePath('home', 'de'),
|
||||
pagePath('home', 'en'),
|
||||
pagePath('pitch', 'de'),
|
||||
pagePath('pitch', 'en'),
|
||||
pagePath('projects', 'de'),
|
||||
pagePath('projects', 'en'),
|
||||
pagePath('servicesAi', 'de'),
|
||||
@@ -23,8 +25,11 @@ test.describe('accessibility', () => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
const pending = page.locator('.reveal-pending');
|
||||
await expect(pending).toHaveCount(0);
|
||||
await expect(page.locator('app-systems-map')).toBeVisible();
|
||||
await expect(page.locator('app-case-card').first()).toBeVisible();
|
||||
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
await expect(page.locator('.reveal-pending')).toHaveCount(0);
|
||||
await expect(page.locator('app-systems-map')).toBeVisible();
|
||||
});
|
||||
|
||||
for (const path of AXE_PATHS) {
|
||||
|
||||
@@ -24,9 +24,13 @@ test.describe('contact briefing', () => {
|
||||
|
||||
await page.locator('#contact-name').fill('Ada');
|
||||
await page.locator('#contact-email').fill('ada@example.com');
|
||||
await page.locator('#contact-projectType').selectOption('software');
|
||||
await page.locator('#contact-situation').fill('Need a migration.');
|
||||
|
||||
const optionalHref = await page.locator('a.submit').getAttribute('href');
|
||||
expect(optionalHref).toMatch(/^mailto:/);
|
||||
|
||||
await page.locator('#contact-projectType').selectOption('unsure');
|
||||
|
||||
const href = await page.locator('a.submit').getAttribute('href');
|
||||
expect(href).toMatch(/^mailto:/);
|
||||
expect(href).toContain(encodeURIComponent(SITE_CONTENT_DATA.de.contact.mailSubject));
|
||||
|
||||
@@ -5,6 +5,8 @@ import { expectNoOverflow, pagePath } from './helpers';
|
||||
const CHECKED = [
|
||||
pagePath('home', 'de'),
|
||||
pagePath('home', 'en'),
|
||||
pagePath('pitch', 'de'),
|
||||
pagePath('pitch', 'en'),
|
||||
pagePath('projects', 'de'),
|
||||
pagePath('projects', 'en'),
|
||||
pagePath('servicesAi', 'de'),
|
||||
|
||||
@@ -38,6 +38,25 @@ test.describe('server-rendered metadata', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('pitch has full head metadata and stays out of the primary nav', async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
await expectHead(request, 'pitch', 'de');
|
||||
await expectHead(request, 'pitch', 'en');
|
||||
|
||||
const sitemap = await request.get('/sitemap.xml');
|
||||
const xml = await sitemap.text();
|
||||
expect(xml).toContain('https://antoniolede.de/pitch');
|
||||
expect(xml).toContain('https://antoniolede.de/en/pitch');
|
||||
|
||||
await page.goto('/');
|
||||
await ensurePrimaryNavOpen(page);
|
||||
const nav = page.locator('.primary-nav');
|
||||
await expect(nav.locator(`a[href="${pagePath('pitch', 'de')}"]`)).toHaveCount(0);
|
||||
await expect(nav.locator(`a[href="${pagePath('home', 'de')}"]`)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => {
|
||||
const home = await request.get('/');
|
||||
expect(home.status(), 'GET /').toBe(200);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pagePath } from './helpers';
|
||||
|
||||
test.describe('Systems Map', () => {
|
||||
test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
|
||||
for (const width of [390, 768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
@@ -21,7 +21,7 @@ test.describe('Systems Map', () => {
|
||||
});
|
||||
|
||||
test('keeps SVG labels inside their shapes at 1024 and 1440', async ({ page }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
|
||||
for (const width of [1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
@@ -51,7 +51,7 @@ test.describe('Systems Map', () => {
|
||||
});
|
||||
|
||||
test('every SVG node points at a real route', async ({ page, request }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const hrefs = await page
|
||||
.locator('.systems-map-node')
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"url": [
|
||||
"http://127.0.0.1:4000/",
|
||||
"http://127.0.0.1:4000/en",
|
||||
"http://127.0.0.1:4000/pitch",
|
||||
"http://127.0.0.1:4000/en/pitch",
|
||||
"http://127.0.0.1:4000/leistungen/ai-integration",
|
||||
"http://127.0.0.1:4000/en/projects"
|
||||
],
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../app.routes';
|
||||
import { SITE_CONTENT } from '../core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../core/content/site-content';
|
||||
import { SITE_CONFIG } from '../core/content/site-config';
|
||||
import { CASE_STUDY_IDS } from '../core/content/content.contracts';
|
||||
import { APP_LOCALES } from '../core/i18n/locale';
|
||||
import { routePath } from '../core/routing/route-paths';
|
||||
@@ -67,6 +66,8 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
const paths = [
|
||||
'/',
|
||||
'/en',
|
||||
'/pitch',
|
||||
'/en/pitch',
|
||||
'/leistungen/software',
|
||||
'/en/services/software',
|
||||
'/projekte',
|
||||
@@ -111,7 +112,7 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes case anchors, audience entries and the CV download', async () => {
|
||||
it('exposes case anchors and the Rösterei proof on Home', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
@@ -129,37 +130,48 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
for (const path of ['/', '/en']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root.querySelector('#recruiters')).toBeTruthy();
|
||||
expect(root.querySelector('#companies')).toBeTruthy();
|
||||
expect(root.querySelector(`a[href="${SITE_CONFIG.cvAssetPath}"]`)).toBeTruthy();
|
||||
expect(root.querySelector('#home-proof')).toBeTruthy();
|
||||
expect(root.querySelector('app-case-card')).toBeTruthy();
|
||||
expect(root.querySelector('#recruiters')).toBeNull();
|
||||
expect(root.querySelector('#companies')).toBeNull();
|
||||
}
|
||||
|
||||
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
|
||||
});
|
||||
|
||||
it('places the Systems Map between the profile block and the featured cases', async () => {
|
||||
it('places the Systems Map on the services overview', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const path of ['/leistungen', '/en/services']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const map = root.querySelector('app-systems-map');
|
||||
expect(map).toBeTruthy();
|
||||
assertPageSemantics(root);
|
||||
}
|
||||
|
||||
for (const path of ['/', '/en']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const metrics = root.querySelector('app-metric-list');
|
||||
const map = root.querySelector('app-systems-map');
|
||||
const firstCase = root.querySelector('app-case-card');
|
||||
expect(root.querySelector('app-systems-map')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(map).toBeTruthy();
|
||||
expect(firstCase).toBeTruthy();
|
||||
it('renders pitch in both locales with a single h1', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const position = metrics!.compareDocumentPosition(map!);
|
||||
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const afterMap = map!.compareDocumentPosition(firstCase!);
|
||||
expect(afterMap & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const path of ['/pitch', '/en/pitch']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root.querySelectorAll('h1')).toHaveLength(1);
|
||||
assertPageSemantics(root);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,6 +92,51 @@ describe('ContactBriefing', () => {
|
||||
expect(xhrSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats project type as optional and enables mailto with name, email and situation', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContactBriefing],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ContactBriefing);
|
||||
const copy = SITE_CONTENT_DATA.de.contact;
|
||||
fixture.componentRef.setInput('copy', copy);
|
||||
await fixture.whenStable();
|
||||
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
||||
expect(projectType?.required).toBe(false);
|
||||
expect(projectType?.options?.some((option) => option.value === 'unsure')).toBe(true);
|
||||
expect(
|
||||
SITE_CONTENT_DATA.en.contact.fields
|
||||
.find((field) => field.id === 'projectType')
|
||||
?.options?.some((option) => option.value === 'unsure'),
|
||||
).toBe(true);
|
||||
|
||||
const unsure = projectType?.options?.find((option) => option.value === 'unsure');
|
||||
expect(unsure?.label).toBe('Noch unklar');
|
||||
expect(
|
||||
SITE_CONTENT_DATA.en.contact.fields
|
||||
.find((field) => field.id === 'projectType')
|
||||
?.options?.find((option) => option.value === 'unsure')?.label,
|
||||
).toBe('Not sure yet');
|
||||
|
||||
setControl(root, 'contact-name', 'Ada');
|
||||
setControl(root, 'contact-email', 'ada@example.com');
|
||||
setControl(root, 'contact-situation', 'Need a shop.');
|
||||
fixture.detectChanges();
|
||||
|
||||
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
|
||||
expect(submit?.getAttribute('href')?.startsWith('mailto:')).toBe(true);
|
||||
expect(submit?.getAttribute('href')).not.toContain(
|
||||
encodeURIComponent(`${projectType!.label}:`),
|
||||
);
|
||||
|
||||
setControl(root, 'contact-projectType', 'unsure');
|
||||
fixture.detectChanges();
|
||||
const withType = root.querySelector<HTMLAnchorElement>('a.submit')?.getAttribute('href') ?? '';
|
||||
expect(decodeURIComponent(withType)).toContain(`${projectType!.label}: ${unsure!.label}`);
|
||||
});
|
||||
|
||||
it('marks required fields invalid only after interaction', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContactBriefing],
|
||||
|
||||
Reference in New Issue
Block a user