10 Commits

Author SHA1 Message Date
4389f3dd3c Restore gist focus to the list and stop claiming the dialog is modal.
SVG nodes are not HTMLElements and sit in an aria-hidden figure, so close returns to the matching card. The gist stays non-modal: no aria-modal, no Tab trap, and destination or outside activation dismisses it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:43 +02:00
334b30145e Keep the terminal placeholder rule on the component stylesheet.
The global duplicate violated the component-owned selector rule; the surviving rule still pins subtle text at full opacity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:41 +02:00
0e1e48d876 Anchor About profile seniority to 2019 so the copy cannot age.
A counted duration would go stale; the approved wording keeps the same facts and lets the reader do the arithmetic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:39 +02:00
50851f4d26 Delete the unused ServicesOverviewPageCopy contract leftover.
The service-led migration already moved systems-map copy onto ServicesPageCopy, and no other dead ServicePage* types remain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:27 +02:00
0b74be69be Wrap the terminal stylesheet path so Prettier stays quiet.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:08:25 +02:00
7619205d91 Point Lighthouse at the consolidated Services pages instead of a deleted route.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:08:14 +02:00
8974b4c6fb Animate the terminal dock with transform only so entry never drops text contrast.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:08:14 +02:00
9bfb1dc71c Give the terminal placeholder a token color that meets contrast on the surface.
The browser default grey only showed up once the dock stayed open on 404.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:45:38 +02:00
bb9912daef Keep the default-open terminal inside the viewport and the map labels inside their shapes.
The dock prompt and placeholder now meet contrast on the raised surface, and service nodes use the same rect geometry as the cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:43:56 +02:00
a9ad7eb1a7 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>
2026-08-27 00:41:24 +02:00
31 changed files with 842 additions and 225 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();
@@ -42,7 +43,9 @@ test.describe('Systems Map', () => {
textBox.x + textBox.width <= shapeBox.x + shapeBox.width + 0.5 &&
textBox.y + textBox.height <= shapeBox.y + shapeBox.height + 0.5;
return fits ? [] : [node.getAttribute('aria-label') ?? 'unnamed node'];
return fits
? []
: [node.getAttribute('data-id') ?? node.getAttribute('aria-label') ?? 'unnamed node'];
});
});
@@ -59,8 +62,94 @@ 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('SVG node gist dialog restores focus after Escape and the close button', async ({
page,
}) => {
const copy = SIGNATURE_COPY.de.systemsMap;
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 1440, height: 900 });
const svgNode = page.locator('.systems-map-figure .systems-map-node').first();
const listNode = page.locator('.systems-map-cards a').first();
await expect(page.locator('.systems-map-figure')).toBeVisible();
await svgNode.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).not.toHaveAttribute('aria-modal');
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(listNode).toBeFocused();
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false);
await svgNode.click();
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: copy.closeLabel }).click();
await expect(dialog).toHaveCount(0);
await expect(listNode).toBeFocused();
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false);
});
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(dialog).not.toHaveAttribute('aria-modal');
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('destination and outside activation dismiss the gist and restore focus', async ({
page,
}) => {
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 390, height: 844 });
const first = page.locator('.systems-map-cards a').first();
await first.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('link').click();
await expect(dialog).toHaveCount(0);
await expect(page).toHaveURL(/#infrastructure-network/);
await expect(first).toBeFocused();
await first.click();
await expect(dialog).toBeVisible();
await page.locator('.systems-map-heading').click();
await expect(dialog).toHaveCount(0);
await expect(first).toBeFocused();
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false);
});
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,89 @@ 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).toContainText(copy.clearedMessage);
await expect(session.locator('p')).toHaveCount(1);
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 +136,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 +146,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

@@ -8,7 +8,8 @@
"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/leistungen",
"http://127.0.0.1:4000/en/services",
"http://127.0.0.1:4000/en/projects"
],
"numberOfRuns": 3,

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,144 @@
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 About profile seniority anchored to 2019 instead of a computed duration', () => {
const duration = /\b(sieben|seven|\d+)\s+(Jahre|Jahren|years)\b/i;
for (const locale of APP_LOCALES) {
const about = SITE_CONTENT_DATA[locale].pages.about;
const profile = about.sections.find((section) => section.id === 'profile');
const blob = [
about.title,
about.description,
about.hero.headline,
about.hero.proof ?? '',
about.hero.playfulLine ?? '',
...(about.hero.body ?? []),
...about.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
...about.ctas.map((cta) => cta.label),
].join('\n');
expect(profile?.body, `${locale} profile body`).toHaveLength(2);
expect(blob, locale).not.toMatch(duration);
}
expect(SITE_CONTENT_DATA.de.pages.about.sections[0].body?.[0]).toBe(
'Berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen seit 2019. Die Arbeit umfasst Fullstack-Entwicklung, DevOps, direkten Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
);
expect(SITE_CONTENT_DATA.en.pages.about.sections[0].body?.[0]).toBe(
'Professional work on building and operating web applications goes back to 2019. It covers fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
);
});
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

@@ -169,10 +169,6 @@ export interface PitchPageCopy extends PageCopy {
readonly stackHeading: string;
}
export interface ServicesOverviewPageCopy extends PageCopy {
readonly systemsMap: { readonly heading: string; readonly intro: string };
}
export type ContactFieldId =
'name' | 'email' | 'company' | 'projectType' | 'situation' | 'timeframe';

View File

@@ -106,7 +106,7 @@ export const ABOUT_DE: PageCopy = {
id: 'profile',
headline: 'Berufliches Profil',
body: [
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Die Arbeit umfasst Fullstack-Entwicklung, DevOps, direkten Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen seit 2019. Die Arbeit umfasst Fullstack-Entwicklung, DevOps, direkten Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Heimspiel ist Java (8/11/17/21, Maven, Spring Boot, Spring Data JPA, WebFlux, JUnit). Dazu kommen C# / .NET Core / .NET 8 (ASP.NET Core, Entity Framework, xUnit) und Angular (TypeScript, RxJS-zeitige SPA-Arbeit, Ngrx, SCSS).',
],
},

View File

@@ -106,7 +106,7 @@ export const ABOUT_EN: PageCopy = {
id: 'profile',
headline: 'Professional profile',
body: [
'About seven years of professional experience building and operating web applications. The work covers fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
'Professional work on building and operating web applications goes back to 2019. It covers fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
'Home ground is Java (8/11/17/21, Maven, Spring Boot, Spring Data JPA, WebFlux, JUnit). Alongside that sit C# / .NET Core / .NET 8 (ASP.NET Core, Entity Framework, xUnit) and Angular (TypeScript, RxJS-era SPA work, Ngrx, SCSS).',
],
},

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

@@ -2,6 +2,7 @@
padding: var(--space-6);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
overflow-wrap: anywhere;
}
h2 {

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

@@ -32,23 +32,20 @@
tabindex="-1"
class="systems-map-node"
[attr.data-kind]="node.kind"
[attr.data-id]="node.id"
[class.is-connected]="isConnectedNode(node.id)"
[class.is-dimmed]="isDimmedNode(node.id)"
(click)="onNodeActivate($event, node)"
(mouseenter)="onNodeEnter(node.id)"
(mouseleave)="onNodeLeave(node.id)"
>
@if (node.kind === 'ai' || node.kind === 'platform') {
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" />
} @else {
<rect
[attr.x]="node.x - node.shapeWidth / 2"
[attr.y]="node.y - node.shapeHeight / 2"
[attr.width]="node.shapeWidth"
[attr.height]="node.shapeHeight"
rx="8"
/>
}
<rect
[attr.x]="node.x - node.shapeWidth / 2"
[attr.y]="node.y - node.shapeHeight / 2"
[attr.width]="node.shapeWidth"
[attr.height]="node.shapeHeight"
rx="8"
/>
<text [attr.x]="node.x" [attr.y]="node.y">
@for (line of node.labelLines; track $index) {
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
@@ -93,11 +90,11 @@
#gistDialog
class="systems-map-dialog"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="dialogTitleId"
[attr.aria-describedby]="dialogDescId"
tabindex="-1"
(keydown)="onDialogKeydown($event)"
(focusout)="onDialogFocusOut($event)"
>
<div class="systems-map-dialog-bar cluster">
<h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3>
@@ -113,7 +110,9 @@
</div>
<p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</p>
<p class="systems-map-dialog-relation">{{ node.relationship }}</p>
<a [routerLink]="node.link" [fragment]="node.fragment">{{ node.linkLabel }}</a>
<a [routerLink]="node.link" [fragment]="node.fragment" (click)="closeDialog()">{{
node.linkLabel
}}</a>
</div>
}
</section>

View File

@@ -64,7 +64,6 @@
color: var(--color-text);
}
.systems-map-node circle,
.systems-map-node rect {
fill: var(--color-surface-overlay);
stroke: var(--color-accent);
@@ -79,7 +78,7 @@
stroke: var(--color-accent-soft);
}
.systems-map-node[data-kind='ai'] circle,
.systems-map-node[data-kind='ai'] rect,
.systems-map-legend [data-kind='ai'] {
stroke: var(--color-accent-strong);
}
@@ -90,7 +89,6 @@
text-anchor: middle;
}
.systems-map-node.is-connected circle,
.systems-map-node.is-connected rect,
.systems-map-edge.is-connected {
stroke: var(--color-accent-strong);
@@ -131,7 +129,6 @@
outline-offset: var(--focus-ring-offset);
}
.systems-map-node:focus-visible circle,
.systems-map-node:focus-visible rect {
stroke: var(--focus-ring-color);
stroke-width: 3;
@@ -187,7 +184,6 @@
}
@media (hover: hover) and (pointer: fine) {
.systems-map-node:hover circle,
.systems-map-node:hover rect {
stroke: var(--color-accent-strong);
}
@@ -199,7 +195,6 @@
@media (prefers-reduced-motion: reduce) {
.systems-map-edge,
.systems-map-node circle,
.systems-map-node rect {
transition: none;
}

View File

@@ -107,7 +107,7 @@ describe('SystemsMap', () => {
const dialog = compiled.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
expect(dialog?.getAttribute('aria-modal')).toBe('true');
expect(dialog?.hasAttribute('aria-modal')).toBe(false);
expect(dialog?.textContent).toContain(first.label);
expect(dialog?.textContent).toContain(first.gist);
expect(dialog?.textContent).toContain(first.relationship);
@@ -141,6 +141,36 @@ describe('SystemsMap', () => {
expect(navigate).not.toHaveBeenCalled();
});
it('restores focus from an SVG node to the matching list anchor, not an aria-hidden target', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const svgAnchor = compiled.querySelector('svg a');
const list = listAnchor(compiled);
expect(svgAnchor).toBeTruthy();
expect(svgAnchor instanceof SVGElement).toBe(true);
expect(svgAnchor instanceof HTMLElement).toBe(false);
expect(list.getAttribute('tabindex')).not.toBe('-1');
expect(list.closest('[aria-hidden="true"]')).toBeNull();
svgAnchor?.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
await flush(fixture);
const dialog = compiled.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog).toBeTruthy();
expect(dialog.contains(document.activeElement)).toBe(true);
compiled.querySelector<HTMLButtonElement>('.systems-map-dialog-close')?.click();
await flush(fixture);
const restored = document.activeElement as HTMLElement | null;
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
expect(restored).toBe(list);
expect(restored?.isConnected).toBe(true);
expect(restored?.closest('[aria-hidden="true"]')).toBeNull();
expect(restored).not.toBe(document.body);
});
it('moves focus into the dialog and restores it to the activating node on close', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
@@ -166,6 +196,91 @@ describe('SystemsMap', () => {
expect(copy.closeLabel.length).toBeGreaterThan(0);
});
it('closes the non-modal gist from the destination link, outside activation and Escape', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
async function openFromList(): Promise<HTMLAnchorElement> {
const anchor = listAnchor(compiled);
anchor.focus();
anchor.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
await flush(fixture);
const dialog = compiled.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
expect(dialog?.hasAttribute('aria-modal')).toBe(false);
return anchor;
}
const destinationOpener = await openFromList();
compiled.querySelector<HTMLAnchorElement>('[role="dialog"] a')?.click();
await flush(fixture);
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(destinationOpener);
const outsideOpener = await openFromList();
compiled
.querySelector('.systems-map-heading')
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }));
await flush(fixture);
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(outsideOpener);
const escapeOpener = await openFromList();
compiled
.querySelector<HTMLButtonElement>('.systems-map-dialog-close')
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(escapeOpener);
});
it('lets Tab leave the gist and closes it without cycling focus', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const opener = listAnchor(compiled);
const next = listAnchor(compiled, 1);
opener.focus();
opener.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
await flush(fixture);
const dialog = compiled.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog).toBeTruthy();
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
const prevent = vi.spyOn(tab, 'preventDefault');
dialog.dispatchEvent(tab);
await flush(fixture);
expect(prevent).not.toHaveBeenCalled();
expect(compiled.querySelector('[role="dialog"]')).toBeTruthy();
dialog.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: next }));
await flush(fixture);
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).not.toBe(opener);
});
it('removes the document dismiss listeners on destroy', async () => {
const add = vi.spyOn(document, 'addEventListener');
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
listAnchor(compiled).dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
await flush(fixture);
const pointer = add.mock.calls.find((call) => call[0] === 'pointerdown');
const keydown = add.mock.calls.find((call) => call[0] === 'keydown');
expect(pointer).toBeTruthy();
expect(keydown).toBeTruthy();
const remove = vi.spyOn(document, 'removeEventListener');
fixture.destroy();
expect(remove).toHaveBeenCalledWith('pointerdown', pointer?.[1], true);
expect(remove).toHaveBeenCalledWith('keydown', keydown?.[1]);
});
it('keeps instance-scoped ids unique when two maps are rendered', async () => {
await configure();
const first = TestBed.createComponent(SystemsMap);

View File

@@ -4,6 +4,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
inject,
Injector,
@@ -75,10 +76,13 @@ export class SystemsMap {
private readonly injector = inject(Injector);
private readonly navigation = inject(NavigationService);
private readonly localeService = inject(LocaleService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isBrowserPlatform();
private readonly instanceId = systemsMapInstanceId++;
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('gistDialog');
private opener: HTMLElement | null = null;
private dismissListening = false;
protected readonly headingId = `systems-map-heading-${this.instanceId}`;
protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`;
@@ -145,13 +149,17 @@ export class SystemsMap {
return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : '';
});
constructor() {
this.destroyRef.onDestroy(() => this.unbindDismissListeners());
}
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
this.openDialog(node.id, event.currentTarget as HTMLElement);
this.openDialog(node.id);
}
protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void {
@@ -160,7 +168,7 @@ export class SystemsMap {
}
event.preventDefault();
this.openDialog(node.id, event.currentTarget as HTMLElement);
this.openDialog(node.id);
}
protected onNodeEnter(id: SystemsMapNodeId): void {
@@ -174,17 +182,19 @@ export class SystemsMap {
}
protected closeDialog(): void {
this.openNodeId.set(null);
const opener = this.opener;
const wasOpen = this.openNodeId() !== null;
this.unbindDismissListeners();
this.openNodeId.set(null);
this.opener = null;
if (!this.isBrowser || !(opener instanceof HTMLElement)) {
if (!wasOpen || !this.isBrowser || !this.isRestorableOpener(opener)) {
return;
}
afterNextRender(
() => {
if (opener.isConnected) {
if (this.isRestorableOpener(opener)) {
opener.focus();
}
},
@@ -192,39 +202,32 @@ export class SystemsMap {
);
}
/**
* Escape dismisses the non-modal gist. Tab is not trapped: a cycle
* would claim modal behaviour the dialog does not have. Leaving via
* Tab closes the gist and leaves focus where it moved.
*/
protected onDialogKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
this.closeDialog();
return;
}
}
if (event.key !== 'Tab') {
protected onDialogFocusOut(event: FocusEvent): void {
if (!this.openNodeId()) {
return;
}
const dialog = this.dialogRef()?.nativeElement;
if (!dialog) {
const next = event.relatedTarget;
if (!(next instanceof Node) || dialog?.contains(next)) {
return;
}
const focusable = this.focusableIn(dialog);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = this.document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
this.unbindDismissListeners();
this.openNodeId.set(null);
this.opener = null;
}
protected isConnectedNode(id: SystemsMapNodeId): boolean {
@@ -247,9 +250,10 @@ export class SystemsMap {
return !!active && edge.from !== active && edge.to !== active;
}
private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void {
this.opener = opener;
private openDialog(id: SystemsMapNodeId): void {
this.opener = this.listAnchorFor(id);
this.openNodeId.set(id);
this.bindDismissListeners();
afterNextRender(
() => {
@@ -261,6 +265,75 @@ export class SystemsMap {
);
}
/**
* Restore to the list-fallback anchor for this node. The SVG figure is
* aria-hidden and its nodes are tabindex="-1"; focusing that subtree
* would drop the user into hidden content. The card list is the single
* intended keyboard target and stays rendered at every viewport.
*/
private listAnchorFor(id: SystemsMapNodeId): HTMLElement | null {
const index = this.nodes().findIndex((node) => node.id === id);
if (index < 0) {
return null;
}
return (
this.host.nativeElement
.querySelectorAll<HTMLAnchorElement>('.systems-map-cards a')
.item(index) ?? null
);
}
private isRestorableOpener(opener: HTMLElement | null): opener is HTMLElement {
return (
!!opener &&
opener.isConnected &&
opener.closest('[aria-hidden="true"]') === null &&
opener.getAttribute('tabindex') !== '-1'
);
}
private bindDismissListeners(): void {
if (!this.isBrowser || this.dismissListening) {
return;
}
this.document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
this.document.addEventListener('keydown', this.onDocumentKeydown);
this.dismissListening = true;
}
private unbindDismissListeners(): void {
if (!this.dismissListening) {
return;
}
this.document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
this.document.removeEventListener('keydown', this.onDocumentKeydown);
this.dismissListening = false;
}
private readonly onDocumentPointerDown = (event: Event): void => {
if (!this.openNodeId()) {
return;
}
const dialog = this.dialogRef()?.nativeElement;
const target = event.target;
if (!(target instanceof Node) || !dialog || dialog.contains(target)) {
return;
}
this.closeDialog();
};
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && this.openNodeId()) {
event.preventDefault();
this.closeDialog();
}
};
private focusableIn(root: HTMLElement | undefined): HTMLElement[] {
if (!root) {
return [];
@@ -295,7 +368,7 @@ export class SystemsMap {
labelLines,
summary,
gist,
shapeWidth: Math.max(140, longest * 8 + 24),
shapeWidth: Math.max(160, longest * 9 + 32),
shapeHeight: multiline ? 52 : 40,
textStartDy: multiline ? -6 : 4,
href: nodeHref(node, locale),

View File

@@ -12,6 +12,7 @@
display: grid;
justify-items: end;
gap: var(--space-2);
max-width: 100%;
}
.terminal-dock-trigger {
@@ -41,6 +42,8 @@
grid-template-rows: auto 1fr;
gap: var(--space-3);
width: 100%;
min-width: 0;
max-width: 100%;
max-height: min(70vh, 32rem);
padding: var(--space-4);
border: 1px solid var(--surface-glass-border);
@@ -101,11 +104,13 @@
.terminal-dock-session {
overflow: auto;
min-width: 0;
min-height: 8rem;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-text-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.terminal-dock-session .echo {
@@ -114,18 +119,22 @@
.terminal-dock-form {
display: grid;
grid-template-columns: auto 1fr;
grid-template-columns: minmax(0, auto) minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.terminal-dock-prompt {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-accent-cool);
color: var(--color-text);
overflow-wrap: anywhere;
}
.terminal-dock-form input {
min-width: 0;
width: 100%;
min-height: 2.75rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--surface-glass-border);
@@ -136,6 +145,12 @@
font-size: var(--text-sm);
}
.terminal-dock-form input::placeholder,
.terminal-dock-form input::-webkit-input-placeholder {
color: var(--color-text-subtle);
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.terminal-dock-trigger:hover,
.terminal-dock-control:hover {
@@ -145,12 +160,10 @@
@keyframes terminal-dock-in {
from {
opacity: 0;
transform: translateY(0.4rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DOCUMENT } from '@angular/common';
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -475,3 +478,37 @@ describe('TerminalDock', () => {
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
});
});
describe('terminal dock stylesheet', () => {
const css = readFileSync(
join(process.cwd(), 'src/app/shared/terminal/terminal-dock.scss'),
'utf8',
);
it('does not fade a text-bearing terminal container during entry', () => {
expect(css).toMatch(/\.terminal-dock-panel[\s\S]*?animation:\s*terminal-dock-in/);
const keyframesStart = css.indexOf('@keyframes terminal-dock-in');
expect(keyframesStart).toBeGreaterThan(-1);
const reducedMotion = css.indexOf('@media (prefers-reduced-motion: reduce)', keyframesStart);
expect(reducedMotion).toBeGreaterThan(keyframesStart);
const keyframes = css.slice(keyframesStart, reducedMotion);
expect(keyframes).toMatch(/transform:\s*translateY/);
expect(keyframes).not.toMatch(/opacity\s*:/);
const panelStart = css.indexOf('.terminal-dock-panel {');
const panelBlock = css.slice(panelStart, css.indexOf('}', panelStart) + 1);
expect(panelBlock).toContain('animation: terminal-dock-in');
expect(panelBlock).not.toMatch(/opacity\s*:/);
const reduceBlock = css.slice(css.indexOf('prefers-reduced-motion'));
expect(reduceBlock).toMatch(/\.terminal-dock-panel\s*\{[^}]*animation:\s*none/);
const placeholderStart = css.indexOf('.terminal-dock-form input::placeholder');
expect(placeholderStart).toBeGreaterThan(-1);
const placeholderBlock = css.slice(placeholderStart, css.indexOf('}', placeholderStart) + 1);
expect(placeholderBlock).toContain('color: var(--color-text-subtle)');
expect(placeholderBlock).toContain('opacity: 1');
});
});

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);
}
});
});