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:
@@ -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({
|
@Component({
|
||||||
selector: 'app-skills-grid',
|
selector: 'app-skills-grid',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export interface ServiceSequenceCopy {
|
|||||||
export interface ServicePageCopy extends PageCopy {
|
export interface ServicePageCopy extends PageCopy {
|
||||||
readonly sequence: ServiceSequenceCopy;
|
readonly sequence: ServiceSequenceCopy;
|
||||||
readonly offerings: readonly OfferingCopy[];
|
readonly offerings: readonly OfferingCopy[];
|
||||||
|
readonly deliveredOfferingsHeading: string;
|
||||||
|
readonly offerOfferingsHeading: string;
|
||||||
|
readonly offeringCaseBackedLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudienceEntryCopy {
|
export interface AudienceEntryCopy {
|
||||||
@@ -167,12 +170,14 @@ export interface StackGroupCopy {
|
|||||||
|
|
||||||
export interface StackPageCopy extends PageCopy {
|
export interface StackPageCopy extends PageCopy {
|
||||||
readonly groups: readonly StackGroupCopy[];
|
readonly groups: readonly StackGroupCopy[];
|
||||||
|
readonly evidenceNote: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaseStudyLabels {
|
export interface CaseStudyLabels {
|
||||||
readonly situation: string;
|
readonly situation: string;
|
||||||
readonly approach: string;
|
readonly approach: string;
|
||||||
readonly outcome: string;
|
readonly outcome: string;
|
||||||
|
readonly metrics: string;
|
||||||
readonly stack: string;
|
readonly stack: string;
|
||||||
readonly tags: string;
|
readonly tags: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const PROJECTS_DE: ProjectsPageCopy = {
|
|||||||
situation: 'Lage',
|
situation: 'Lage',
|
||||||
approach: 'Vorgehen',
|
approach: 'Vorgehen',
|
||||||
outcome: 'Ergebnis',
|
outcome: 'Ergebnis',
|
||||||
|
metrics: 'Kennzahlen',
|
||||||
stack: 'Technik',
|
stack: 'Technik',
|
||||||
tags: 'Schlagworte',
|
tags: 'Schlagworte',
|
||||||
},
|
},
|
||||||
@@ -50,21 +51,23 @@ export const STACK_DE: StackPageCopy = {
|
|||||||
routeId: 'stack',
|
routeId: 'stack',
|
||||||
title: 'Technik-Stack | Antonio Ledebuhr',
|
title: 'Technik-Stack | Antonio Ledebuhr',
|
||||||
description:
|
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: {
|
hero: {
|
||||||
headline: 'Der Stack, der auf diesen Seiten vorkommt',
|
headline: 'Ein Technik-Überblick zur Einordnung',
|
||||||
proof:
|
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: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'Eine Auswahl, keine Rangliste',
|
headline: 'Überblick, keine Rangliste',
|
||||||
body: ['Die Übersicht ist eine Auswahl aus der öffentlichen Arbeit, 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',
|
id: 'groups',
|
||||||
headline: 'Wo die Gruppen in der Arbeit vorkommen',
|
headline: 'Welche Gruppen die Arbeit berührt',
|
||||||
body: [
|
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.',
|
'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: 'hyperviser', title: 'Hypervisor' },
|
||||||
{ id: 'tools', title: 'Werkzeuge' },
|
{ 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 = {
|
export const ABOUT_DE: PageCopy = {
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
||||||
@@ -208,6 +211,9 @@ export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
||||||
@@ -289,6 +295,9 @@ export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_AI_DE: ServicePageCopy = {
|
export const SERVICES_AI_DE: ServicePageCopy = {
|
||||||
@@ -421,4 +430,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
|
|||||||
status: 'offer',
|
status: 'offer',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||||
|
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||||
|
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const PROJECTS_EN: ProjectsPageCopy = {
|
|||||||
situation: 'Situation',
|
situation: 'Situation',
|
||||||
approach: 'Approach',
|
approach: 'Approach',
|
||||||
outcome: 'Outcome',
|
outcome: 'Outcome',
|
||||||
|
metrics: 'Metrics',
|
||||||
stack: 'Stack',
|
stack: 'Stack',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
},
|
},
|
||||||
@@ -50,21 +51,23 @@ export const STACK_EN: StackPageCopy = {
|
|||||||
routeId: 'stack',
|
routeId: 'stack',
|
||||||
title: 'Technology stack | Antonio Ledebuhr',
|
title: 'Technology stack | Antonio Ledebuhr',
|
||||||
description:
|
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: {
|
hero: {
|
||||||
headline: 'The stack that appears on these pages',
|
headline: 'A technology overview, not a proof list',
|
||||||
proof:
|
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: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'A selection, not a ranking',
|
headline: 'An overview, not a ranking',
|
||||||
body: ['The overview is a selection from the public work, 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',
|
id: 'groups',
|
||||||
headline: 'Where the groups show up in the work',
|
headline: 'Which groups the work touches',
|
||||||
body: [
|
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.',
|
'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: 'hyperviser', title: 'Hypervisors' },
|
||||||
{ id: 'tools', title: 'Tools' },
|
{ 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 = {
|
export const ABOUT_EN: PageCopy = {
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
||||||
@@ -208,6 +211,9 @@ export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
||||||
@@ -289,6 +295,9 @@ export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
offerings: [],
|
offerings: [],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES_AI_EN: ServicePageCopy = {
|
export const SERVICES_AI_EN: ServicePageCopy = {
|
||||||
@@ -421,4 +430,7 @@ export const SERVICES_AI_EN: ServicePageCopy = {
|
|||||||
status: 'offer',
|
status: 'offer',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
deliveredOfferingsHeading: 'Delivered work',
|
||||||
|
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||||
|
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ function accessibleName(root: HTMLElement, element: Element): string {
|
|||||||
return (element.getAttribute('aria-label') ?? element.textContent ?? '').trim();
|
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 {
|
function assertPageSemantics(root: HTMLElement): void {
|
||||||
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
|
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
|
||||||
expect(headings.filter((heading) => heading.tagName === 'H1')).toHaveLength(1);
|
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;
|
const root = harness.routeNativeElement as HTMLElement;
|
||||||
expect(root).toBeTruthy();
|
expect(root).toBeTruthy();
|
||||||
assertPageSemantics(root);
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
[situationLabel]="copy.caseLabels.situation"
|
[situationLabel]="copy.caseLabels.situation"
|
||||||
[approachLabel]="copy.caseLabels.approach"
|
[approachLabel]="copy.caseLabels.approach"
|
||||||
[outcomeLabel]="copy.caseLabels.outcome"
|
[outcomeLabel]="copy.caseLabels.outcome"
|
||||||
|
[metricsLabel]="copy.caseLabels.metrics"
|
||||||
[stackLabel]="copy.caseLabels.stack"
|
[stackLabel]="copy.caseLabels.stack"
|
||||||
[tagsLabel]="copy.caseLabels.tags"
|
[tagsLabel]="copy.caseLabels.tags"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,15 +6,34 @@
|
|||||||
@if (section.id === 'sequence') {
|
@if (section.id === 'sequence') {
|
||||||
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
|
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
|
||||||
}
|
}
|
||||||
@if (section.id === 'offer-boundary' && copy.offerings.length > 0) {
|
@if (section.id === 'scope' && deliveredOfferings().length > 0) {
|
||||||
<ul class="stack offerings">
|
<section class="stack offerings-group" [attr.aria-labelledby]="deliveredHeadingId()">
|
||||||
@for (offering of copy.offerings; track offering.id) {
|
<h3 [id]="deliveredHeadingId()">{{ copy.deliveredOfferingsHeading }}</h3>
|
||||||
<li class="glass-surface">
|
<ul class="stack offerings">
|
||||||
<h3>{{ offering.title }}</h3>
|
@for (offering of deliveredOfferings(); track offering.id) {
|
||||||
<p>{{ offering.body }}</p>
|
<li class="glass-surface" [attr.data-offering-status]="offering.status">
|
||||||
</li>
|
<h4>{{ offering.title }}</h4>
|
||||||
}
|
<p>{{ offering.body }}</p>
|
||||||
</ul>
|
@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">
|
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">
|
||||||
|
|||||||
@@ -12,12 +12,21 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.offerings h3 {
|
.offerings-group h3,
|
||||||
|
.offerings h4 {
|
||||||
margin: 0 0 var(--space-2);
|
margin: 0 0 var(--space-2);
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offerings h4 {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
|
||||||
.offerings p {
|
.offerings p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offerings .case-backed {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
|
type OfferingCopy,
|
||||||
type ProcessStepCopy,
|
type ProcessStepCopy,
|
||||||
type ServicePageId,
|
type ServicePageId,
|
||||||
type ServiceSequenceCopy,
|
type ServiceSequenceCopy,
|
||||||
@@ -42,4 +43,24 @@ export class ServicePageView {
|
|||||||
const section = this.page().sections.find((item) => item.id === 'sequence');
|
const section = this.page().sections.find((item) => item.id === 'sequence');
|
||||||
return section ? `section-${section.id}` : null;
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/app/features/stack/stack-evidence.spec.ts
Normal file
90
src/app/features/stack/stack-evidence.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@for (section of copy.sections; track section.id) {
|
@for (section of copy.sections; track section.id) {
|
||||||
<app-content-section [section]="section" />
|
<app-content-section [section]="section" />
|
||||||
}
|
}
|
||||||
|
<p class="evidence-note" role="note">{{ copy.evidenceNote }}</p>
|
||||||
<app-skills-grid [categoryTitles]="categoryTitles()" />
|
<app-skills-grid [categoryTitles]="categoryTitles()" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (caseStudy().metrics.length > 0) {
|
@if (caseStudy().metrics.length > 0) {
|
||||||
<app-metric-list [metrics]="caseStudy().metrics" [labelledBy]="metricsLabelId()" />
|
<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'">
|
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-stack'">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export class CaseStudy {
|
|||||||
readonly situationLabel = input.required<string>();
|
readonly situationLabel = input.required<string>();
|
||||||
readonly approachLabel = input.required<string>();
|
readonly approachLabel = input.required<string>();
|
||||||
readonly outcomeLabel = input.required<string>();
|
readonly outcomeLabel = input.required<string>();
|
||||||
|
readonly metricsLabel = input.required<string>();
|
||||||
readonly stackLabel = input.required<string>();
|
readonly stackLabel = input.required<string>();
|
||||||
readonly tagsLabel = input.required<string>();
|
readonly tagsLabel = input.required<string>();
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page section p {
|
.page section p,
|
||||||
|
.page .evidence-note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user