diff --git a/src/app/components/pages/skills/skills-grid/skills-grid.ts b/src/app/components/pages/skills/skills-grid/skills-grid.ts
index 8c3e9f9..20a21a6 100644
--- a/src/app/components/pages/skills/skills-grid/skills-grid.ts
+++ b/src/app/components/pages/skills/skills-grid/skills-grid.ts
@@ -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,
diff --git a/src/app/core/content/content.contracts.ts b/src/app/core/content/content.contracts.ts
index 9df44b7..b6235d2 100644
--- a/src/app/core/content/content.contracts.ts
+++ b/src/app/core/content/content.contracts.ts
@@ -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;
}
diff --git a/src/app/core/content/de/pages.ts b/src/app/core/content/de/pages.ts
index 6b6a863..ab2bbd5 100644
--- a/src/app/core/content/de/pages.ts
+++ b/src/app/core/content/de/pages.ts
@@ -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 = {
diff --git a/src/app/core/content/de/services.ts b/src/app/core/content/de/services.ts
index 739f009..eb2055b 100644
--- a/src/app/core/content/de/services.ts
+++ b/src/app/core/content/de/services.ts
@@ -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}.',
};
diff --git a/src/app/core/content/en/pages.ts b/src/app/core/content/en/pages.ts
index 13f02c8..aa63832 100644
--- a/src/app/core/content/en/pages.ts
+++ b/src/app/core/content/en/pages.ts
@@ -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 = {
diff --git a/src/app/core/content/en/services.ts b/src/app/core/content/en/services.ts
index 3bce016..9bee5f1 100644
--- a/src/app/core/content/en/services.ts
+++ b/src/app/core/content/en/services.ts
@@ -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}.',
};
diff --git a/src/app/features/page-rendering.spec.ts b/src/app/features/page-rendering.spec.ts
index ee3bff5..e3e24b3 100644
--- a/src/app/features/page-rendering.spec.ts
+++ b/src/app/features/page-rendering.spec.ts
@@ -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);
}
});
diff --git a/src/app/features/projects/projects.html b/src/app/features/projects/projects.html
index 04dfd43..9892001 100644
--- a/src/app/features/projects/projects.html
+++ b/src/app/features/projects/projects.html
@@ -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"
/>
diff --git a/src/app/features/services/service-page-view/service-offerings.spec.ts b/src/app/features/services/service-page-view/service-offerings.spec.ts
new file mode 100644
index 0000000..b59c7d0
--- /dev/null
+++ b/src/app/features/services/service-page-view/service-offerings.spec.ts
@@ -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();
+ }
+ });
+});
diff --git a/src/app/features/services/service-page-view/service-page-view.html b/src/app/features/services/service-page-view/service-page-view.html
index bf39939..814965a 100644
--- a/src/app/features/services/service-page-view/service-page-view.html
+++ b/src/app/features/services/service-page-view/service-page-view.html
@@ -6,15 +6,34 @@
@if (section.id === 'sequence') {
{{ offering.body }}
-{{ offering.body }}
+ @if (caseBackedNote(offering); as note) { +{{ note }}
+ } +{{ offering.body }}
+{{ copy.evidenceNote }}