Footer-Anker von Unterseiten, 404 ohne Konsolenfehler, Datenschutz-Link im neuen Tab

ScrollToTop springt bei /#services nach dem Laden der Schriften zum Abschnitt
(vorher landete man oben auf der Startseite bzw. 220 px zu weit unten).
Dazu Quelltext-Test gegen 99,9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-01 13:20:06 +00:00
1 parent e6d603cd0c
commit d78338a969
4 files changed
+85 -18

No files matched your search

+31 -3
View File
@@ -10,11 +10,39 @@ import { AppRoutes } from "./routes";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
/** Scrollt bei einem Seitenwechsel nach oben; Anker-Links (URL mit #) bleiben unberührt. */ /**
const ScrollToTop = () => { * Scrollt bei einem Seitenwechsel nach oben. Mit Anker (URL mit #) springt es zum Abschnitt,
* sobald er gerendert ist, z. B. beim Footer-Link „/#services“ von einer Unterseite aus.
*/
export const ScrollToTop = () => {
const { pathname, hash } = useLocation(); const { pathname, hash } = useLocation();
useEffect(() => { useEffect(() => {
if (!hash) window.scrollTo(0, 0); if (!hash) {
window.scrollTo(0, 0);
return;
}
const id = decodeURIComponent(hash.slice(1));
let versuche = 0;
let frame = 0;
let abgebrochen = false;
const springe = () => {
const ziel = document.getElementById(id);
if (!ziel) {
if (versuche++ < 60) frame = requestAnimationFrame(springe); // höchstens ca. 1 s warten
return;
}
// Erst springen, wenn die Schriften geladen sind: Vorher ist der Text darüber höher,
// und der Abschnitt landet nach dem Nachladen ein Stück zu weit oben.
const schriften = document.fonts?.ready ?? Promise.resolve();
schriften.then(() => {
if (!abgebrochen) ziel.scrollIntoView();
});
};
springe();
return () => {
abgebrochen = true;
cancelAnimationFrame(frame);
};
}, [pathname, hash]); }, [pathname, hash]);
return null; return null;
}; };
+3 -1
View File
@@ -64,7 +64,7 @@ const Contact = () => {
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group"> <Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight"> <span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar WEBklar
</span> </span>
</Link> </Link>
@@ -184,6 +184,8 @@ const Contact = () => {
Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer{" "} Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer{" "}
<a <a
href="https://project.webklar.com/datenschutz.html" href="https://project.webklar.com/datenschutz.html"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline underline-offset-4 hover:no-underline" className="text-foreground underline underline-offset-4 hover:no-underline"
> >
Datenschutzerklärung Datenschutzerklärung
+1 -7
View File
@@ -1,15 +1,9 @@
import { Link, useLocation } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect } from "react";
import { usePageMeta } from "@/hooks/use-page-meta"; import { usePageMeta } from "@/hooks/use-page-meta";
const NotFound = () => { const NotFound = () => {
const location = useLocation();
usePageMeta("Seite nicht gefunden"); usePageMeta("Seite nicht gefunden");
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return ( return (
<div className="flex min-h-[70vh] items-center justify-center bg-background px-6 text-foreground"> <div className="flex min-h-[70vh] items-center justify-center bg-background px-6 text-foreground">
<div className="text-center"> <div className="text-center">
+50 -7
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, fireEvent, act, cleanup } from "@testing-library/react"; import { render, screen, within, fireEvent, act, cleanup, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@@ -17,6 +17,7 @@ import KontaktAbschnitt from "@/components/Contact";
import About from "@/pages/About"; import About from "@/pages/About";
import { TextHoverEffect } from "@/components/ui/text-hover-effect"; import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ScrollToTop } from "@/App";
// WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und // WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und
// das Kontaktformular-Backend werden durch Attrappen ersetzt. // das Kontaktformular-Backend werden durch Attrappen ersetzt.
@@ -264,7 +265,6 @@ describe("C1 Footer auf allen Unterseiten", () => {
const unterseiten = ["/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"]; const unterseiten = ["/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"];
it.each(unterseiten)("%s zeigt genau einen Footer mit Impressum und Datenschutz", (pfad) => { it.each(unterseiten)("%s zeigt genau einen Footer mit Impressum und Datenschutz", (pfad) => {
vi.spyOn(console, "error").mockImplementation(() => {}); // die 404-Seite protokolliert den Aufruf
seite(pfad); seite(pfad);
const footer = screen.getByRole("contentinfo"); const footer = screen.getByRole("contentinfo");
expect(within(footer).getByRole("link", { name: "Impressum" })).toHaveAttribute("href", "/impressum"); expect(within(footer).getByRole("link", { name: "Impressum" })).toHaveAttribute("href", "/impressum");
@@ -327,7 +327,6 @@ const THEMEN: Thema[] = ["hell", "dunkel"];
describe("C5 404-Seite", () => { describe("C5 404-Seite", () => {
it("zeigt Überschrift, Erklärung und Link zur Startseite auf Deutsch", () => { it("zeigt Überschrift, Erklärung und Link zur Startseite auf Deutsch", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
seite("/gibt-es-nicht"); seite("/gibt-es-nicht");
expect(screen.getByRole("heading", { level: 1, name: "Seite nicht gefunden" })).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 1, name: "Seite nicht gefunden" })).toBeInTheDocument();
expect(screen.getByText("Diese Seite gibt es nicht oder nicht mehr.")).toBeInTheDocument(); expect(screen.getByText("Diese Seite gibt es nicht oder nicht mehr.")).toBeInTheDocument();
@@ -336,7 +335,6 @@ describe("C5 404-Seite", () => {
}); });
it("nutzt die Theme-Farben statt eines festen grauen Hintergrunds", () => { it("nutzt die Theme-Farben statt eines festen grauen Hintergrunds", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const { container } = seite("/gibt-es-nicht"); const { container } = seite("/gibt-es-nicht");
expect(container.querySelector(".bg-background.text-foreground")).not.toBeNull(); expect(container.querySelector(".bg-background.text-foreground")).not.toBeNull();
expect(container.querySelector(".bg-muted")).toBeNull(); expect(container.querySelector(".bg-muted")).toBeNull();
@@ -360,8 +358,7 @@ describe("C4 Schreibweise WEBklar", () => {
it.each(["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"])( it.each(["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"])(
"%s: die Marke steht als WEBklar da", "%s: die Marke steht als WEBklar da",
(pfad) => { (pfad) => {
vi.spyOn(console, "error").mockImplementation(() => {}); seite(pfad);
seite(pfad);
expect(screen.queryAllByText(/Webklar/)).toHaveLength(0); expect(screen.queryAllByText(/Webklar/)).toHaveLength(0);
expect(screen.getAllByText("WEBklar").length).toBeGreaterThan(0); expect(screen.getAllByText("WEBklar").length).toBeGreaterThan(0);
}, },
@@ -446,7 +443,6 @@ describe("C3 Seitentitel und Beschreibung der Unterseiten", () => {
}); });
it("die 404-Seite setzt den Titel »Seite nicht gefunden«", () => { it("die 404-Seite setzt den Titel »Seite nicht gefunden«", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
seite("/gibt-es-nicht"); seite("/gibt-es-nicht");
expect(document.title).toBe("Seite nicht gefunden | WEBklar"); expect(document.title).toBe("Seite nicht gefunden | WEBklar");
}); });
@@ -671,3 +667,50 @@ describe("A1 Karten (BorderGlow) im hellen Modus lesbar", () => {
expect(css).toMatch(/\.border-glow-card\s*>\s*\.edge-light\s*\{[^}]*mix-blend-mode:\s*plus-lighter/); expect(css).toMatch(/\.border-glow-card\s*>\s*\.edge-light\s*\{[^}]*mix-blend-mode:\s*plus-lighter/);
}); });
}); });
describe("Nacharbeit nach der Prüfung", () => {
it("99,9 steht nirgends mehr im Quelltext", () => {
expect(vorkommen(/99[,.]9/)).toEqual([]);
});
it("ScrollToTop springt bei einem Anker zum Abschnitt (Footer-Link »Leistungen« von einer Unterseite)", async () => {
const springen = vi.fn();
Element.prototype.scrollIntoView = springen;
render(
<MemoryRouter initialEntries={["/#services"]} future={routerFuture}>
<ScrollToTop />
<section id="services" />
</MemoryRouter>,
);
await waitFor(() => expect(springen).toHaveBeenCalledTimes(1));
expect(springen.mock.instances[0]).toBe(document.getElementById("services"));
});
it("ScrollToTop scrollt ohne Anker wie bisher nach oben", () => {
const nachOben = vi.fn();
vi.stubGlobal("scrollTo", nachOben);
render(
<MemoryRouter initialEntries={["/kontakt"]} future={routerFuture}>
<ScrollToTop />
</MemoryRouter>,
);
expect(nachOben).toHaveBeenCalledWith(0, 0);
});
it("die 404-Seite schreibt nichts in die Konsole", () => {
const fehler = vi.spyOn(console, "error");
seite("/gibt-es-nicht");
expect(fehler).not.toHaveBeenCalled();
});
it("der Datenschutz-Link am Formular öffnet einen neuen Tab, damit eine getippte Nachricht nicht verloren geht", () => {
seite("/kontakt");
const link = screen.getByRole("link", { name: "Datenschutzerklärung" });
expect(link).toHaveAttribute("target", "_blank");
expect(link.getAttribute("rel")).toMatch(/noopener/);
});
it("der Markenname steht nicht mehr mit enger Laufweite da (B und k berührten sich)", () => {
expect(vorkommen(/tracking-tight[^"]*">\s*WEBklar\s*</)).toEqual([]);
});
});