Relaunch Phase 1: Hero mit Deutschland-Karte, Kundenportal-Bühne, Leistungen, Preise, Unterseiten im neuen Layout

- Hero: wechselnde Überschrift (ein Durchlauf, Pause-Knopf), Karte zeichnet
  sich, roter Punkt auf Falkenstein
- Kundenportal: Bühne mit Browserfenster und Handy-Rahmen (Desktop),
  Wisch-Reihe (Handy), Schrittzeile statt „So arbeiten wir“
- Leistungen als nummerierte Zeilen, Preise aus src/data/abos.ts
- Unterseiten ohne eigenen Kopfbereich, Wortlaut der Rechtstexte unverändert
- Tests für Hero, Kundenportal, Leistungen/Preise und Layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-01 15:28:38 +00:00
1 parent 69d006334f
commit 1c8cfa5c1c
54 files changed
+4761 -1166

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, fireEvent, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { AppRoutes } from "@/routes";
import { NAVIGATION } from "@/components/site/SiteHeader";
import { projekte } from "@/data/projekte";
// Netzwerk gibt es in jsdom nicht: das Kontaktformular-Backend wird durch eine Attrappe ersetzt.
vi.mock("@/lib/appwrite", () => ({ createContactDocument: vi.fn().mockResolvedValue(undefined) }));
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
/** Alle Seiten im gemeinsamen Layout (src/routes.tsx). /datenschutz leitet nur weiter und hat kein Layout. */
const ROUTEN = ["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"];
const UNTERSEITEN = ROUTEN.slice(1);
const seite = (pfad: string) =>
render(
<MemoryRouter initialEntries={[pfad]} future={routerFuture}>
<AppRoutes />
</MemoryRouter>,
);
const lies = (pfad: string) => readFileSync(join(process.cwd(), pfad), "utf8");
afterEach(() => {
cleanup();
document.body.style.overflow = "";
});
describe.each(ROUTEN)("Layout auf %s", (pfad) => {
it("hat genau eine Kopfleiste mit der Hauptnavigation", () => {
seite(pfad);
const kopf = screen.getByRole("banner");
expect(screen.getAllByRole("banner")).toHaveLength(1);
const nav = within(kopf).getByRole("navigation", { name: "Hauptnavigation" });
for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) {
expect(within(nav).getByRole("link", { name })).toBeInTheDocument();
}
// „Projekte“ gibt es nur mit freigegebenen Referenzen (src/data/projekte.ts)
expect(within(nav).queryByRole("link", { name: "Projekte" } as const) !== null).toBe(projekte.length > 0);
expect(within(kopf).getByRole("link", { name: /WEBklar – zur Startseite/ })).toHaveAttribute("href", "/");
expect(within(kopf).getAllByRole("link", { name: "Projekt besprechen" })[0]).toHaveAttribute("href", "/kontakt");
});
it("hat genau einen Footer mit Impressum, Datenschutz, AGB, Kündigen und Widerrufen", () => {
seite(pfad);
const footer = screen.getByRole("contentinfo");
expect(screen.getAllByRole("contentinfo")).toHaveLength(1);
for (const name of ["Impressum", "Datenschutz", "AGB", "Verträge hier kündigen", "Vertrag widerrufen"]) {
expect(within(footer).getByRole("link", { name })).toBeInTheDocument();
}
});
it("hat genau ein main (mit dem Sprungziel #inhalt) und genau ein h1", () => {
seite(pfad);
const inhalt = screen.getByRole("main");
expect(screen.getAllByRole("main")).toHaveLength(1);
expect(inhalt).toHaveAttribute("id", "inhalt");
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByRole("link", { name: "Zum Inhalt springen" })).toHaveAttribute("href", "#inhalt");
});
it("Kopfleiste und Footer stehen außerhalb von main, in der Reihenfolge Kopf, Inhalt, Footer", () => {
seite(pfad);
const kopf = screen.getByRole("banner");
const inhalt = screen.getByRole("main");
const footer = screen.getByRole("contentinfo");
expect(inhalt).not.toContainElement(kopf);
expect(inhalt).not.toContainElement(footer);
expect(kopf.compareDocumentPosition(inhalt) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(inhalt.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe("Navigation", () => {
it("die Einträge der Kopfleiste verweisen auf die Anker der Startseite (Spezifikation 6.1)", () => {
seite("/");
const nav = within(screen.getByRole("banner")).getByRole("navigation", { name: "Hauptnavigation" });
const erwartet = NAVIGATION.map(({ label, to }) => [label, to]);
expect(erwartet).toEqual(
expect.arrayContaining([
["Kundenportal", "/#kundenportal"],
["Was wir bauen", "/#leistungen"],
["Preise", "/#preise"],
["Über uns", "/#ueber-uns"],
]),
);
for (const [name, ziel] of erwartet) {
expect(within(nav).getByRole("link", { name })).toHaveAttribute("href", ziel);
}
});
it("jede Unterseite lädt keine eigene Kopfzeile mit Zurück-Knopf mehr (alter glass-nav-Block)", () => {
for (const pfad of UNTERSEITEN) {
const { container } = seite(pfad);
expect(container.querySelector(".glass-nav"), pfad).toBeNull();
expect(screen.queryByRole("link", { name: "Zurück" }), pfad).toBeNull();
expect(screen.queryByRole("button", { name: "Zurück" }), pfad).toBeNull();
cleanup();
}
});
});
describe("Handy-Menü", () => {
it("der Menü-Knopf hat aria-expanded und öffnet und schließt das Menü", () => {
seite("/kontakt");
const knopf = screen.getByRole("button", { name: "Menü öffnen" });
expect(knopf).toHaveAttribute("aria-expanded", "false");
expect(knopf).toHaveAttribute("aria-controls");
expect(screen.queryByRole("navigation", { name: "Menü" })).toBeNull();
fireEvent.click(knopf);
const geoeffnet = screen.getByRole("button", { name: "Menü schließen" });
expect(geoeffnet).toHaveAttribute("aria-expanded", "true");
const menue = screen.getByRole("navigation", { name: "Menü" });
expect(document.getElementById(geoeffnet.getAttribute("aria-controls") as string)).toContainElement(menue);
for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) {
expect(within(menue).getByRole("link", { name })).toBeInTheDocument();
}
// Seite ist gesperrt, solange das Menü offen ist
expect(document.body.style.overflow).toBe("hidden");
fireEvent.click(geoeffnet);
expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false");
expect(document.body.style.overflow).not.toBe("hidden");
});
it("Escape schließt das Menü und gibt den Fokus an den Knopf zurück", () => {
seite("/");
fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" }));
expect(screen.getByRole("button", { name: "Menü schließen" })).toHaveAttribute("aria-expanded", "true");
fireEvent.keyDown(window, { key: "Escape" });
const knopf = screen.getByRole("button", { name: "Menü öffnen" });
expect(knopf).toHaveAttribute("aria-expanded", "false");
expect(knopf).toHaveFocus();
});
it("enthält den Button „Projekt besprechen“ und den Hell/Dunkel-Schalter; ein Klick auf einen Link schließt es", () => {
seite("/impressum");
fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" }));
const menue = screen.getByRole("navigation", { name: "Menü" }).parentElement as HTMLElement;
expect(within(menue).getByRole("link", { name: "Projekt besprechen" })).toHaveAttribute("href", "/kontakt");
expect(within(menue).getByRole("button", { name: /Modus einschalten/ })).toBeInTheDocument();
fireEvent.click(within(menue).getByRole("link", { name: "Preise" }));
expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false");
});
it("Menü-Knopf und Schalter sind mindestens 44 × 44 px groß (Spezifikation 9.6)", () => {
const css = lies("src/components/site/site.css");
for (const klasse of [".wk-header__menuknopf", ".wk-icon-btn"]) {
const block = css.match(new RegExp(`${klasse.replace(".", "\\.")}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
expect(block, klasse).toMatch(/width:\s*44px/);
expect(block, klasse).toMatch(/height:\s*44px/);
}
});
});
describe("/kontakt im neuen Layout", () => {
it("hat den Datenschutzhinweis mit Link und nennt das Wort »Potenzialanalyse« nirgends", () => {
seite("/kontakt");
const formular = screen.getByRole("button", { name: "Nachricht senden" }).closest("form") as HTMLFormElement;
expect(formular).toHaveTextContent(
"Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer Datenschutzerklärung.",
);
expect(within(formular).getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute(
"href",
"https://project.webklar.com/datenschutz.html",
);
expect(document.body).not.toHaveTextContent(/Potenzialanalyse/);
});
it("Felder haben Beschriftung, passendes autocomplete und mindestens 16 px Schrift", () => {
seite("/kontakt");
expect(screen.getByLabelText(/^Name/)).toHaveAttribute("autocomplete", "name");
expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("autocomplete", "email");
expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("type", "email");
expect(screen.getByLabelText(/^Unternehmen/)).toHaveAttribute("autocomplete", "organization");
for (const name of [/^Name/, /^E-Mail/, /^Nachricht/]) expect(screen.getByLabelText(name)).toBeRequired();
expect(screen.getByLabelText(/^Unternehmen/)).not.toBeRequired();
// iOS zoomt bei weniger als 16 px: rem-Wert der Felder prüfen
const css = lies("src/pages/unterseiten.css");
const block = css.match(/\.wk-feld input,\s*\.wk-feld textarea\s*\{([^}]*)\}/)?.[1] ?? "";
const rem = Number(block.match(/font-size:\s*([\d.]+)rem/)?.[1]);
expect(rem * 16).toBeGreaterThanOrEqual(16);
});
it("Senden-Knopf ist ein wk-btn, E-Mail und Telefon stehen genau einmal da", () => {
seite("/kontakt");
expect(screen.getByRole("button", { name: "Nachricht senden" })).toHaveClass("wk-btn", "wk-btn--primary");
const haupt = screen.getByRole("main");
expect(within(haupt).getAllByRole("link", { name: "support@webklar.com" })).toHaveLength(1);
const telefon = within(haupt).getAllByRole("link", { name: "0176 23726355" });
expect(telefon).toHaveLength(1);
expect(telefon[0]).toHaveAttribute("href", "tel:+4917623726355");
expect(within(haupt).getByRole("link", { name: "support@webklar.com" })).toHaveAttribute(
"href",
"mailto:support@webklar.com",
);
});
it("die Rückmeldung steht in einem aria-live-Bereich, der schon vor dem Absenden im Dokument ist", () => {
seite("/kontakt");
const bereich = document.querySelector("[aria-live]");
expect(bereich).not.toBeNull();
expect(bereich).toBeEmptyDOMElement();
expect(bereich).toHaveAttribute("aria-live", "polite");
});
});
describe("Quelltext der Unterseiten", () => {
const SEITEN = ["Contact", "About", "NotFound", "AGB", "Impressum", "WartungsAbo", "HostingAbo"].map((name) => ({
name,
text: lies(`src/pages/${name}.tsx`),
}));
it.each(SEITEN)("$name: kein eigenes main, keine eigene Kopfleiste, keine Versalien, keine Alt-Buttons, keine Effekte", ({ text }) => {
expect(text).not.toMatch(/<main\b/);
expect(text).not.toMatch(/<header\b/);
expect(text).not.toMatch(/\buppercase\b|tracking-(wider|widest)|font-display|label-tag/);
expect(text).not.toMatch(/glass-nav|btn-minimal|btn-wrapper|className="btn\b/);
expect(text).not.toMatch(/BorderGlow|CountUp|FlickeringGrid|PixelBlast|LightRays|Silk/);
// Seitenrahmen kommt aus dem neuen Layout
expect(text).toMatch(/wk-seite/);
});
it("die Seiten tragen ihr Stylesheet selbst und laden nichts von fremden Servern", () => {
const css = lies("src/pages/unterseiten.css");
expect(css).not.toMatch(/https?:\/\/|@import/);
for (const { name, text } of SEITEN) expect(text, name).toContain('import "./unterseiten.css"');
});
it("Bewegung nur über transform und opacity, Hover nur mit feinem Zeiger", () => {
const css = lies("src/pages/unterseiten.css");
// alle Übergänge der Unterseiten betreffen nur Farben (kein Layout)
for (const [, wert] of css.matchAll(/transition:\s*([^;]+);/g)) {
expect(wert).toMatch(/^(border-color|text-decoration-color)\b/);
}
// oberste Blöcke (Regel oder @media) zerlegen; :hover darf nur in @media (hover: hover) and (pointer: fine) stehen
const bloecke: { kopf: string; inhalt: string }[] = [];
let tiefe = 0;
let start = 0;
let kopfStart = 0;
let kopf = "";
const ohneKommentare = css.replace(/\/\*[\s\S]*?\*\//g, "");
for (let i = 0; i < ohneKommentare.length; i++) {
const z = ohneKommentare[i];
if (z === "{") {
if (tiefe === 0) {
kopf = ohneKommentare.slice(kopfStart, i).trim();
start = i + 1;
}
tiefe++;
} else if (z === "}") {
tiefe--;
if (tiefe === 0) {
bloecke.push({ kopf, inhalt: ohneKommentare.slice(start, i) });
kopfStart = i + 1;
}
}
}
expect(bloecke.length).toBeGreaterThan(20);
let hoverRegeln = 0;
for (const { kopf: k, inhalt } of bloecke) {
const text = `${k}${inhalt}`;
if (!text.includes(":hover")) continue;
hoverRegeln++;
expect(k, `${k} enthält :hover`).toBe("@media (hover: hover) and (pointer: fine)");
}
expect(hoverRegeln).toBeGreaterThan(0);
});
});