Compare commits
10 Commits
55d012cc10
...
orchestrat
| Author | SHA1 | Date | |
|---|---|---|---|
| 4389f3dd3c | |||
| 334b30145e | |||
| 0e1e48d876 | |||
| 50851f4d26 | |||
| 0b74be69be | |||
| 7619205d91 | |||
| 8974b4c6fb | |||
| 9bfb1dc71c | |||
| bb9912daef | |||
| a9ad7eb1a7 |
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -42,7 +43,9 @@ test.describe('Systems Map', () => {
|
|||||||
textBox.x + textBox.width <= shapeBox.x + shapeBox.width + 0.5 &&
|
textBox.x + textBox.width <= shapeBox.x + shapeBox.width + 0.5 &&
|
||||||
textBox.y + textBox.height <= shapeBox.y + shapeBox.height + 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);
|
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('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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,89 @@ 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).toContainText(copy.clearedMessage);
|
||||||
|
await expect(session.locator('p')).toHaveCount(1);
|
||||||
|
|
||||||
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 +136,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 +146,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"http://127.0.0.1:4000/en",
|
"http://127.0.0.1:4000/en",
|
||||||
"http://127.0.0.1:4000/pitch",
|
"http://127.0.0.1:4000/pitch",
|
||||||
"http://127.0.0.1:4000/en/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"
|
"http://127.0.0.1:4000/en/projects"
|
||||||
],
|
],
|
||||||
"numberOfRuns": 3,
|
"numberOfRuns": 3,
|
||||||
|
|||||||
@@ -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']),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
144
src/app/core/content/content-service-led.spec.ts
Normal file
144
src/app/core/content/content-service-led.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,10 +169,6 @@ export interface PitchPageCopy extends PageCopy {
|
|||||||
readonly stackHeading: string;
|
readonly stackHeading: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServicesOverviewPageCopy extends PageCopy {
|
|
||||||
readonly systemsMap: { readonly heading: string; readonly intro: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContactFieldId =
|
export type ContactFieldId =
|
||||||
'name' | 'email' | 'company' | 'projectType' | 'situation' | 'timeframe';
|
'name' | 'email' | 'company' | 'projectType' | 'situation' | 'timeframe';
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const ABOUT_DE: PageCopy = {
|
|||||||
id: 'profile',
|
id: 'profile',
|
||||||
headline: 'Berufliches Profil',
|
headline: 'Berufliches Profil',
|
||||||
body: [
|
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).',
|
'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).',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const ABOUT_EN: PageCopy = {
|
|||||||
id: 'profile',
|
id: 'profile',
|
||||||
headline: 'Professional profile',
|
headline: 'Professional profile',
|
||||||
body: [
|
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).',
|
'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).',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
|||||||
33
src/app/shared/interactive-lift.spec.ts
Normal file
33
src/app/shared/interactive-lift.spec.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,15 +32,13 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="systems-map-node"
|
class="systems-map-node"
|
||||||
[attr.data-kind]="node.kind"
|
[attr.data-kind]="node.kind"
|
||||||
|
[attr.data-id]="node.id"
|
||||||
[class.is-connected]="isConnectedNode(node.id)"
|
[class.is-connected]="isConnectedNode(node.id)"
|
||||||
[class.is-dimmed]="isDimmedNode(node.id)"
|
[class.is-dimmed]="isDimmedNode(node.id)"
|
||||||
(click)="onNodeActivate($event, node)"
|
(click)="onNodeActivate($event, node)"
|
||||||
(mouseenter)="onNodeEnter(node.id)"
|
(mouseenter)="onNodeEnter(node.id)"
|
||||||
(mouseleave)="onNodeLeave(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
|
<rect
|
||||||
[attr.x]="node.x - node.shapeWidth / 2"
|
[attr.x]="node.x - node.shapeWidth / 2"
|
||||||
[attr.y]="node.y - node.shapeHeight / 2"
|
[attr.y]="node.y - node.shapeHeight / 2"
|
||||||
@@ -48,7 +46,6 @@
|
|||||||
[attr.height]="node.shapeHeight"
|
[attr.height]="node.shapeHeight"
|
||||||
rx="8"
|
rx="8"
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
<text [attr.x]="node.x" [attr.y]="node.y">
|
<text [attr.x]="node.x" [attr.y]="node.y">
|
||||||
@for (line of node.labelLines; track $index) {
|
@for (line of node.labelLines; track $index) {
|
||||||
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
|
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
|
||||||
@@ -93,11 +90,11 @@
|
|||||||
#gistDialog
|
#gistDialog
|
||||||
class="systems-map-dialog"
|
class="systems-map-dialog"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
|
||||||
[attr.aria-labelledby]="dialogTitleId"
|
[attr.aria-labelledby]="dialogTitleId"
|
||||||
[attr.aria-describedby]="dialogDescId"
|
[attr.aria-describedby]="dialogDescId"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
(keydown)="onDialogKeydown($event)"
|
(keydown)="onDialogKeydown($event)"
|
||||||
|
(focusout)="onDialogFocusOut($event)"
|
||||||
>
|
>
|
||||||
<div class="systems-map-dialog-bar cluster">
|
<div class="systems-map-dialog-bar cluster">
|
||||||
<h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3>
|
<h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3>
|
||||||
@@ -113,7 +110,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</p>
|
<p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</p>
|
||||||
<p class="systems-map-dialog-relation">{{ node.relationship }}</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>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -64,7 +64,6 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.systems-map-node circle,
|
|
||||||
.systems-map-node rect {
|
.systems-map-node rect {
|
||||||
fill: var(--color-surface-overlay);
|
fill: var(--color-surface-overlay);
|
||||||
stroke: var(--color-accent);
|
stroke: var(--color-accent);
|
||||||
@@ -79,7 +78,7 @@
|
|||||||
stroke: var(--color-accent-soft);
|
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'] {
|
.systems-map-legend [data-kind='ai'] {
|
||||||
stroke: var(--color-accent-strong);
|
stroke: var(--color-accent-strong);
|
||||||
}
|
}
|
||||||
@@ -90,7 +89,6 @@
|
|||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.systems-map-node.is-connected circle,
|
|
||||||
.systems-map-node.is-connected rect,
|
.systems-map-node.is-connected rect,
|
||||||
.systems-map-edge.is-connected {
|
.systems-map-edge.is-connected {
|
||||||
stroke: var(--color-accent-strong);
|
stroke: var(--color-accent-strong);
|
||||||
@@ -131,7 +129,6 @@
|
|||||||
outline-offset: var(--focus-ring-offset);
|
outline-offset: var(--focus-ring-offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.systems-map-node:focus-visible circle,
|
|
||||||
.systems-map-node:focus-visible rect {
|
.systems-map-node:focus-visible rect {
|
||||||
stroke: var(--focus-ring-color);
|
stroke: var(--focus-ring-color);
|
||||||
stroke-width: 3;
|
stroke-width: 3;
|
||||||
@@ -187,7 +184,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.systems-map-node:hover circle,
|
|
||||||
.systems-map-node:hover rect {
|
.systems-map-node:hover rect {
|
||||||
stroke: var(--color-accent-strong);
|
stroke: var(--color-accent-strong);
|
||||||
}
|
}
|
||||||
@@ -199,7 +195,6 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.systems-map-edge,
|
.systems-map-edge,
|
||||||
.systems-map-node circle,
|
|
||||||
.systems-map-node rect {
|
.systems-map-node rect {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('SystemsMap', () => {
|
|||||||
|
|
||||||
const dialog = compiled.querySelector('[role="dialog"]');
|
const dialog = compiled.querySelector('[role="dialog"]');
|
||||||
expect(dialog).toBeTruthy();
|
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.label);
|
||||||
expect(dialog?.textContent).toContain(first.gist);
|
expect(dialog?.textContent).toContain(first.gist);
|
||||||
expect(dialog?.textContent).toContain(first.relationship);
|
expect(dialog?.textContent).toContain(first.relationship);
|
||||||
@@ -141,6 +141,36 @@ describe('SystemsMap', () => {
|
|||||||
expect(navigate).not.toHaveBeenCalled();
|
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 () => {
|
it('moves focus into the dialog and restores it to the activating node on close', async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
@@ -166,6 +196,91 @@ describe('SystemsMap', () => {
|
|||||||
expect(copy.closeLabel.length).toBeGreaterThan(0);
|
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 () => {
|
it('keeps instance-scoped ids unique when two maps are rendered', async () => {
|
||||||
await configure();
|
await configure();
|
||||||
const first = TestBed.createComponent(SystemsMap);
|
const first = TestBed.createComponent(SystemsMap);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
DestroyRef,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
Injector,
|
Injector,
|
||||||
@@ -75,10 +76,13 @@ export class SystemsMap {
|
|||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
private readonly navigation = inject(NavigationService);
|
private readonly navigation = inject(NavigationService);
|
||||||
private readonly localeService = inject(LocaleService);
|
private readonly localeService = inject(LocaleService);
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly isBrowser = isBrowserPlatform();
|
private readonly isBrowser = isBrowserPlatform();
|
||||||
private readonly instanceId = systemsMapInstanceId++;
|
private readonly instanceId = systemsMapInstanceId++;
|
||||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('gistDialog');
|
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('gistDialog');
|
||||||
private opener: HTMLElement | null = null;
|
private opener: HTMLElement | null = null;
|
||||||
|
private dismissListening = false;
|
||||||
|
|
||||||
protected readonly headingId = `systems-map-heading-${this.instanceId}`;
|
protected readonly headingId = `systems-map-heading-${this.instanceId}`;
|
||||||
protected readonly svgTitleId = `systems-map-svg-title-${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) : '';
|
return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.destroyRef.onDestroy(() => this.unbindDismissListeners());
|
||||||
|
}
|
||||||
|
|
||||||
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
|
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
|
||||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.openDialog(node.id, event.currentTarget as HTMLElement);
|
this.openDialog(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void {
|
protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void {
|
||||||
@@ -160,7 +168,7 @@ export class SystemsMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.openDialog(node.id, event.currentTarget as HTMLElement);
|
this.openDialog(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onNodeEnter(id: SystemsMapNodeId): void {
|
protected onNodeEnter(id: SystemsMapNodeId): void {
|
||||||
@@ -174,17 +182,19 @@ export class SystemsMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected closeDialog(): void {
|
protected closeDialog(): void {
|
||||||
this.openNodeId.set(null);
|
|
||||||
const opener = this.opener;
|
const opener = this.opener;
|
||||||
|
const wasOpen = this.openNodeId() !== null;
|
||||||
|
this.unbindDismissListeners();
|
||||||
|
this.openNodeId.set(null);
|
||||||
this.opener = null;
|
this.opener = null;
|
||||||
|
|
||||||
if (!this.isBrowser || !(opener instanceof HTMLElement)) {
|
if (!wasOpen || !this.isBrowser || !this.isRestorableOpener(opener)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterNextRender(
|
afterNextRender(
|
||||||
() => {
|
() => {
|
||||||
if (opener.isConnected) {
|
if (this.isRestorableOpener(opener)) {
|
||||||
opener.focus();
|
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 {
|
protected onDialogKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key !== 'Tab') {
|
protected onDialogFocusOut(event: FocusEvent): void {
|
||||||
|
if (!this.openNodeId()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialog = this.dialogRef()?.nativeElement;
|
const dialog = this.dialogRef()?.nativeElement;
|
||||||
if (!dialog) {
|
const next = event.relatedTarget;
|
||||||
|
if (!(next instanceof Node) || dialog?.contains(next)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusable = this.focusableIn(dialog);
|
this.unbindDismissListeners();
|
||||||
if (focusable.length === 0) {
|
this.openNodeId.set(null);
|
||||||
event.preventDefault();
|
this.opener = null;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected isConnectedNode(id: SystemsMapNodeId): boolean {
|
protected isConnectedNode(id: SystemsMapNodeId): boolean {
|
||||||
@@ -247,9 +250,10 @@ export class SystemsMap {
|
|||||||
return !!active && edge.from !== active && edge.to !== active;
|
return !!active && edge.from !== active && edge.to !== active;
|
||||||
}
|
}
|
||||||
|
|
||||||
private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void {
|
private openDialog(id: SystemsMapNodeId): void {
|
||||||
this.opener = opener;
|
this.opener = this.listAnchorFor(id);
|
||||||
this.openNodeId.set(id);
|
this.openNodeId.set(id);
|
||||||
|
this.bindDismissListeners();
|
||||||
|
|
||||||
afterNextRender(
|
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[] {
|
private focusableIn(root: HTMLElement | undefined): HTMLElement[] {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return [];
|
return [];
|
||||||
@@ -295,7 +368,7 @@ export class SystemsMap {
|
|||||||
labelLines,
|
labelLines,
|
||||||
summary,
|
summary,
|
||||||
gist,
|
gist,
|
||||||
shapeWidth: Math.max(140, longest * 8 + 24),
|
shapeWidth: Math.max(160, longest * 9 + 32),
|
||||||
shapeHeight: multiline ? 52 : 40,
|
shapeHeight: multiline ? 52 : 40,
|
||||||
textStartDy: multiline ? -6 : 4,
|
textStartDy: multiline ? -6 : 4,
|
||||||
href: nodeHref(node, locale),
|
href: nodeHref(node, locale),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
justify-items: end;
|
justify-items: end;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock-trigger {
|
.terminal-dock-trigger {
|
||||||
@@ -41,6 +42,8 @@
|
|||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
max-height: min(70vh, 32rem);
|
max-height: min(70vh, 32rem);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border: 1px solid var(--surface-glass-border);
|
border: 1px solid var(--surface-glass-border);
|
||||||
@@ -101,11 +104,13 @@
|
|||||||
|
|
||||||
.terminal-dock-session {
|
.terminal-dock-session {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
min-width: 0;
|
||||||
min-height: 8rem;
|
min-height: 8rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock-session .echo {
|
.terminal-dock-session .echo {
|
||||||
@@ -114,18 +119,22 @@
|
|||||||
|
|
||||||
.terminal-dock-form {
|
.terminal-dock-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: minmax(0, auto) minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock-prompt {
|
.terminal-dock-prompt {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-accent-cool);
|
color: var(--color-text);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock-form input {
|
.terminal-dock-form input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 1px solid var(--surface-glass-border);
|
border: 1px solid var(--surface-glass-border);
|
||||||
@@ -136,6 +145,12 @@
|
|||||||
font-size: var(--text-sm);
|
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) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.terminal-dock-trigger:hover,
|
.terminal-dock-trigger:hover,
|
||||||
.terminal-dock-control:hover {
|
.terminal-dock-control:hover {
|
||||||
@@ -145,12 +160,10 @@
|
|||||||
|
|
||||||
@keyframes terminal-dock-in {
|
@keyframes terminal-dock-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(0.4rem);
|
transform: translateY(0.4rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
@@ -475,3 +478,37 @@ describe('TerminalDock', () => {
|
|||||||
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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:/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
52
src/legacy-service-redirects.spec.ts
Normal file
52
src/legacy-service-redirects.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user