integration: name case metrics, split delivered offerings from offers, and frame the stack honestly

Give every metrics list a localized heading, keep delivered Rösterei tools out of the offer boundary, and describe the skills grid as a familiarity overview with an evidence note instead of claiming public-case backing for every item.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 19:32:18 +02:00
parent 1d2965a7ff
commit 1de146c650
17 changed files with 309 additions and 24 deletions

View File

@@ -105,6 +105,10 @@ const BASE_CATEGORIES: readonly SkillCategory[] = [
},
];
export const SKILL_GRID_ITEM_NAMES: readonly string[] = BASE_CATEGORIES.flatMap((category) =>
category.skills.map((skill) => skill.name),
);
@Component({
selector: 'app-skills-grid',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -96,6 +96,9 @@ export interface ServiceSequenceCopy {
export interface ServicePageCopy extends PageCopy {
readonly sequence: ServiceSequenceCopy;
readonly offerings: readonly OfferingCopy[];
readonly deliveredOfferingsHeading: string;
readonly offerOfferingsHeading: string;
readonly offeringCaseBackedLabel: string;
}
export interface AudienceEntryCopy {
@@ -167,12 +170,14 @@ export interface StackGroupCopy {
export interface StackPageCopy extends PageCopy {
readonly groups: readonly StackGroupCopy[];
readonly evidenceNote: string;
}
export interface CaseStudyLabels {
readonly situation: string;
readonly approach: string;
readonly outcome: string;
readonly metrics: string;
readonly stack: string;
readonly tags: string;
}

View File

@@ -41,6 +41,7 @@ export const PROJECTS_DE: ProjectsPageCopy = {
situation: 'Lage',
approach: 'Vorgehen',
outcome: 'Ergebnis',
metrics: 'Kennzahlen',
stack: 'Technik',
tags: 'Schlagworte',
},
@@ -50,21 +51,23 @@ export const STACK_DE: StackPageCopy = {
routeId: 'stack',
title: 'Technik-Stack | Antonio Ledebuhr',
description:
'Der öffentliche Stack in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
'Technik-Überblick in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
hero: {
headline: 'Der Stack, der auf diesen Seiten vorkommt',
headline: 'Ein Technik-Überblick zur Einordnung',
proof:
'Die Gruppen gehören zur Produktarbeit in Java und Spring sowie in C# und .NET, zur Datenarbeit in SQL Server, zu Containern und Clustern und zu Servern vor Ort.',
'Die Rasterkarte ist eine Vertrautheitsliste. Sie zeigt Technologien aus der Arbeit, ohne jeden Eintrag als öffentlichen Fall zu belegen.',
},
sections: [
{
id: 'reading',
headline: 'Eine Auswahl, keine Rangliste',
body: ['Die Übersicht ist eine Auswahl aus der öffentlichen Arbeit, keine Rangliste.'],
headline: 'Überblick, keine Rangliste',
body: [
'Die Übersicht ordnet Technologien nach Gruppen. Sie ist keine Rangliste und kein Nachweis, dass jeder Eintrag in einem öffentlichen Fall vorkommt.',
],
},
{
id: 'groups',
headline: 'Wo die Gruppen in der Arbeit vorkommen',
headline: 'Welche Gruppen die Arbeit berührt',
body: [
'Produktarbeit in Java und Spring sowie in C# und .NET, mit Angular auf der Oberfläche. Datenarbeit in SQL Server und T-SQL. Container und Cluster mit Docker, Kubernetes und Azure AKS. Pipelines in GitLab CI/CD und Azure DevOps, GitOps mit Argo CD. Server und Virtualisierung vor Ort.',
],
@@ -83,6 +86,8 @@ export const STACK_DE: StackPageCopy = {
{ id: 'hyperviser', title: 'Hypervisor' },
{ id: 'tools', title: 'Werkzeuge' },
],
evidenceNote:
'In den öffentlichen Fällen sind unter anderem Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox und Microsoft 365 belegt. Die übrigen Einträge der Übersicht stehen für Breite und Vertrautheit, ohne öffentlichen Referenzfall.',
};
export const ABOUT_DE: PageCopy = {

View File

@@ -126,6 +126,9 @@ export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
@@ -208,6 +211,9 @@ export const SERVICES_HARDWARE_DE: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
@@ -289,6 +295,9 @@ export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};
export const SERVICES_AI_DE: ServicePageCopy = {
@@ -421,4 +430,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
status: 'offer',
},
],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};

View File

@@ -41,6 +41,7 @@ export const PROJECTS_EN: ProjectsPageCopy = {
situation: 'Situation',
approach: 'Approach',
outcome: 'Outcome',
metrics: 'Metrics',
stack: 'Stack',
tags: 'Tags',
},
@@ -50,21 +51,23 @@ export const STACK_EN: StackPageCopy = {
routeId: 'stack',
title: 'Technology stack | Antonio Ledebuhr',
description:
'The public stack in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
'A technology overview in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
hero: {
headline: 'The stack that appears on these pages',
headline: 'A technology overview, not a proof list',
proof:
'The groups belong to product work in Java and Spring and in C# and .NET, to SQL Server data work, to containers and clusters, and to servers on site.',
'The grid is a familiarity list. It shows technologies from the work without claiming a public case for every entry.',
},
sections: [
{
id: 'reading',
headline: 'A selection, not a ranking',
body: ['The overview is a selection from the public work, not a ranking.'],
headline: 'An overview, not a ranking',
body: [
'The grid groups technologies for orientation. It is not a ranking, and it does not claim that every entry appears in a public case.',
],
},
{
id: 'groups',
headline: 'Where the groups show up in the work',
headline: 'Which groups the work touches',
body: [
'Product work in Java and Spring and in C# and .NET, with Angular on the front end. SQL Server and T-SQL data work. Containers and clusters with Docker, Kubernetes and Azure AKS. Pipelines in GitLab CI/CD and Azure DevOps, GitOps with Argo CD. Servers and virtualisation on site.',
],
@@ -83,6 +86,8 @@ export const STACK_EN: StackPageCopy = {
{ id: 'hyperviser', title: 'Hypervisors' },
{ id: 'tools', title: 'Tools' },
],
evidenceNote:
'The public cases evidence items such as Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox and Microsoft 365. The remaining entries show breadth and familiarity without a public reference.',
};
export const ABOUT_EN: PageCopy = {

View File

@@ -126,6 +126,9 @@ export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
@@ -208,6 +211,9 @@ export const SERVICES_HARDWARE_EN: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
@@ -289,6 +295,9 @@ export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
},
},
offerings: [],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};
export const SERVICES_AI_EN: ServicePageCopy = {
@@ -421,4 +430,7 @@ export const SERVICES_AI_EN: ServicePageCopy = {
status: 'offer',
},
],
deliveredOfferingsHeading: 'Delivered work',
offerOfferingsHeading: 'Offered and validated per engagement',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};

View File

@@ -26,6 +26,17 @@ function accessibleName(root: HTMLElement, element: Element): string {
return (element.getAttribute('aria-label') ?? element.textContent ?? '').trim();
}
function assertNoDanglingReferences(root: HTMLElement): void {
for (const attribute of ['aria-labelledby', 'aria-describedby'] as const) {
for (const element of root.querySelectorAll(`[${attribute}]`)) {
const ids = (element.getAttribute(attribute) ?? '').split(/\s+/).filter(Boolean);
for (const id of ids) {
expect(root.querySelector(`[id="${id}"]`), `${attribute} -> #${id}`).toBeTruthy();
}
}
}
}
function assertPageSemantics(root: HTMLElement): void {
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
expect(headings.filter((heading) => heading.tagName === 'H1')).toHaveLength(1);
@@ -69,6 +80,34 @@ describe('page rendering, semantics and accessibility', () => {
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
assertPageSemantics(root);
assertNoDanglingReferences(root);
}
});
it('keeps every aria-labelledby and aria-describedby target in the document', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
const paths = [
'/',
'/en',
'/projekte',
'/en/projects',
'/leistungen/ai-integration',
'/en/services/ai-integration',
'/kontakt',
'/en/contact',
'/stack',
'/en/stack',
];
for (const path of paths) {
await harness.navigateByUrl(path);
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
assertNoDanglingReferences(root);
}
});

View File

@@ -10,6 +10,7 @@
[situationLabel]="copy.caseLabels.situation"
[approachLabel]="copy.caseLabels.approach"
[outcomeLabel]="copy.caseLabels.outcome"
[metricsLabel]="copy.caseLabels.metrics"
[stackLabel]="copy.caseLabels.stack"
[tagsLabel]="copy.caseLabels.tags"
/>

View File

@@ -0,0 +1,57 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../../app.routes';
import { SITE_CONTENT } from '../../../core/content/content.token';
import { SITE_CONTENT_DATA } from '../../../core/content/site-content';
import { APP_LOCALES } from '../../../core/i18n/locale';
import { routePath } from '../../../core/routing/route-paths';
describe('AI service offering groups', () => {
it('renders delivered work and offers under their own localized labels', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) {
const copy = SITE_CONTENT_DATA[locale].services.servicesAi;
const delivered = copy.offerings.filter((offering) => offering.status === 'delivered');
const offers = copy.offerings.filter((offering) => offering.status === 'offer');
expect(delivered.length).toBeGreaterThan(0);
expect(offers.length).toBeGreaterThan(0);
await harness.navigateByUrl(routePath('servicesAi', locale));
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
const deliveredGroup = root.querySelector(
`[aria-labelledby="offerings-delivered-servicesAi"]`,
);
const offerGroup = root.querySelector(`[aria-labelledby="offerings-offer-servicesAi"]`);
expect(deliveredGroup).toBeTruthy();
expect(offerGroup).toBeTruthy();
expect(deliveredGroup?.textContent).toContain(copy.deliveredOfferingsHeading);
expect(offerGroup?.textContent).toContain(copy.offerOfferingsHeading);
for (const offering of delivered) {
expect(deliveredGroup?.textContent).toContain(offering.title);
expect(offerGroup?.textContent).not.toContain(offering.title);
const note = copy.offeringCaseBackedLabel.replace(
'{case}',
SITE_CONTENT_DATA[locale].cases[offering.referenceCaseId!].client,
);
expect(deliveredGroup?.textContent).toContain(note);
}
for (const offering of offers) {
expect(offerGroup?.textContent).toContain(offering.title);
expect(deliveredGroup?.textContent).not.toContain(offering.title);
}
expect(deliveredGroup?.querySelector('[data-offering-status="offer"]')).toBeNull();
expect(offerGroup?.querySelector('[data-offering-status="delivered"]')).toBeNull();
}
});
});

View File

@@ -6,15 +6,34 @@
@if (section.id === 'sequence') {
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
}
@if (section.id === 'offer-boundary' && copy.offerings.length > 0) {
@if (section.id === 'scope' && deliveredOfferings().length > 0) {
<section class="stack offerings-group" [attr.aria-labelledby]="deliveredHeadingId()">
<h3 [id]="deliveredHeadingId()">{{ copy.deliveredOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of copy.offerings; track offering.id) {
<li class="glass-surface">
<h3>{{ offering.title }}</h3>
@for (offering of deliveredOfferings(); track offering.id) {
<li class="glass-surface" [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
@if (caseBackedNote(offering); as note) {
<p class="case-backed">{{ note }}</p>
}
</li>
}
</ul>
</section>
}
@if (section.id === 'offer-boundary' && offerOfferings().length > 0) {
<section class="stack offerings-group" [attr.aria-labelledby]="offerHeadingId()">
<h3 [id]="offerHeadingId()">{{ copy.offerOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of offerOfferings(); track offering.id) {
<li class="glass-surface" [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
</li>
}
</ul>
</section>
}
}
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">

View File

@@ -12,12 +12,21 @@
border-radius: var(--radius-md);
}
.offerings h3 {
.offerings-group h3,
.offerings h4 {
margin: 0 0 var(--space-2);
font-size: var(--text-lg);
}
.offerings h4 {
font-size: var(--text-md);
}
.offerings p {
margin: 0;
color: var(--color-text-muted);
}
.offerings .case-backed {
color: var(--color-text);
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import {
type OfferingCopy,
type ProcessStepCopy,
type ServicePageId,
type ServiceSequenceCopy,
@@ -42,4 +43,24 @@ export class ServicePageView {
const section = this.page().sections.find((item) => item.id === 'sequence');
return section ? `section-${section.id}` : null;
});
protected readonly deliveredOfferings = computed(() =>
this.page().offerings.filter((offering) => offering.status === 'delivered'),
);
protected readonly offerOfferings = computed(() =>
this.page().offerings.filter((offering) => offering.status === 'offer'),
);
protected readonly deliveredHeadingId = computed(() => `offerings-delivered-${this.routeId()}`);
protected readonly offerHeadingId = computed(() => `offerings-offer-${this.routeId()}`);
protected caseBackedNote(offering: OfferingCopy): string | null {
const caseId = offering.referenceCaseId;
if (!caseId) {
return null;
}
return this.page().offeringCaseBackedLabel.replace(
'{case}',
this.content.caseStudy(caseId)().client,
);
}
}

View File

@@ -0,0 +1,90 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../app.routes';
import { SKILL_GRID_ITEM_NAMES } from '../../components/pages/skills/skills-grid/skills-grid';
import { SITE_CONTENT } from '../../core/content/content.token';
import { SITE_CONTENT_DATA } from '../../core/content/site-content';
import { APP_LOCALES } from '../../core/i18n/locale';
import { routePath } from '../../core/routing/route-paths';
const OVERCLAIMS = [
'Der Stack, der auf diesen Seiten vorkommt',
'The stack that appears on these pages',
'Auswahl aus der öffentlichen Arbeit',
'selection from the public work',
];
function walkStrings(value: unknown, visit: (text: string) => void): void {
if (typeof value === 'string') {
visit(value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => walkStrings(item, visit));
return;
}
if (value && typeof value === 'object') {
Object.values(value).forEach((child) => walkStrings(child, visit));
}
}
function contentMentions(name: string): boolean {
let found = false;
walkStrings(SITE_CONTENT_DATA, (text) => {
if (text.includes(name)) {
found = true;
}
});
return found;
}
describe('stack evidence framing', () => {
it('backs every skills-grid name with public copy or the evidence note', () => {
for (const locale of APP_LOCALES) {
const note = SITE_CONTENT_DATA[locale].stack.evidenceNote;
expect(note.trim().length).toBeGreaterThan(40);
for (const name of SKILL_GRID_ITEM_NAMES) {
expect(
contentMentions(name) || note.length > 0,
`${locale} skill "${name}" must appear in copy or be covered by the evidence note`,
).toBe(true);
}
}
expect(SITE_CONTENT_DATA.de.stack.evidenceNote).not.toBe(
SITE_CONTENT_DATA.en.stack.evidenceNote,
);
});
it('renders the evidence note and drops the old overclaiming sentences', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) {
const note = SITE_CONTENT_DATA[locale].stack.evidenceNote;
await harness.navigateByUrl(routePath('stack', locale));
const root = harness.routeNativeElement as HTMLElement;
const rendered = root.querySelector('[role="note"]');
expect(rendered?.textContent).toContain(note);
expect(root.textContent).toContain(note);
for (const claim of OVERCLAIMS) {
expect(root.textContent).not.toContain(claim);
expect(SITE_CONTENT_DATA[locale].stack.hero.headline).not.toContain(claim);
expect(
SITE_CONTENT_DATA[locale].stack.sections
.map((section) => section.body?.join(' ') ?? '')
.join(' '),
).not.toContain(claim);
}
}
});
});

View File

@@ -4,6 +4,7 @@
@for (section of copy.sections; track section.id) {
<app-content-section [section]="section" />
}
<p class="evidence-note" role="note">{{ copy.evidenceNote }}</p>
<app-skills-grid [categoryTitles]="categoryTitles()" />
</div>
}

View File

@@ -36,7 +36,10 @@
</section>
@if (caseStudy().metrics.length > 0) {
<section class="stack" [attr.aria-labelledby]="metricsLabelId()">
<h3 [id]="metricsLabelId()">{{ metricsLabel() }}</h3>
<app-metric-list [metrics]="caseStudy().metrics" [labelledBy]="metricsLabelId()" />
</section>
}
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-stack'">

View File

@@ -14,6 +14,7 @@ export class CaseStudy {
readonly situationLabel = input.required<string>();
readonly approachLabel = input.required<string>();
readonly outcomeLabel = input.required<string>();
readonly metricsLabel = input.required<string>();
readonly stackLabel = input.required<string>();
readonly tagsLabel = input.required<string>();

View File

@@ -13,7 +13,8 @@
font-weight: 600;
}
.page section p {
.page section p,
.page .evidence-note {
margin: 0;
max-width: 40rem;
color: var(--color-text-muted);