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 ## 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. - 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 - 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`
@@ -128,7 +128,7 @@ German and English are written idiomatically per language, never machine-transla
- 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 - 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 - 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` - `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 - 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 | | 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 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 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). Contributor and agent conventions are in [AGENTS.md](./AGENTS.md).

View File

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

View File

@@ -40,12 +40,12 @@ test.describe('fragment scrolling', () => {
await page.setViewportSize({ width, height: VIEWPORT_HEIGHT }); await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
for (const locale of APP_LOCALES) { for (const locale of APP_LOCALES) {
const path = `${pagePath('projects', locale)}#myspa`; const path = `${pagePath('projects', locale)}#bannier`;
const label = `${width}px ${locale}`; const label = `${width}px ${locale}`;
await page.goto(path, { waitUntil: 'networkidle' }); await page.goto(path, { waitUntil: 'networkidle' });
const header = page.locator('.site-header'); const header = page.locator('.site-header');
const heading = page.locator('#myspa h2'); const heading = page.locator('#bannier h2');
await expect(heading).toBeVisible(); await expect(heading).toBeVisible();
await waitForScrollSettle(page); 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, page,
}, testInfo) => { }, 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.setViewportSize({ width: 1024, height: 900 });
await page.goto('/'); await page.goto('/');
const servicesParent = page.locator( const services = page.locator(`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`);
`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`, await expect(services).toBeVisible();
); await expect(page.locator('.primary-nav .submenu')).toHaveCount(0);
const firstChild = page.locator( await expect(page.locator('.primary-nav a')).toHaveCount(5);
`.primary-nav > li > .submenu a[href="${pagePath('servicesSoftware', 'de')}"]`,
);
await servicesParent.focus(); await services.click();
await expect(firstChild).toBeVisible(); await page.waitForURL('**/leistungen');
await page.keyboard.press('Tab'); await expect(services).toHaveAttribute('aria-current', 'page');
await expect(firstChild).toBeFocused();
const headerBox = await page.locator('.site-header').boundingBox(); const overflow = await page.evaluate(
expect(headerBox, 'header while submenu is open').toBeTruthy(); () => document.documentElement.scrollWidth <= window.innerWidth + 1,
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+\)$/,
); );
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, page,
}) => { }) => {
await page.goto('/'); await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger'); const trigger = page.locator('.terminal-dock-trigger');
await trigger.focus();
await page.keyboard.press('Control+k');
const panel = page.locator('.terminal-dock-panel'); const panel = page.locator('.terminal-dock-panel');
await expect(panel).toBeVisible(); 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'); await expect(page.locator('.site')).not.toHaveAttribute('inert');
expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden'); 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 page.keyboard.press('Escape');
await expect(panel).toHaveCount(0); await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused(); await expect(trigger).toBeFocused();

View File

@@ -9,13 +9,13 @@ const CHECKED = [
pagePath('pitch', 'en'), pagePath('pitch', 'en'),
pagePath('projects', 'de'), pagePath('projects', 'de'),
pagePath('projects', 'en'), pagePath('projects', 'en'),
pagePath('servicesAi', 'de'), pagePath('services', 'de'),
pagePath('contact', 'de'), pagePath('contact', 'de'),
pagePath('imprint', 'en'), pagePath('imprint', 'en'),
'/missing-route', '/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.describe('layout and crawlability', () => {
test('does not overflow horizontally on checked routes', async ({ page }) => { 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 { expect, test, type Page } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content'; 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'; import { expectHead, jsonLdGraph, pagePath } from './helpers';
async function ensurePrimaryNavOpen(page: Page): Promise<void> { 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) { for (const locale of ['de', 'en'] as const) {
await expectHead(request, 'projects', locale); await expectHead(request, 'projects', locale);
await expectHead(request, 'servicesAi', locale); await expectHead(request, 'services', locale);
await expectHead(request, 'contact', locale); await expectHead(request, 'contact', locale);
await expectHead(request, 'imprint', locale); await expectHead(request, 'imprint', locale);
await expectHead(request, 'notFound', 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); 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 }) => { test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => {
const home = await request.get('/'); const home = await request.get('/');
expect(home.status(), 'GET /').toBe(200); 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 }) => { test('client navigation does not duplicate head tags', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await ensurePrimaryNavOpen(page); 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) => { const assertUnique = async (canonical: string, description: string) => {
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1); await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
@@ -97,23 +107,34 @@ test.describe('server-rendered metadata', () => {
); );
await ensurePrimaryNavOpen(page); await ensurePrimaryNavOpen(page);
const servicesParent = page.locator( await page.locator(`.primary-nav a[href="${pagePath('services', 'de')}"]`).click();
`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`, await page.waitForURL('**/leistungen');
);
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"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href', 'href',
'https://antoniolede.de/leistungen/ai-integration', 'https://antoniolede.de/leistungen',
); );
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1); 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 { expect, test } from '@playwright/test';
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
import { pagePath } from './helpers'; import { pagePath } from './helpers';
test.describe('Systems Map', () => { test.describe('Systems Map', () => {
test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => { test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => {
await page.goto(pagePath('services', 'de')); 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 }); await page.setViewportSize({ width, height: 900 });
const list = page.locator('.systems-map-list'); const list = page.locator('.systems-map-list');
await expect(list).toBeVisible(); await expect(list).toBeVisible();
@@ -59,8 +60,41 @@ test.describe('Systems Map', () => {
expect(hrefs.length).toBeGreaterThan(0); expect(hrefs.length).toBeGreaterThan(0);
for (const href of hrefs) { 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); 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 { expect, test } from '@playwright/test';
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy'; 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.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, page,
}, testInfo) => { }, testInfo) => {
const copy = SIGNATURE_COPY.de.terminal; const copy = SIGNATURE_COPY.de.terminal;
@@ -11,14 +14,17 @@ test.describe('terminal dock', () => {
const trigger = page.locator('.terminal-dock-trigger'); const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel'); const panel = page.locator('.terminal-dock-panel');
const input = page.locator('#terminal-dock-input'); 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 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(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); await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt);
if (testInfo.project.name === 'mobile') { if (testInfo.project.name === 'mobile') {
@@ -28,6 +34,7 @@ test.describe('terminal dock', () => {
} else { } else {
await maximize.click(); await maximize.click();
await expect(maximize).toHaveAttribute('aria-pressed', 'true'); await expect(maximize).toHaveAttribute('aria-pressed', 'true');
await expect(maximize).toHaveAttribute('aria-label', copy.restoreLabel);
await maximize.click(); await maximize.click();
await expect(maximize).toHaveAttribute('aria-pressed', 'false'); await expect(maximize).toHaveAttribute('aria-pressed', 'false');
} }
@@ -36,90 +43,88 @@ test.describe('terminal dock', () => {
await input.press('Enter'); await input.press('Enter');
await page.waitForURL('**/pitch'); await page.waitForURL('**/pitch');
await expect(page).toHaveURL(/\/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 input.press('ArrowUp');
await expect(input).toHaveValue('navigate pitch'); await expect(input).toHaveValue('navigate pitch');
await input.fill('history'); await input.fill('history');
await input.press('Enter'); await input.press('Enter');
await expect(log).toContainText(copy.historyIntro); await expect(session).toContainText(copy.historyIntro);
await expect(log).toContainText('navigate pitch'); await expect(session).toContainText('navigate pitch');
await input.fill('clear'); await input.fill('clear');
await input.press('Enter'); 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.fill('navigate p');
await input.press('Tab'); await input.press('Tab');
await expect(log).toContainText('pitch'); await expect(session).toContainText('pitch');
await expect(log).toContainText('projects'); await expect(session).toContainText('projects');
await input.fill('navigate nowhere'); await input.fill('navigate nowhere');
await input.press('Enter'); await input.press('Enter');
await expect(log).toContainText(copy.validTargetsLabel); await expect(session).toContainText(copy.validTargetsLabel);
await expect(log).toContainText('pitch'); await expect(session).toContainText('pitch');
await input.fill('nav'); await input.fill('nav');
await input.press('Tab'); await input.press('Tab');
await expect(input).toHaveValue('navigate'); 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 ({ test('collapse control hides the panel and returns focus to the trigger', async ({ page }) => {
page, const copy = SIGNATURE_COPY.de.terminal;
}, testInfo) => {
test.skip(
testInfo.project.name === 'mobile',
'Below md the trigger is hidden while the panel is open.',
);
await page.goto('/'); await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger'); const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel'); const panel = page.locator('.terminal-dock-panel');
await trigger.click();
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await expect(trigger).toBeVisible(); await expect(trigger).toBeHidden();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await trigger.click(); const collapse = page.getByRole('button', { name: copy.collapseLabel });
await collapse.click();
await expect(panel).toHaveCount(0); await expect(panel).toHaveCount(0);
await expect(trigger).toHaveAttribute('aria-expanded', 'false'); await expect(trigger).toBeVisible();
await expect(trigger).toBeFocused(); 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; const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/'); await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const input = page.locator('#terminal-dock-input'); 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(session).toBeVisible();
await expect(log).toBeVisible();
for (let index = 0; index < 8; index += 1) { for (let index = 0; index < 8; index += 1) {
await input.fill('help'); await input.fill('help');
await input.press('Enter'); 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 await expect
.poll(async () => .poll(async () =>
log.evaluate((element) => { session.evaluate((element) => {
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2; return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
}), }),
) )
.toBe(true); .toBe(true);
const last = log.locator('p').last(); const form = session.locator('form');
const logBox = await log.boundingBox(); const sessionBox = await session.boundingBox();
const lastBox = await last.boundingBox(); const formBox = await form.boundingBox();
expect(logBox && lastBox).toBeTruthy(); expect(sessionBox && formBox).toBeTruthy();
expect(lastBox!.y).toBeGreaterThanOrEqual(logBox!.y - 1); expect(formBox!.y).toBeGreaterThanOrEqual(sessionBox!.y - 1);
expect(lastBox!.y + lastBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height + 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 ({ 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 trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel'); const panel = page.locator('.terminal-dock-panel');
await trigger.click();
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
const collapse = page.getByRole('button', { name: copy.collapseLabel }); const collapse = page.getByRole('button', { name: copy.collapseLabel });
@@ -142,4 +145,13 @@ test.describe('terminal dock', () => {
await expect(panel).toHaveCount(0); await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused(); 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); expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
const prerendered = serverRoutes.filter((route) => route.path !== '**'); const prerendered = serverRoutes.filter((route) => route.path !== '**');
expect(prerendered).toHaveLength(26); expect(prerendered).toHaveLength(18);
expect(prerendered.map((route) => route.path)).toEqual( expect(prerendered.map((route) => route.path)).toEqual(
expect.arrayContaining(['pitch', 'en/pitch']), expect.arrayContaining(['pitch', 'en/pitch']),
); );

View File

@@ -18,7 +18,7 @@ function allMetrics(site: SiteContent): readonly MetricCopy[] {
} }
function allOfferings(site: SiteContent): readonly OfferingCopy[] { 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 { function caseText(caseStudy: CaseStudyCopy): string {
@@ -33,11 +33,18 @@ function caseText(caseStudy: CaseStudyCopy): string {
} }
describe('content claims and attribution', () => { 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) { for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale]; const site = SITE_CONTENT_DATA[locale];
expect(Object.keys(site.cases)).toEqual([...CASE_STUDY_IDS]); 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) => const matchesRuntime = (metric: MetricCopy) =>
/8/.test(metric.value) && /90/.test(metric.value); /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); 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); const hdiText = caseText(site.cases.hdi);
expect(hdiText).toMatch(/Azure AKS/); expect(hdiText).toMatch(/Azure AKS/);
expect(hdiText).toMatch(/Azure App Services/); 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', () => { it('keeps AI delivery claims limited to the two Rösterei tools', () => {
for (const locale of APP_LOCALES) { for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale]; const site = SITE_CONTENT_DATA[locale];
const aiOfferings = site.services.servicesAi.offerings; const sections = site.services.serviceSections;
const delivered = aiOfferings.filter((offering) => offering.status === 'delivered');
expect(delivered).toHaveLength(2); const ai = sections.find((section) => section.id === 'ai-workflows');
expect(delivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true); expect(ai, `${locale} ai-workflows section`).toBeTruthy();
const otherOfferings = allOfferings(site).filter((offering) => !delivered.includes(offering)); const aiDelivered = ai!.offerings.filter((offering) => offering.status === 'delivered');
expect(otherOfferings.every((offering) => offering.status === 'offer')).toBe(true); expect(aiDelivered).toHaveLength(2);
expect(aiDelivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true);
expect( 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}`), DELIVERED_OFFER_WORDING.test(`${offering.title} ${offering.body}`),
), ),
).toBe(false); ).toBe(false);

View File

@@ -40,13 +40,12 @@ describe('content completeness', () => {
expect(site.pages.home).toBe(site.home); expect(site.pages.home).toBe(site.home);
expect(site.pages.pitch).toBe(site.pitch); 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.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);
expect(site.pages.imprint).toBe(site.legal.imprint); expect(site.pages.imprint).toBe(site.legal.imprint);
expect(site.pages.privacy).toBe(site.legal.privacy); expect(site.pages.privacy).toBe(site.legal.privacy);
expect(site.pages.servicesAi).toBe(site.services.servicesAi);
for (const routeId of ROUTE_IDS) { for (const routeId of ROUTE_IDS) {
const page = site.pages[routeId]; 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'; import { FOOTER_NAV, PRIMARY_NAV } from '../navigation/navigation';
const EXCLUDED = new RegExp( 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', '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'; import { absoluteUrl } from './route-metadata';
const PUBLIC = join(process.cwd(), 'public'); 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 { function readPublic(name: 'robots.txt' | 'sitemap.xml' | 'llms.txt'): string {
return readFileSync(join(PUBLIC, name), 'utf8'); return readFileSync(join(PUBLIC, name), 'utf8');
@@ -119,14 +122,6 @@ describe('crawl assets', () => {
absoluteUrl(routePath('about', 'en')), absoluteUrl(routePath('about', 'en')),
absoluteUrl(routePath('services', 'de')), absoluteUrl(routePath('services', 'de')),
absoluteUrl(routePath('services', 'en')), 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', 'de')),
absoluteUrl(routePath('projects', 'en')), absoluteUrl(routePath('projects', 'en')),
...CASE_STUDY_IDS.flatMap((id) => [ ...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(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', 'numberOfEmployees',
] as const; ] as const;
const SERVICE_ROUTES: readonly RouteId[] = [ const SERVICE_ROUTES: readonly RouteId[] = ['services'];
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
];
function collectKeys(value: unknown, keys = new Set<string>()): Set<string> { function collectKeys(value: unknown, keys = new Set<string>()): Set<string> {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -63,15 +57,20 @@ describe('JSON-LD builders', () => {
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']); expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']); expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']);
for (const routeId of SERVICE_ROUTES) { expect(graphTypes('services', locale), `${locale}.services`).toEqual([
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']); 'Service',
} 'Service',
'Service',
'Service',
]);
expect(graphTypes('projects', locale)).toEqual([ expect(graphTypes('projects', locale)).toEqual([
'CreativeWork', 'CreativeWork',
'CreativeWork', 'CreativeWork',
'CreativeWork', 'CreativeWork',
'CreativeWork', 'CreativeWork',
'CreativeWork',
'CreativeWork',
]); ]);
for (const routeId of ROUTE_IDS.filter( for (const routeId of ROUTE_IDS.filter(
@@ -105,7 +104,12 @@ describe('JSON-LD builders', () => {
} }
const serialized = serializeJsonLd(graph); 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', '/en',
'/pitch', '/pitch',
'/en/pitch', '/en/pitch',
'/leistungen/software', '/leistungen',
'/en/services/software', '/en/services',
'/projekte', '/projekte',
'/en/projects', '/en/projects',
'/missing-route', '/missing-route',
@@ -96,8 +96,8 @@ describe('page rendering, semantics and accessibility', () => {
'/en', '/en',
'/projekte', '/projekte',
'/en/projects', '/en/projects',
'/leistungen/ai-integration', '/leistungen',
'/en/services/ai-integration', '/en/services',
'/kontakt', '/kontakt',
'/en/contact', '/en/contact',
'/stack', '/stack',
@@ -139,6 +139,37 @@ describe('page rendering, semantics and accessibility', () => {
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy(); 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 () => { it('places the Systems Map on the services overview', 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 }],
@@ -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({ TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }], 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(); const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) { 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 root = harness.routeNativeElement as HTMLElement;
const expected = `${routePath('projects', locale)}#innofocus`; expect(root.querySelectorAll('h1')).toHaveLength(1);
const hrefs = [...root.querySelectorAll('a')].map( const ids = [
(anchor) => anchor.getAttribute('href') ?? '', 'infrastructure-network',
); 'platform-operations',
expect(hrefs.some((href) => href.endsWith(expected))).toBe(true); '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);
}
});
});