Rewrite the constraint specs and docs for the service-led information architecture.

Assertions now follow the eighteen prerenderable paths, the gist dialog, and the six-case evidence set.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 00:41:24 +02:00
parent 55d012cc10
commit a9ad7eb1a7
20 changed files with 467 additions and 165 deletions

View File

@@ -93,7 +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.
- Primary navigation is Home, Services, Projects, About and Contact. Services is one anchored page with four sections. 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`
@@ -128,7 +128,7 @@ German and English are written idiomatically per language, never machine-transla
- 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
- One JSON-LD `@graph` script per route: Person and ProfessionalService on Home, one Service entry per services section on the services route, 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
@@ -158,4 +158,4 @@ A change is not done until:
| 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.
Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch owns the bilingual copy tree behind `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract.

View File

@@ -59,7 +59,7 @@ src/_breakpoints.scss Breakpoint map and respond-to mixin
public/ Static SVG assets
```
Design tokens live in `src/styles.scss` as CSS custom properties. Content contracts and the placeholder provider live in `src/app/core/content`. Route ids and locale path tables live in `src/app/core/routing`.
Design tokens live in `src/styles.scss` as CSS custom properties. Content contracts and the bilingual copy tree live in `src/app/core/content`. Route ids and locale path tables live in `src/app/core/routing`.
Contributor and agent conventions are in [AGENTS.md](./AGENTS.md).

View File

@@ -9,8 +9,8 @@ const AXE_PATHS = [
pagePath('pitch', 'en'),
pagePath('projects', 'de'),
pagePath('projects', 'en'),
pagePath('servicesAi', 'de'),
pagePath('servicesAi', 'en'),
pagePath('services', 'de'),
pagePath('services', 'en'),
pagePath('contact', 'de'),
pagePath('contact', 'en'),
pagePath('imprint', 'de'),

View File

@@ -40,12 +40,12 @@ test.describe('fragment scrolling', () => {
await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
for (const locale of APP_LOCALES) {
const path = `${pagePath('projects', locale)}#myspa`;
const path = `${pagePath('projects', locale)}#bannier`;
const label = `${width}px ${locale}`;
await page.goto(path, { waitUntil: 'networkidle' });
const header = page.locator('.site-header');
const heading = page.locator('#myspa h2');
const heading = page.locator('#bannier h2');
await expect(heading).toBeVisible();
await waitForScrollSettle(page);

View File

@@ -64,53 +64,44 @@ test.describe('keyboard and terminal dock', () => {
}
});
test('compact services submenu stays keyboard accessible at 1024px', async ({
test('compact primary nav stays a single row without a services submenu', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name === 'mobile', 'compact submenu applies from md up');
test.skip(testInfo.project.name === 'mobile', 'compact nav 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')}"]`,
);
const services = page.locator(`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`);
await expect(services).toBeVisible();
await expect(page.locator('.primary-nav .submenu')).toHaveCount(0);
await expect(page.locator('.primary-nav a')).toHaveCount(5);
await servicesParent.focus();
await expect(firstChild).toBeVisible();
await page.keyboard.press('Tab');
await expect(firstChild).toBeFocused();
await services.click();
await page.waitForURL('**/leistungen');
await expect(services).toHaveAttribute('aria-current', 'page');
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+\)$/,
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth + 1,
);
expect(overflow, 'horizontal overflow at 1024px').toBe(true);
});
test('terminal dock opens with Control+K without locking the page and restores on Escape', async ({
test('terminal dock is open on load, does not lock 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(trigger).toBeHidden();
await expect(page.locator('#terminal-dock-input')).not.toBeFocused();
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 expect(page.locator('.terminal-dock [role="dialog"]')).toHaveCount(0);
await page.locator('#terminal-dock-input').focus();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();

View File

@@ -9,13 +9,13 @@ const CHECKED = [
pagePath('pitch', 'en'),
pagePath('projects', 'de'),
pagePath('projects', 'en'),
pagePath('servicesAi', 'de'),
pagePath('services', 'de'),
pagePath('contact', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
];
const VIEWPORTS = [320, 768, 1024, 1440] as const;
const VIEWPORTS = [320, 390, 768, 1024, 1440] as const;
test.describe('layout and crawlability', () => {
test('does not overflow horizontally on checked routes', async ({ page }) => {

View File

@@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { prerenderablePaths } from '../src/app/core/routing/route-paths';
import { expectHead, jsonLdGraph, pagePath } from './helpers';
async function ensurePrimaryNavOpen(page: Page): Promise<void> {
@@ -28,10 +29,10 @@ test.describe('server-rendered metadata', () => {
);
});
test('projects, AI service, contact, legal and 404 keep locale metadata', async ({ request }) => {
test('projects, services, 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, 'services', locale);
await expectHead(request, 'contact', locale);
await expectHead(request, 'imprint', locale);
await expectHead(request, 'notFound', locale);
@@ -57,6 +58,15 @@ test.describe('server-rendered metadata', () => {
await expect(nav.locator(`a[href="${pagePath('home', 'de')}"]`)).toHaveCount(1);
});
test('every prerenderable path returns 200', async ({ request }) => {
const paths = prerenderablePaths();
expect(paths).toHaveLength(18);
for (const path of paths) {
const response = await request.get(path);
expect(response.status(), path).toBe(200);
}
});
test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => {
const home = await request.get('/');
expect(home.status(), 'GET /').toBe(200);
@@ -68,7 +78,7 @@ test.describe('server-rendered metadata', () => {
test('client navigation does not duplicate head tags', async ({ page }) => {
await page.goto('/');
await ensurePrimaryNavOpen(page);
await page.locator('a.contact-cta').first().waitFor();
await page.locator('.language-switch').first().waitFor();
const assertUnique = async (canonical: string, description: string) => {
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
@@ -97,23 +107,34 @@ test.describe('server-rendered metadata', () => {
);
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 page.locator(`.primary-nav a[href="${pagePath('services', 'de')}"]`).click();
await page.waitForURL('**/leistungen');
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://antoniolede.de/leistungen/ai-integration',
'https://antoniolede.de/leistungen',
);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
});
test('legacy service URLs return HTTP 308 with the fragment Location', async ({ request }) => {
const redirects = [
['/leistungen/software', '/leistungen#software-data'],
['/leistungen/software/', '/leistungen#software-data'],
['/leistungen/hardware-netzwerk', '/leistungen#infrastructure-network'],
['/leistungen/cluster', '/leistungen#platform-operations'],
['/leistungen/ai-integration', '/leistungen#ai-workflows'],
['/en/services/software', '/en/services#software-data'],
['/en/services/hardware-network', '/en/services#infrastructure-network'],
['/en/services/clusters', '/en/services#platform-operations'],
['/en/services/ai-integration', '/en/services#ai-workflows'],
['/en/services/ai-integration/', '/en/services#ai-workflows'],
] as const;
for (const [from, to] of redirects) {
const response = await request.get(from, { maxRedirects: 0 });
expect(response.status(), from).toBe(308);
expect(response.headers().location, from).toBe(to);
}
});
});

View File

@@ -1,11 +1,12 @@
import { expect, test } from '@playwright/test';
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
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]) {
for (const width of [320, 390, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
const list = page.locator('.systems-map-list');
await expect(list).toBeVisible();
@@ -59,8 +60,41 @@ test.describe('Systems Map', () => {
expect(hrefs.length).toBeGreaterThan(0);
for (const href of hrefs) {
const response = await request.get(href);
const path = href.split('#')[0];
const response = await request.get(path);
expect(response.status(), href).toBe(200);
}
});
test('plain activation opens the gist dialog without navigating', async ({ page }) => {
const copy = SIGNATURE_COPY.de.systemsMap;
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 390, height: 844 });
const first = page.locator('.systems-map-cards a').first();
const href = await first.getAttribute('href');
expect(href).toContain('#infrastructure-network');
await first.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${pagePath('services', 'de')}$`));
await expect(dialog.getByRole('link')).toHaveAttribute('href', href ?? '');
await expect(dialog.getByRole('button', { name: copy.closeLabel })).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox, 'dialog readable at 390px').toBeTruthy();
expect(dialogBox!.width).toBeGreaterThan(200);
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(first).toBeFocused();
});
test('a modified click follows the node href', async ({ page }) => {
await page.goto(pagePath('services', 'de'));
const first = page.locator('.systems-map-cards a').first();
await first.click({ modifiers: ['Control'] });
await expect(page.getByRole('dialog')).toHaveCount(0);
});
});

View File

@@ -1,8 +1,11 @@
import { expect, test } from '@playwright/test';
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
import { expectNoOverflow } from './helpers';
const VIEWPORTS = [320, 390, 768, 1024, 1440] as const;
test.describe('terminal dock', () => {
test('walks collapsed, expanded and maximized and runs the grammar', async ({
test('defaults open, keeps one session scrollport and runs the grammar', async ({
page,
}, testInfo) => {
const copy = SIGNATURE_COPY.de.terminal;
@@ -11,14 +14,17 @@ test.describe('terminal dock', () => {
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 session = page.locator('.terminal-dock-session');
const maximize = page.locator('.terminal-dock-control[aria-pressed]');
const collapse = page.getByRole('button', { name: copy.collapseLabel });
await expect(trigger).toBeVisible();
await expect(panel).toHaveCount(0);
await trigger.click();
await expect(panel).toBeVisible();
await expect(trigger).toBeHidden();
await expect(input).not.toBeFocused();
await expect(session).toBeVisible();
await expect(session.locator('form')).toHaveCount(1);
await expect(collapse).toHaveAttribute('title', copy.collapseTooltip);
await expect(maximize).toHaveAttribute('aria-label', copy.maximizeLabel);
await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt);
if (testInfo.project.name === 'mobile') {
@@ -28,6 +34,7 @@ test.describe('terminal dock', () => {
} else {
await maximize.click();
await expect(maximize).toHaveAttribute('aria-pressed', 'true');
await expect(maximize).toHaveAttribute('aria-label', copy.restoreLabel);
await maximize.click();
await expect(maximize).toHaveAttribute('aria-pressed', 'false');
}
@@ -36,90 +43,88 @@ test.describe('terminal dock', () => {
await input.press('Enter');
await page.waitForURL('**/pitch');
await expect(page).toHaveURL(/\/pitch$/);
await expect(log).toContainText(`${copy.prompt} navigate pitch`);
await expect(session).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 expect(session).toContainText(copy.historyIntro);
await expect(session).toContainText('navigate pitch');
await input.fill('clear');
await input.press('Enter');
await expect(log).toHaveText(copy.clearedMessage);
await expect(session.locator('p')).toHaveText(copy.clearedMessage);
await input.fill('navigate p');
await input.press('Tab');
await expect(log).toContainText('pitch');
await expect(log).toContainText('projects');
await expect(session).toContainText('pitch');
await expect(session).toContainText('projects');
await input.fill('navigate nowhere');
await input.press('Enter');
await expect(log).toContainText(copy.validTargetsLabel);
await expect(log).toContainText('pitch');
await expect(session).toContainText(copy.validTargetsLabel);
await expect(session).toContainText('pitch');
await input.fill('nav');
await input.press('Tab');
await expect(input).toHaveValue('navigate');
await input.fill('navigate services sof');
await input.press('Tab');
await expect(input).toHaveValue('navigate services software');
});
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.',
);
test('collapse control hides the panel 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();
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await expect(trigger).toBeHidden();
await trigger.click();
const collapse = page.getByRole('button', { name: copy.collapseLabel });
await collapse.click();
await expect(panel).toHaveCount(0);
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(trigger).toBeVisible();
await expect(trigger).toBeFocused();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
test('keeps the newest echoed line in the log scrollport after overflow', async ({ page }) => {
test('keeps the prompt in the session 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');
const session = page.locator('.terminal-dock-session');
await trigger.click();
await expect(log).toBeVisible();
await expect(session).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(session.locator('p.echo').last()).toHaveText(`${copy.prompt} help`);
await expect
.poll(async () =>
log.evaluate((element) => {
session.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);
const form = session.locator('form');
const sessionBox = await session.boundingBox();
const formBox = await form.boundingBox();
expect(sessionBox && formBox).toBeTruthy();
expect(formBox!.y).toBeGreaterThanOrEqual(sessionBox!.y - 1);
expect(formBox!.y + formBox!.height).toBeLessThanOrEqual(
sessionBox!.y + sessionBox!.height + 1,
);
});
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
@@ -130,8 +135,6 @@ test.describe('terminal dock', () => {
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 });
@@ -142,4 +145,13 @@ test.describe('terminal dock', () => {
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
});
test('stays usable and overflow-free at the required viewports', async ({ page }) => {
await page.goto('/');
for (const width of VIEWPORTS) {
await page.setViewportSize({ width, height: 900 });
await expect(page.locator('.terminal-dock-panel')).toBeVisible();
await expectNoOverflow(page, '/', width);
}
});
});

View File

@@ -9,7 +9,7 @@ describe('server routes', () => {
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
const prerendered = serverRoutes.filter((route) => route.path !== '**');
expect(prerendered).toHaveLength(26);
expect(prerendered).toHaveLength(18);
expect(prerendered.map((route) => route.path)).toEqual(
expect.arrayContaining(['pitch', 'en/pitch']),
);

View File

@@ -18,7 +18,7 @@ function allMetrics(site: SiteContent): readonly MetricCopy[] {
}
function allOfferings(site: SiteContent): readonly OfferingCopy[] {
return Object.values(site.services).flatMap((page) => page.offerings);
return site.services.serviceSections.flatMap((section) => section.offerings);
}
function caseText(caseStudy: CaseStudyCopy): string {
@@ -33,11 +33,18 @@ function caseText(caseStudy: CaseStudyCopy): string {
}
describe('content claims and attribution', () => {
it('keeps the four public cases and attributes verified facts to the right ones', () => {
it('keeps the six public cases and attributes verified facts to the right ones', () => {
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
expect(Object.keys(site.cases)).toEqual([...CASE_STUDY_IDS]);
expect(CASE_STUDY_IDS).toEqual(['innofocus', 'roesterei', 'myspa', 'hdi']);
expect(CASE_STUDY_IDS).toEqual([
'innofocus',
'roesterei',
'bannier',
'elbwaerme',
'hdi',
'devdays',
]);
const matchesRuntime = (metric: MetricCopy) =>
/8/.test(metric.value) && /90/.test(metric.value);
@@ -49,14 +56,6 @@ describe('content claims and attribution', () => {
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
}
const leadershipMetrics = allMetrics(site).filter(
(metric) =>
metric.caseId === 'myspa' &&
/4/.test(`${metric.value} ${metric.label}`) &&
/2/.test(`${metric.value} ${metric.label}`),
);
expect(leadershipMetrics.length).toBeGreaterThan(0);
const hdiText = caseText(site.cases.hdi);
expect(hdiText).toMatch(/Azure AKS/);
expect(hdiText).toMatch(/Azure App Services/);
@@ -70,16 +69,34 @@ describe('content claims and attribution', () => {
it('keeps AI delivery claims limited to the two Rösterei tools', () => {
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
const aiOfferings = site.services.servicesAi.offerings;
const delivered = aiOfferings.filter((offering) => offering.status === 'delivered');
const sections = site.services.serviceSections;
expect(delivered).toHaveLength(2);
expect(delivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true);
const ai = sections.find((section) => section.id === 'ai-workflows');
expect(ai, `${locale} ai-workflows section`).toBeTruthy();
const otherOfferings = allOfferings(site).filter((offering) => !delivered.includes(offering));
expect(otherOfferings.every((offering) => offering.status === 'offer')).toBe(true);
const aiDelivered = ai!.offerings.filter((offering) => offering.status === 'delivered');
expect(aiDelivered).toHaveLength(2);
expect(aiDelivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true);
expect(
otherOfferings.some((offering) =>
ai!.offerings
.filter((offering) => offering.status !== 'delivered')
.every((offering) => offering.status === 'offer'),
).toBe(true);
const everyDelivered = allOfferings(site).filter(
(offering) => offering.status === 'delivered',
);
expect(
everyDelivered.every(
(offering) =>
!!offering.referenceCaseId && CASE_STUDY_IDS.includes(offering.referenceCaseId),
),
`${locale}: every delivered offering names a public case`,
).toBe(true);
const offered = allOfferings(site).filter((offering) => offering.status === 'offer');
expect(
offered.some((offering) =>
DELIVERED_OFFER_WORDING.test(`${offering.title} ${offering.body}`),
),
).toBe(false);

View File

@@ -40,13 +40,12 @@ describe('content completeness', () => {
expect(site.pages.home).toBe(site.home);
expect(site.pages.pitch).toBe(site.pitch);
expect(site.pages.services).toBe(site.servicesOverview);
expect(site.pages.services).toBe(site.services);
expect(site.pages.contact).toBe(site.contact);
expect(site.pages.projects).toBe(site.projects);
expect(site.pages.stack).toBe(site.stack);
expect(site.pages.imprint).toBe(site.legal.imprint);
expect(site.pages.privacy).toBe(site.legal.privacy);
expect(site.pages.servicesAi).toBe(site.services.servicesAi);
for (const routeId of ROUTE_IDS) {
const page = site.pages[routeId];

View File

@@ -8,7 +8,7 @@ import { SHELL_COPY } from './shell-copy';
import { FOOTER_NAV, PRIMARY_NAV } from '../navigation/navigation';
const EXCLUDED = new RegExp(
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading'].join('|')})\\b`,
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`,
'i',
);

View File

@@ -0,0 +1,115 @@
import { APP_LOCALES } from '../i18n/locale';
import { CASE_STUDY_IDS, SERVICE_SECTION_IDS, type CaseStudyCopy } from './content.contracts';
import { SITE_CONTENT_DATA } from './site-content';
const DEVICE_IN_HEADING = /arbeitsplatz|workstation|gerät(?:e|esupport)?|device support|endgerät/i;
function caseBlob(study: CaseStudyCopy): string {
return [
study.period,
study.domain,
study.headline,
study.summary,
study.responsibility,
study.situation,
...study.approach,
...study.outcome,
study.teaser,
study.externalUrl ?? '',
study.externalLabel ?? '',
].join('\n');
}
describe('service-led content contracts', () => {
it('keeps service sections infrastructure-first in the fixed order', () => {
expect(SERVICE_SECTION_IDS).toEqual([
'infrastructure-network',
'platform-operations',
'software-data',
'ai-workflows',
]);
for (const locale of APP_LOCALES) {
const ids = SITE_CONTENT_DATA[locale].services.serviceSections.map((section) => section.id);
expect(ids, locale).toEqual([...SERVICE_SECTION_IDS]);
expect(SITE_CONTENT_DATA[locale].home.serviceAreas.map((area) => area.id)).toEqual([
...SERVICE_SECTION_IDS,
]);
}
});
it('keeps Bannier and Elbwärme dates, scopes, externals and the support-versus-development line', () => {
const de = SITE_CONTENT_DATA.de.cases;
const en = SITE_CONTENT_DATA.en.cases;
expect(de.bannier.period).toBe('seit etwa 2018, laufend');
expect(en.bannier.period).toBe('since roughly 2018, ongoing');
expect(de.elbwaerme.period).toBe('seit 11/2025, laufend');
expect(en.elbwaerme.period).toBe('since November 2025, ongoing');
expect(de.bannier.externalUrl).toBe('https://fahrschule-stendal.de');
expect(en.bannier.externalUrl).toBe('https://fahrschule-stendal.de');
expect(de.elbwaerme.externalUrl).toBe('https://elbwaerme.de');
expect(en.elbwaerme.externalUrl).toBe('https://elbwaerme.de');
expect(de.bannier.externalLabel).toBe('fahrschule-stendal.de öffnen');
expect(en.bannier.externalLabel).toBe('Visit fahrschule-stendal.de');
expect(de.elbwaerme.externalLabel).toBe('elbwaerme.de öffnen');
expect(en.elbwaerme.externalLabel).toBe('Visit elbwaerme.de');
expect(caseBlob(de.bannier)).toMatch(/nicht entwickelt/);
expect(caseBlob(de.bannier)).toMatch(/betreue und betreibe/);
expect(caseBlob(en.bannier)).toMatch(/did not develop/);
expect(caseBlob(en.bannier)).toMatch(/support and operate/);
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'bannier' && id !== 'elbwaerme')) {
expect(Object.hasOwn(de[caseId], 'externalUrl')).toBe(false);
expect(Object.hasOwn(en[caseId], 'externalUrl')).toBe(false);
expect(Object.hasOwn(de[caseId], 'externalLabel')).toBe(false);
expect(Object.hasOwn(en[caseId], 'externalLabel')).toBe(false);
}
});
it('states the personal contact, two-person team, organisational support and specialist network', () => {
const de = SITE_CONTENT_DATA.de;
const en = SITE_CONTENT_DATA.en;
expect(de.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain(
'Der Kern sind zwei Personen zwischen Tangermünde und Magdeburg',
);
expect(de.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain(
'ausgesuchtes Netzwerk',
);
expect(de.services.sections[1].body?.join('\n')).toContain('technischer Ansprechpartner');
expect(de.services.sections[1].body?.join('\n')).toContain('Kaufmännisches und Organisation');
expect(en.services.sections[1].body?.join('\n')).toContain('technical point of contact');
expect(en.services.sections[1].body?.join('\n')).toContain('two people');
expect(en.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain(
'two people',
);
expect(
en.pages.about.sections
.find((section) => section.id === 'contact-and-team')
?.body?.join('\n'),
).toMatch(/curated/);
expect(en.pitch.profile.join('\n')).toContain('curated network of specialists');
expect(de.pitch.profile.join('\n')).toContain('ausgesuchtes Fachnetzwerk');
});
it('keeps device and workstation wording out of headings, nav labels and offering titles', () => {
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
const headings = [
...site.home.serviceAreas.map((area) => area.title),
...site.services.serviceSections.map((section) => section.title),
...site.services.serviceSections.flatMap((section) =>
section.offerings.map((offering) => offering.title),
),
...site.services.sections.map((section) => section.headline),
];
for (const heading of headings) {
expect(DEVICE_IN_HEADING.test(heading), `${locale}: ${heading}`).toBe(false);
}
}
});
});

View File

@@ -7,7 +7,10 @@ import { prerenderablePaths, routePath } from '../routing/route-paths';
import { absoluteUrl } from './route-metadata';
const PUBLIC = join(process.cwd(), 'public');
const EXCLUDED = /HUP|BitWiz|Cybertrading/;
const EXCLUDED = new RegExp(
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`,
'i',
);
function readPublic(name: 'robots.txt' | 'sitemap.xml' | 'llms.txt'): string {
return readFileSync(join(PUBLIC, name), 'utf8');
@@ -119,14 +122,6 @@ describe('crawl assets', () => {
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) => [
@@ -166,6 +161,10 @@ describe('crawl assets', () => {
[],
);
expect(EXCLUDED.test(text), `${name} contains an excluded station name`).toBe(false);
expect(text).not.toContain('/leistungen/software');
expect(text).not.toContain('/en/services/software');
expect(text).not.toContain('/leistungen/ai-integration');
expect(text).not.toContain('/en/services/ai-integration');
}
});
});

View File

@@ -20,13 +20,7 @@ const FORBIDDEN_FIELDS = [
'numberOfEmployees',
] as const;
const SERVICE_ROUTES: readonly RouteId[] = [
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
];
const SERVICE_ROUTES: readonly RouteId[] = ['services'];
function collectKeys(value: unknown, keys = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
@@ -63,15 +57,20 @@ describe('JSON-LD builders', () => {
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('services', locale), `${locale}.services`).toEqual([
'Service',
'Service',
'Service',
'Service',
]);
expect(graphTypes('projects', locale)).toEqual([
'CreativeWork',
'CreativeWork',
'CreativeWork',
'CreativeWork',
'CreativeWork',
'CreativeWork',
]);
for (const routeId of ROUTE_IDS.filter(
@@ -105,7 +104,12 @@ describe('JSON-LD builders', () => {
}
const serialized = serializeJsonLd(graph);
expect(serialized).not.toMatch(/HUP|BitWiz|Cybertrading/);
expect(serialized).not.toMatch(
new RegExp(
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`,
'i',
),
);
}
}
});

View File

@@ -68,8 +68,8 @@ describe('page rendering, semantics and accessibility', () => {
'/en',
'/pitch',
'/en/pitch',
'/leistungen/software',
'/en/services/software',
'/leistungen',
'/en/services',
'/projekte',
'/en/projects',
'/missing-route',
@@ -96,8 +96,8 @@ describe('page rendering, semantics and accessibility', () => {
'/en',
'/projekte',
'/en/projects',
'/leistungen/ai-integration',
'/en/services/ai-integration',
'/leistungen',
'/en/services',
'/kontakt',
'/en/contact',
'/stack',
@@ -139,6 +139,37 @@ describe('page rendering, semantics and accessibility', () => {
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
});
it('renders six cases with Bannier and Elbwärme externals and no excluded station names', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
const excluded = new RegExp(
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`,
'i',
);
for (const path of ['/projekte', '/en/projects']) {
await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement;
const ids = [...root.querySelectorAll('article.case-study')].map((article) => article.id);
expect(ids).toEqual([...CASE_STUDY_IDS]);
expect(excluded.test(root.textContent ?? '')).toBe(false);
const bannier = root.querySelector('#bannier a.external-link') as HTMLAnchorElement | null;
const elbwaerme = root.querySelector(
'#elbwaerme a.external-link',
) as HTMLAnchorElement | null;
expect(bannier?.getAttribute('href')).toBe('https://fahrschule-stendal.de');
expect(elbwaerme?.getAttribute('href')).toBe('https://elbwaerme.de');
expect(bannier?.getAttribute('rel')).toBe('noopener noreferrer');
expect(elbwaerme?.getAttribute('rel')).toBe('noopener noreferrer');
expect(bannier?.getAttribute('target')).toBe('_blank');
expect(root.querySelectorAll('a.external-link')).toHaveLength(2);
}
});
it('places the Systems Map on the services overview', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
@@ -197,7 +228,7 @@ describe('page rendering, semantics and accessibility', () => {
}
});
it('sends the software service case CTA to the innofocus project anchor', async () => {
it('exposes the four service section anchors and unique ids on the services page', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
@@ -205,13 +236,22 @@ describe('page rendering, semantics and accessibility', () => {
const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) {
await harness.navigateByUrl(routePath('servicesSoftware', locale));
await harness.navigateByUrl(routePath('services', locale));
const root = harness.routeNativeElement as HTMLElement;
const expected = `${routePath('projects', locale)}#innofocus`;
const hrefs = [...root.querySelectorAll('a')].map(
(anchor) => anchor.getAttribute('href') ?? '',
);
expect(hrefs.some((href) => href.endsWith(expected))).toBe(true);
expect(root.querySelectorAll('h1')).toHaveLength(1);
const ids = [
'infrastructure-network',
'platform-operations',
'software-data',
'ai-workflows',
];
for (const id of ids) {
expect(root.querySelectorAll(`#${id}`)).toHaveLength(1);
}
const allIds = [...root.querySelectorAll('[id]')].map((element) => element.id);
expect(new Set(allIds).size, `${locale} duplicate ids: ${allIds}`).toBe(allIds.length);
assertNoDanglingReferences(root);
}
});
});

View File

@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = process.cwd();
function read(path: string): string {
return readFileSync(join(ROOT, path), 'utf8');
}
describe('interactive lift primitive', () => {
it('lives in the global stylesheet, hover-only, and drops transform under reduced motion', () => {
const css = read('src/styles.scss');
expect(css).toContain('.interactive-lift');
expect(css).toMatch(/@media \(hover: hover\) and \(pointer: fine\)/);
expect(css).toMatch(/prefers-reduced-motion:\s*reduce/);
const reduceBlock = css.slice(css.indexOf('prefers-reduced-motion'));
expect(reduceBlock).toMatch(/\.interactive-lift\s*\{[^}]*transform:\s*none/);
expect(reduceBlock).toMatch(/transition:\s*none/);
});
it('is applied to clickable case cards and home service links, not to prose sections', () => {
expect(read('src/app/shared/case-card/case-card.html')).toContain('interactive-lift');
expect(read('src/app/features/home/home.html')).toContain('home-service-card interactive-lift');
expect(read('src/app/shared/content-section/content-section.html')).not.toContain(
'interactive-lift',
);
expect(read('src/app/shared/page-hero/page-hero.html')).not.toContain('interactive-lift');
expect(read('src/app/components/pages/skills/skill-card/skill-card.html')).not.toContain(
'interactive-lift',
);
});
});

View File

@@ -1,15 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const APP_SCSS = readFileSync(join(process.cwd(), 'src/app/app.scss'), 'utf8');
describe('primary nav submenu surface', () => {
it('uses the opaque menu token and not the glass surface', () => {
const submenu = APP_SCSS.slice(APP_SCSS.indexOf('.primary-nav > li > .submenu'));
expect(submenu).toMatch(/background:\s*var\(--color-surface-menu\)/);
expect(submenu).not.toMatch(/var\(--surface-glass\)/);
expect(submenu).toMatch(/box-shadow:/);
expect(submenu).toMatch(/border-radius:/);
});
});

View File

@@ -0,0 +1,52 @@
import { LEGACY_SERVICE_REDIRECTS, normalizeRequestPath } from './legacy-service-redirects';
import { prerenderablePaths } from './app/core/routing/route-paths';
describe('legacy service redirects', () => {
it('maps exactly eight retired service URLs onto services fragments', () => {
expect(Object.keys(LEGACY_SERVICE_REDIRECTS)).toEqual([
'/leistungen/software',
'/leistungen/hardware-netzwerk',
'/leistungen/cluster',
'/leistungen/ai-integration',
'/en/services/software',
'/en/services/hardware-network',
'/en/services/clusters',
'/en/services/ai-integration',
]);
expect(LEGACY_SERVICE_REDIRECTS['/leistungen/software']).toBe('/leistungen#software-data');
expect(LEGACY_SERVICE_REDIRECTS['/leistungen/hardware-netzwerk']).toBe(
'/leistungen#infrastructure-network',
);
expect(LEGACY_SERVICE_REDIRECTS['/leistungen/cluster']).toBe('/leistungen#platform-operations');
expect(LEGACY_SERVICE_REDIRECTS['/leistungen/ai-integration']).toBe('/leistungen#ai-workflows');
expect(LEGACY_SERVICE_REDIRECTS['/en/services/software']).toBe('/en/services#software-data');
expect(LEGACY_SERVICE_REDIRECTS['/en/services/hardware-network']).toBe(
'/en/services#infrastructure-network',
);
expect(LEGACY_SERVICE_REDIRECTS['/en/services/clusters']).toBe(
'/en/services#platform-operations',
);
expect(LEGACY_SERVICE_REDIRECTS['/en/services/ai-integration']).toBe(
'/en/services#ai-workflows',
);
});
it('treats an optional trailing slash as the same path', () => {
expect(normalizeRequestPath('/leistungen/software/')).toBe('/leistungen/software');
expect(normalizeRequestPath('/en/services/ai-integration/')).toBe(
'/en/services/ai-integration',
);
expect(normalizeRequestPath('/')).toBe('/');
expect(LEGACY_SERVICE_REDIRECTS[normalizeRequestPath('/leistungen/cluster/')]).toBe(
'/leistungen#platform-operations',
);
});
it('keeps the eight legacy paths out of prerenderablePaths', () => {
const paths = prerenderablePaths();
expect(paths).toHaveLength(18);
for (const from of Object.keys(LEGACY_SERVICE_REDIRECTS)) {
expect(paths, from).not.toContain(from);
}
});
});