diff --git a/AGENTS.md b/AGENTS.md index 632f3e7..944012f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index ed7072c..baf258d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -17,23 +17,24 @@ 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. | -| `npm test` | Unit tests in watch mode. | -| `npm run test:ci` | Unit tests once; exits when finished. | -| `npm run lint` | Run ESLint. | -| `npm run lint:fix` | Run ESLint with autofix. | -| `npm run format` | Format the workspace with Prettier. | -| `npm run format:check` | Check formatting without writing. | -| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. | -| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. | -| `npm run e2e` | Playwright + axe-core against the production SSR bundle. | -| `npm run e2e:install` | Install the pinned Chromium build for Playwright. | -| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). | -| `npm run ci` | lint, format check, tests, then production build. | +| 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. | +| `npm test` | Unit tests in watch mode. | +| `npm run test:ci` | Unit tests once; exits when finished. | +| `npm run lint` | Run ESLint. | +| `npm run lint:fix` | Run ESLint with autofix. | +| `npm run format` | Format the workspace with Prettier. | +| `npm run format:check` | Check formatting without writing. | +| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. | +| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. | +| `npm run e2e` | Playwright + axe-core against the production SSR bundle. | +| `npm run e2e:install` | Install the pinned Chromium build for Playwright. | +| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). | +| `npm run ci` | lint, format check, tests, then production build. | +| `npm run cv:link` | Add the visible `/pitch` URL and link annotation to `CV.pdf` (idempotent). | ## Local SSR build @@ -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. diff --git a/e2e/a11y.e2e.ts b/e2e/a11y.e2e.ts index 4fcd80e..a42293d 100644 --- a/e2e/a11y.e2e.ts +++ b/e2e/a11y.e2e.ts @@ -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) { diff --git a/e2e/contact.e2e.ts b/e2e/contact.e2e.ts index 7be2e8d..993a33c 100644 --- a/e2e/contact.e2e.ts +++ b/e2e/contact.e2e.ts @@ -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)); diff --git a/e2e/layout.e2e.ts b/e2e/layout.e2e.ts index f0fd7ad..01557d0 100644 --- a/e2e/layout.e2e.ts +++ b/e2e/layout.e2e.ts @@ -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'), diff --git a/e2e/seo.e2e.ts b/e2e/seo.e2e.ts index 77b0773..e5c35c1 100644 --- a/e2e/seo.e2e.ts +++ b/e2e/seo.e2e.ts @@ -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); diff --git a/e2e/systems-map.e2e.ts b/e2e/systems-map.e2e.ts index c34585e..9d951a8 100644 --- a/e2e/systems-map.e2e.ts +++ b/e2e/systems-map.e2e.ts @@ -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') diff --git a/lighthouserc.json b/lighthouserc.json index 5a6ddf4..612c51b 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -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" ], diff --git a/src/app/features/page-rendering.spec.ts b/src/app/features/page-rendering.spec.ts index e3e24b3..0e0c52b 100644 --- a/src/app/features/page-rendering.spec.ts +++ b/src/app/features/page-rendering.spec.ts @@ -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); } }); diff --git a/src/app/shared/contact-briefing/contact-briefing.spec.ts b/src/app/shared/contact-briefing/contact-briefing.spec.ts index 3f14fa4..0507669 100644 --- a/src/app/shared/contact-briefing/contact-briefing.spec.ts +++ b/src/app/shared/contact-briefing/contact-briefing.spec.ts @@ -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('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('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],