Compare commits

...
10 Commits
Author SHA1 Message Date
knsoandClaude Opus 5.5 0546ae396f Archiv-Hinweis: Stand vor dem Relaunch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:03:05 +00:00
rootandClaude Opus 5.5 80925c2a43 Kaltakquise-Vorlage: gültige Telefonnummer 0176 23726355
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:20:06 +00:00
rootandClaude Opus 5.5 b46527e65d Markenname WEBklar mit normaler Laufweite (B und k berührten sich)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:20:06 +00:00
rootandClaude Opus 5.5 d78338a969 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>
2026-10-01 13:20:06 +00:00
rootandClaude Opus 5.5 e6d603cd0c Kontaktkarte: CTA-Button mit 4,5:1 Kontrast (Weiß auf dunklerem Blau)
Weiß auf #0793CF kam nur auf 3,45:1 (hell und dunkel). Der Button auf der
Kontaktkarte nutzt jetzt hsl(198 93% 35%) = 4,76:1, beim Hovern
hsl(198 93% 30%) = 6,08:1. Der Test nimmt den Button nicht mehr aus,
sondern prüft Ruhe- und Hover-Farbe aller Buttons auf den Karten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:10:27 +00:00
rootandClaude Sonnet 5.5 003f9e67aa Tests: 404-Konsolenrauschen unterdrückt, Glow-Beschreibung präzisiert
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:56:02 +00:00
rootandClaude Sonnet 5.5 897d594dde Karten im hellen Modus lesbar: Kartenfarbe nach Theme
Die BorderGlow-Karten in Values, Kontaktabschnitt und Über uns hatten
dunkle Hintergründe fest verdrahtet, ihr Text nutzt aber Theme-Farben
(Kontrast 1,07:1 im hellen Modus). BorderGlow kennt jetzt
lightBackgroundColor und wählt die Kartenfarbe per CSS nach Theme
(--card-bg-dark / --card-bg-light, html:not(.dark)), ohne Flackern beim
Laden. Dunkel behält exakt die bisherigen Farben (hsl(0 0% 6%), #120F17,
hsl(0 0% 4%)); hell nutzt hsl(0 0% 96%) mit sichtbarem Rand. Der
Kantenschein wird im hellen Modus normal statt mit plus-lighter gemischt
und in Violett gezeichnet, sonst wäre er auf Weiß unsichtbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:48:36 +00:00
rootandClaude Sonnet 5.5 194301c6af Glow-Überschriften: Fix A aus der Hover-Analyse
Der Glow der Überschriften (Leistungen, Ablauf) wacht jetzt etwa 180 px
vor der SVG-Box auf (Zeiger-Tracking am window, nur feine Zeiger), bildet
den Mauszeiger über getScreenCTM korrekt auf die viewBox ab, blendet
weich per Opacity ein und aus und klebt auf Touch-Geräten nicht mehr.
Screenreader hören das Wort einmal (role=img mit aria-label, Textknoten
aria-hidden). Der Diff aus /root/reports/webklar-relaunch-2026-10-01/
hover/fix/text-hover-effect.fixA.diff ließ sich unverändert anwenden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:43:43 +00:00
rootandClaude Sonnet 5.5 0d12bbc06a Navigation: Theme-Schalter und Kontakt-Button bekommen wieder Mausklicks
Der Link-Container der Desktop-Navigation ist "absolute inset-0" (hält die
Links mittig), lag dadurch aber über .navbar-actions und schluckte alle
Mausklicks auf Theme-Schalter und Kontakt-Button ab 1024 px. Der Container
ist jetzt pointer-events-none, die Links pointer-events-auto; Position und
Optik bleiben identisch, die Hover-Pille der Links funktioniert weiter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:43:29 +00:00
rootandClaude Sonnet 5.5 b2e17d96c2 Hintergrundbilder nur im Dark Mode
problem_blitz.jpg, loesung.jpg und backgroud_effect.webp sind dunkle
Bilder mit 30 % Deckkraft und wurden im hellen Modus zu harten grauen
Quadraten. Die Bild-Ebenen sind jetzt "hidden dark:block"; der dunkle
Modus bleibt unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:43:16 +00:00
21 changed files with 428 additions and 61 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"enabled": false, "enabled": false,
"type": "static", "type": "static",
"branch": "main", "branch": "main",
"displayName": "Webklar", "displayName": "webklar.com alt (Archiv)",
"templateName": "webklar-preview-template", "templateName": "webklar-preview-template",
"projectType": "project", "projectType": "project",
"public": false, "public": false,
+9
View File
@@ -0,0 +1,9 @@
# Archiv: webklar.com vor dem Relaunch
Dieses Repo ist eine schreibgeschützte Kopie der Website, die bis zum 04.10.2026 auf webklar.com lief.
- Quelle: `JUSN/Webklar`, Branch `main`, Commit `80925c2` (Tag `vor-relaunch-2026-10-04`, auch im Quell-Repo)
- Danach ging dort der Relaunch live.
- Die gebauten Dateien von damals liegen auf dem Server unter `/root/reports/webklar-alt-2026-10-04/out-live-80925c2.tar.gz`.
Wiederherstellen: Tag `vor-relaunch-2026-10-04` in `JUSN/Webklar` nach `main` bringen, der Deploy baut dann die alte Seite.
+1 -1
View File
@@ -1,7 +1,7 @@
# WEBklar – Cold-Email-Paket (B2B KMU) # WEBklar – Cold-Email-Paket (B2B KMU)
Ziel: **kurze Antwort** oder **15-min. Potenzialanalyse** (unverbindlich). Ziel: **kurze Antwort** oder **15-min. Potenzialanalyse** (unverbindlich).
Link: https://webklar.com/kontakt · E-Mail: support@webklar.com · Tel. 0170 4969375 Link: https://webklar.com/kontakt · E-Mail: support@webklar.com · Tel. 0176 23726355
--- ---
+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;
}; };
+22
View File
@@ -6,6 +6,8 @@
--border-radius: 28px; --border-radius: 28px;
--glow-padding: 40px; --glow-padding: 40px;
--cone-spread: 25; --cone-spread: 25;
/* Kartenfarbe nach Theme: --card-bg-dark / --card-bg-light kommen aus BorderGlow.tsx */
--card-bg: var(--card-bg-dark, #000);
position: relative; position: relative;
border-radius: var(--border-radius); border-radius: var(--border-radius);
isolation: isolate; isolation: isolate;
@@ -25,6 +27,12 @@
rgba(0, 0, 0, 0.1) 0px 32px 64px; rgba(0, 0, 0, 0.1) 0px 32px 64px;
} }
/* Heller Modus: helle Kartenfarbe und ein sichtbarer Rand (weiß mit 8 % Deckkraft wäre auf Weiß unsichtbar) */
html:not(.dark) .border-glow-card {
--card-bg: var(--card-bg-light, var(--card-bg-dark, #000));
border-color: hsl(var(--border));
}
.border-glow-card::before, .border-glow-card::before,
.border-glow-card::after, .border-glow-card::after,
.border-glow-card > .edge-light { .border-glow-card > .edge-light {
@@ -147,6 +155,20 @@
0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%)); 0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%));
} }
/* Heller Modus: "plus-lighter" ginge auf einer hellen Karte im Weiß unter, und die blasse Glow-Farbe wäre kaum sichtbar.
Der Kantenschein wird deshalb normal gemischt und in einem kräftigeren Violett gezeichnet. */
html:not(.dark) .border-glow-card > .edge-light {
mix-blend-mode: normal;
}
html:not(.dark) .border-glow-card > .edge-light::before {
box-shadow:
inset 0 0 0 1px hsl(268deg 83% 62% / 70%),
inset 0 0 3px 0 hsl(268deg 83% 62% / 45%),
inset 0 0 12px 0 hsl(268deg 83% 62% / 30%),
inset 0 0 25px 2px hsl(268deg 83% 62% / 15%);
}
.border-glow-inner { .border-glow-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+6 -1
View File
@@ -56,7 +56,10 @@ interface BorderGlowProps {
className?: string; className?: string;
edgeSensitivity?: number; edgeSensitivity?: number;
glowColor?: string; glowColor?: string;
/** Kartenfarbe im dunklen Modus (und Standard, wenn lightBackgroundColor fehlt). */
backgroundColor?: string; backgroundColor?: string;
/** Kartenfarbe im hellen Modus; ohne Angabe gilt backgroundColor in beiden Themes. */
lightBackgroundColor?: string;
borderRadius?: number; borderRadius?: number;
glowRadius?: number; glowRadius?: number;
glowIntensity?: number; glowIntensity?: number;
@@ -72,6 +75,7 @@ const BorderGlow = ({
edgeSensitivity = 30, edgeSensitivity = 30,
glowColor = '40 80 80', glowColor = '40 80 80',
backgroundColor = '#120F17', backgroundColor = '#120F17',
lightBackgroundColor,
borderRadius = 28, borderRadius = 28,
glowRadius = 40, glowRadius = 40,
glowIntensity = 1.0, glowIntensity = 1.0,
@@ -183,7 +187,8 @@ const BorderGlow = ({
onPointerMove={animated ? undefined : handlePointerMove} onPointerMove={animated ? undefined : handlePointerMove}
className={`border-glow-card ${className}`} className={`border-glow-card ${className}`}
style={{ style={{
'--card-bg': backgroundColor, '--card-bg-dark': backgroundColor,
'--card-bg-light': lightBackgroundColor ?? backgroundColor,
'--edge-sensitivity': edgeSensitivity, '--edge-sensitivity': edgeSensitivity,
'--border-radius': `${borderRadius}px`, '--border-radius': `${borderRadius}px`,
'--glow-padding': `${glowRadius}px`, '--glow-padding': `${glowRadius}px`,
+4 -1
View File
@@ -12,6 +12,7 @@ const Contact = () => {
edgeSensitivity={30} edgeSensitivity={30}
glowColor="40 80 80" glowColor="40 80 80"
backgroundColor="#120F17" backgroundColor="#120F17"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={28} borderRadius={28}
glowRadius={40} glowRadius={40}
glowIntensity={1} glowIntensity={1}
@@ -38,9 +39,11 @@ const Contact = () => {
data-analytics-cta="Potenzialanalyse starten" data-analytics-cta="Potenzialanalyse starten"
data-analytics-location="contact_section" data-analytics-location="contact_section"
> >
{/* Etwas dunkleres Blau als der Standard-Button (3,45:1 -> 4,76:1 mit weißem Text, Hover 6,08:1);
die Farbe gilt in beiden Themes, weil der Button nicht von Theme-Farben abhängt. */}
<Button <Button
size="lg" size="lg"
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group" className="btn-minimal rounded-full px-8 py-6 text-base font-medium group bg-[hsl(198,93%,35%)] hover:bg-[hsl(198,93%,30%)]"
> >
Potenzialanalyse starten Potenzialanalyse starten
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
+2 -2
View File
@@ -32,7 +32,7 @@ const Header = () => {
<NavBody> <NavBody>
<NavbarLogo href="#"> <NavbarLogo href="#">
<Logo width={30} height={30} /> <Logo width={30} height={30} />
<span className="font-display text-lg font-medium tracking-tight"> <span className="font-display text-lg font-medium tracking-normal">
WEBklar WEBklar
</span> </span>
</NavbarLogo> </NavbarLogo>
@@ -59,7 +59,7 @@ const Header = () => {
<MobileNavHeader> <MobileNavHeader>
<NavbarLogo href="#"> <NavbarLogo href="#">
<Logo width={30} height={30} /> <Logo width={30} height={30} />
<span className="font-display text-lg font-medium tracking-tight"> <span className="font-display text-lg font-medium tracking-normal">
WEBklar WEBklar
</span> </span>
</NavbarLogo> </NavbarLogo>
+2 -2
View File
@@ -58,9 +58,9 @@ const ProblemSection = () => {
</Suspense> </Suspense>
</div> </div>
)} )}
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */} {/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
<div <div
className="problem-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0" className="problem-section-bg hidden dark:block absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0"
style={{ style={{
backgroundImage: "url(/problem_blitz.jpg)", backgroundImage: "url(/problem_blitz.jpg)",
}} }}
+2 -2
View File
@@ -47,9 +47,9 @@ const SolutionSection = () => {
</Suspense> </Suspense>
</div> </div>
)} )}
{/* Hintergrundbild mittig, auf Handy maximale Breite */} {/* Hintergrundbild mittig, auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
<div <div
className="solution-section-bg absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0" className="solution-section-bg hidden dark:block absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0"
style={{ style={{
backgroundImage: "url(/loesung.jpg)", backgroundImage: "url(/loesung.jpg)",
}} }}
+3 -2
View File
@@ -42,9 +42,9 @@ const Values = () => {
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10"> <div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
<TextHoverEffect text="Leistungen" /> <TextHoverEffect text="Leistungen" />
</div> </div>
{/* Hintergrundbild: auf Handy maximale Breite */} {/* Hintergrundbild: auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
<div <div
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none" className="values-section-bg hidden dark:block absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
style={{ style={{
backgroundImage: "url(/backgroud_effect.webp)", backgroundImage: "url(/backgroud_effect.webp)",
}} }}
@@ -69,6 +69,7 @@ const Values = () => {
edgeSensitivity={30} edgeSensitivity={30}
glowColor="40 80 80" glowColor="40 80 80"
backgroundColor="hsl(0 0% 6%)" backgroundColor="hsl(0 0% 6%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8} borderRadius={8}
glowRadius={30} glowRadius={30}
glowIntensity={0.8} glowIntensity={0.8}
+4 -2
View File
@@ -105,7 +105,9 @@ export const NavItems = ({
<motion.div <motion.div
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
className={cn( className={cn(
"absolute inset-0 hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2", // absolute inset-0 hält die Links mittig, liegt aber über Theme-Schalter und Kontakt-Button:
// der Container nimmt deshalb keine Mausklicks an (pointer-events-none), nur die Links selbst.
"pointer-events-none absolute inset-0 hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2",
className className
)} )}
> >
@@ -114,7 +116,7 @@ export const NavItems = ({
const commonProps = { const commonProps = {
onMouseEnter: () => setHovered(idx), onMouseEnter: () => setHovered(idx),
onClick: onItemClick, onClick: onItemClick,
className: "relative px-4 py-2 text-neutral-900 dark:text-neutral-300", className: "pointer-events-auto relative px-4 py-2 text-neutral-900 dark:text-neutral-300",
key: `link-${idx}`, key: `link-${idx}`,
}; };
const inner = ( const inner = (
+65 -25
View File
@@ -1,8 +1,12 @@
"use client"; "use client";
import { useRef, useState, useCallback, useId } from "react"; import { useRef, useState, useEffect, useId } from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
// Wie weit (in CSS-px) um die SVG-Box herum der Mauszeiger den Glow schon weckt.
// Der Glow-Radius ist 80 viewBox-Einheiten (~300 px), dahinter ist er ohnehin schwarz.
const WAKE_DISTANCE_PX = 260;
export const TextHoverEffect = ({ export const TextHoverEffect = ({
text, text,
}: { }: {
@@ -10,24 +14,64 @@ export const TextHoverEffect = ({
duration?: number; duration?: number;
}) => { }) => {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const [hovered, setHovered] = useState(false); const [active, setActive] = useState(false);
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 }); const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
const id = useId(); const id = useId();
const gradientId = `textGradient-${id}`; const gradientId = `textGradient-${id}`;
const revealMaskId = `revealMask-${id}`; const revealMaskId = `revealMask-${id}`;
const textMaskId = `textMask-${id}`; const textMaskId = `textMask-${id}`;
const handleMouseMove = useCallback( useEffect(() => {
(e: React.MouseEvent<SVGSVGElement>) => { const svg = svgRef.current;
if (!svgRef.current) return; // Nur echte Maus: Touch hat kein Hover und wuerde den Glow "kleben" lassen.
const rect = svgRef.current.getBoundingClientRect(); if (!svg || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
// Map screen coords to viewBox coords (0 0 300 60)
const x = ((e.clientX - rect.left) / rect.width) * 300; let last: { x: number; y: number } | null = null;
const y = ((e.clientY - rect.top) / rect.height) * 60; let raf = 0;
setCursorPos({ x, y }); let isActive = false;
},
[] const update = () => {
); raf = 0;
if (!last) return;
const r = svg.getBoundingClientRect();
const dx = Math.max(r.left - last.x, 0, last.x - r.right);
const dy = Math.max(r.top - last.y, 0, last.y - r.bottom);
const near = Math.hypot(dx, dy) <= WAKE_DISTANCE_PX;
if (near !== isActive) {
isActive = near;
setActive(near);
}
const m = svg.getScreenCTM();
if (near && m) {
// echte Bildschirm -> viewBox-Abbildung (beruecksichtigt "meet"-Letterbox)
const p = new DOMPoint(last.x, last.y).matrixTransform(m.inverse());
setCursorPos({ x: p.x, y: p.y });
}
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(update);
};
const onMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
last = { x: e.clientX, y: e.clientY };
schedule();
};
const onLeave = () => {
last = null;
isActive = false;
setActive(false);
};
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("scroll", schedule, { passive: true });
document.documentElement.addEventListener("mouseleave", onLeave);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("scroll", schedule);
document.documentElement.removeEventListener("mouseleave", onLeave);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return ( return (
<svg <svg
@@ -36,9 +80,8 @@ export const TextHoverEffect = ({
height="100%" height="100%"
viewBox="0 0 300 60" viewBox="0 0 300 60"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
onMouseEnter={() => setHovered(true)} role="img"
onMouseLeave={() => setHovered(false)} aria-label={text}
onMouseMove={handleMouseMove}
className="select-none" className="select-none"
> >
<defs> <defs>
@@ -62,13 +105,7 @@ export const TextHoverEffect = ({
</radialGradient> </radialGradient>
<mask id={textMaskId}> <mask id={textMaskId}>
<rect <rect x="0" y="0" width="300" height="60" fill={`url(#${revealMaskId})`} />
x="0"
y="0"
width="300"
height="60"
fill={hovered ? `url(#${revealMaskId})` : "black"}
/>
</mask> </mask>
</defs> </defs>
@@ -79,6 +116,7 @@ export const TextHoverEffect = ({
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
strokeWidth="0.3" strokeWidth="0.3"
aria-hidden="true"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800" className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ opacity: 0.15, fontSize: "2rem" }} style={{ opacity: 0.15, fontSize: "2rem" }}
> >
@@ -92,6 +130,7 @@ export const TextHoverEffect = ({
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
strokeWidth="0.3" strokeWidth="0.3"
aria-hidden="true"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800" className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ fontSize: "2rem" }} style={{ fontSize: "2rem" }}
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }} initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
@@ -101,7 +140,7 @@ export const TextHoverEffect = ({
{text} {text}
</motion.text> </motion.text>
{/* Colored gradient revealed on hover */} {/* Colored gradient revealed near the pointer */}
<text <text
x="150" x="150"
y="30" y="30"
@@ -110,8 +149,9 @@ export const TextHoverEffect = ({
stroke={`url(#${gradientId})`} stroke={`url(#${gradientId})`}
strokeWidth="0.3" strokeWidth="0.3"
mask={`url(#${textMaskId})`} mask={`url(#${textMaskId})`}
aria-hidden="true"
className="font-display font-bold fill-transparent" className="font-display font-bold fill-transparent"
style={{ fontSize: "2rem" }} style={{ fontSize: "2rem", opacity: active ? 1 : 0, transition: "opacity 300ms ease-out" }}
> >
{text} {text}
</text> </text>
+1 -1
View File
@@ -17,7 +17,7 @@ const AGB = () => {
<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>
+4 -1
View File
@@ -18,7 +18,7 @@ const About = () => {
<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>
@@ -105,6 +105,7 @@ const About = () => {
edgeSensitivity={30} edgeSensitivity={30}
glowColor="40 80 80" glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)" backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8} borderRadius={8}
glowRadius={30} glowRadius={30}
glowIntensity={0.8} glowIntensity={0.8}
@@ -128,6 +129,7 @@ const About = () => {
edgeSensitivity={30} edgeSensitivity={30}
glowColor="40 80 80" glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)" backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8} borderRadius={8}
glowRadius={30} glowRadius={30}
glowIntensity={0.8} glowIntensity={0.8}
@@ -151,6 +153,7 @@ const About = () => {
edgeSensitivity={30} edgeSensitivity={30}
glowColor="40 80 80" glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)" backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8} borderRadius={8}
glowRadius={30} glowRadius={30}
glowIntensity={0.8} glowIntensity={0.8}
+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 -1
View File
@@ -40,7 +40,7 @@ const HostingAbo = () => {
<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">WEBklar</span> <span className="text-xl font-display font-medium text-foreground tracking-normal">WEBklar</span>
</Link> </Link>
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground"> <Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
<Link to="/"> <Link to="/">
+1 -1
View File
@@ -17,7 +17,7 @@ const Impressum = () => {
<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>
+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">
+1 -1
View File
@@ -40,7 +40,7 @@ const WartungsAbo = () => {
<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">WEBklar</span> <span className="text-xl font-display font-medium text-foreground tracking-normal">WEBklar</span>
</Link> </Link>
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground"> <Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
<Link to="/"> <Link to="/">
+264 -6
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.
@@ -326,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();
@@ -335,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();
@@ -359,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);
}, },
@@ -445,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");
}); });
@@ -456,3 +453,264 @@ describe("A5 Anker landen nicht unter dem fixen Header", () => {
expect(lies("src/index.css")).toMatch(/section\[id\]\s*\{[^}]*scroll-margin-top:\s*6rem/); expect(lies("src/index.css")).toMatch(/section\[id\]\s*\{[^}]*scroll-margin-top:\s*6rem/);
}); });
}); });
describe("A2 Hintergrundbilder nur im dunklen Modus", () => {
const BILDER: { name: string; ui: React.ReactElement; datei: string }[] = [
{ name: "ProblemSection", ui: <ProblemSection />, datei: "problem_blitz.jpg" },
{ name: "SolutionSection", ui: <SolutionSection />, datei: "loesung.jpg" },
{ name: "Values", ui: <Values />, datei: "backgroud_effect.webp" },
];
it.each(BILDER)("$name: $datei ist im hellen Modus ausgeblendet und im dunklen sichtbar", ({ ui, datei }) => {
const { container } = zeige(ui);
const bild = container.querySelector<HTMLElement>(`[style*="${datei}"]`);
expect(bild).not.toBeNull();
expect(bild).toHaveClass("hidden", "dark:block");
});
});
describe("A3 Navigation: Theme-Schalter, Kontakt und Logo bekommen echte Mausklicks", () => {
it("der absolut gelegte Link-Container fängt keine Mausklicks ab, die Links selbst schon", () => {
zeige(<Header />);
const link = screen.getAllByRole("link", { name: "Leistungen" })[0];
const container = link.parentElement as HTMLElement;
// bleibt mittig über die ganze Leiste gelegt (Optik unverändert) …
expect(container).toHaveClass("absolute", "inset-0", "justify-center");
// … lässt Klicks aber zum Theme-Schalter, zum Kontakt-Button und zum Logo durch
expect(container).toHaveClass("pointer-events-none");
for (const name of ["Über uns", "Leistungen", "Ablauf"]) {
expect(screen.getAllByRole("link", { name })[0]).toHaveClass("pointer-events-auto");
}
});
});
describe("A4 Glow-Überschrift (Fix A)", () => {
const BOX = { left: 100, top: 400, right: 1300, bottom: 624, width: 1200, height: 224, x: 100, y: 400 };
/** Browser-Umgebung für die Überschrift: Maus-Gerät, feste Box, manuell abgespielte Animation-Frames. */
const umgebung = (feinerZeiger = true) => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => frames.push(cb));
vi.spyOn(window, "matchMedia").mockImplementation(
(query) =>
({
matches: feinerZeiger,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
);
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ ...BOX, toJSON: () => ({}) });
// jsdom hat weder getScreenCTM noch DOMPoint: Abbildung Bildschirm -> viewBox (300 x 60) mit Faktor 4
Object.defineProperty(SVGSVGElement.prototype, "getScreenCTM", {
configurable: true,
value: () => ({ inverse: () => ({}) }),
});
vi.stubGlobal(
"DOMPoint",
class {
constructor(
public x: number,
public y: number,
) {}
matrixTransform() {
return { x: (this.x - BOX.left) / 4, y: (this.y - BOX.top) / 4 };
}
},
);
return () => act(() => frames.splice(0).forEach((cb) => cb(0)));
};
afterEach(() => {
delete (SVGSVGElement.prototype as { getScreenCTM?: unknown }).getScreenCTM;
});
/** jsdom kennt keine PointerEvents: MouseEvent mit pointerType reicht dem Fix. */
const zeiger = (x: number, y: number, pointerType = "mouse") => {
const e = new MouseEvent("pointermove", { clientX: x, clientY: y, bubbles: true });
Object.defineProperty(e, "pointerType", { value: pointerType });
window.dispatchEvent(e);
};
const glow = () => document.querySelector("svg text[mask]") as SVGTextElement;
const mitte = () => document.querySelector("radialGradient") as SVGRadialGradientElement;
it("wird als ein Bild vorgelesen, die Textknoten sind für Screenreader versteckt", () => {
umgebung();
render(<TextHoverEffect text="Leistungen" />);
expect(screen.getByRole("img", { name: "Leistungen" })).toBeInTheDocument();
const texte = [...document.querySelectorAll("svg text")];
expect(texte).toHaveLength(3);
for (const t of texte) expect(t).toHaveAttribute("aria-hidden", "true");
});
it("weckt den Glow spätestens 180 px vor der Box (Aufwachradius 260 px) und bildet den Zeiger korrekt auf die viewBox ab", () => {
const abspielen = umgebung();
render(<TextHoverEffect text="Leistungen" />);
expect(glow().style.opacity).toBe("0");
expect(glow().style.transition).toContain("opacity");
act(() => zeiger(700, BOX.top - 180));
abspielen();
expect(glow().style.opacity).toBe("1");
expect(mitte().getAttribute("cx")).toBe("150");
expect(mitte().getAttribute("cy")).toBe("-45");
act(() => zeiger(700, BOX.top - 400));
abspielen();
expect(glow().style.opacity).toBe("0");
});
it("Touch lässt den Glow nicht kleben, und Verlassen der Seite schaltet ihn aus", () => {
const abspielen = umgebung();
render(<TextHoverEffect text="Leistungen" />);
act(() => zeiger(700, BOX.top + 100, "touch"));
abspielen();
expect(glow().style.opacity).toBe("0");
act(() => zeiger(700, BOX.top + 100));
abspielen();
expect(glow().style.opacity).toBe("1");
act(() => {
document.documentElement.dispatchEvent(new MouseEvent("mouseleave"));
});
expect(glow().style.opacity).toBe("0");
});
it("auf Geräten ohne feinen Zeiger (Touch) werden gar keine Zeiger-Listener angehängt", () => {
const abspielen = umgebung(false);
const hinzu = vi.spyOn(window, "addEventListener");
render(<TextHoverEffect text="Leistungen" />);
expect(hinzu.mock.calls.filter(([typ]) => typ === "pointermove")).toHaveLength(0);
act(() => zeiger(700, BOX.top + 100));
abspielen();
expect(glow().style.opacity).toBe("0");
});
it("räumt seine Listener beim Entfernen wieder auf", () => {
umgebung();
const weg = vi.spyOn(window, "removeEventListener");
const { unmount } = render(<TextHoverEffect text="Leistungen" />);
unmount();
expect(weg.mock.calls.map(([typ]) => typ)).toEqual(expect.arrayContaining(["pointermove", "scroll"]));
});
});
describe("A1 Karten (BorderGlow) im hellen Modus lesbar", () => {
// Dunkel bleibt exakt wie vorher; hell bekommt eine helle Kartenfarbe.
const KARTEN: { name: string; ui: React.ReactElement; anzahl: number; dunkel: string }[] = [
{ name: "Values (Was Sie bekommen)", ui: <Values />, anzahl: 6, dunkel: "hsl(0 0% 6%)" },
{ name: "Kontaktabschnitt (Der nächste Schritt)", ui: <KontaktAbschnitt />, anzahl: 1, dunkel: "#120F17" },
{ name: "Über uns (Der Unterschied)", ui: <About />, anzahl: 3, dunkel: "hsl(0 0% 4%)" },
];
it.each(KARTEN)("$name: dunkle Kartenfarbe unverändert, helle Variante vorhanden", ({ ui, anzahl, dunkel }) => {
const { container } = zeige(ui);
const karten = [...container.querySelectorAll<HTMLElement>(".border-glow-card")];
expect(karten).toHaveLength(anzahl);
for (const karte of karten) {
expect(karte.style.getPropertyValue("--card-bg-dark")).toBe(dunkel);
const hell = karte.style.getPropertyValue("--card-bg-light");
expect(hell).not.toBe("");
expect(hell).not.toBe(dunkel);
expect(leuchtdichte(farbe(hell))).toBeGreaterThan(0.85);
}
});
it.each(KARTEN)("$name: Text und Icons erreichen in beiden Themes mindestens 4,5:1", ({ ui }) => {
const { container } = zeige(ui);
const karte = container.querySelector<HTMLElement>(".border-glow-card") as HTMLElement;
const hintergrund = { hell: farbe(karte.style.getPropertyValue("--card-bg-light")), dunkel: farbe(karte.style.getPropertyValue("--card-bg-dark")) };
for (const thema of THEMEN) {
// text-foreground (Titel, Hover-Zustand der Icons) und text-muted-foreground (Fließtext, Icons).
// Der blaue CTA-Button im Kontaktabschnitt hat eigene, themeunabhängige Farben und wird im nächsten Test geprüft.
for (const name of ["foreground", "muted-foreground"]) {
expect(kontrast(tokenFarbe(thema, name), hintergrund[thema === "hell" ? "hell" : "dunkel"])).toBeGreaterThanOrEqual(4.5);
}
}
});
it.each(KARTEN)("$name: Buttons auf den Karten (Text weiß auf eigener Farbe) erreichen in Ruhe und beim Hovern mindestens 4,5:1", ({ ui, name }) => {
const { container } = zeige(ui);
const buttons = [...container.querySelectorAll<HTMLElement>(".border-glow-card button")];
if (name.startsWith("Kontaktabschnitt")) expect(buttons).toHaveLength(1);
const arbitraerFarbe = (klassen: string[], praefix: string) =>
klassen.filter((k) => k.startsWith(`${praefix}bg-[`)).map((k) => k.slice(praefix.length + 4, -1));
for (const button of buttons) {
const klassen = [...button.classList];
// Weiß auf der eigenen Farbe; die Farbe ist themeunabhängig, gilt also in hell und dunkel gleich.
expect(klassen).toContain("text-white");
const ruhe = arbitraerFarbe(klassen, "");
const hover = arbitraerFarbe(klassen, "hover:");
// twMerge muss die Standardfarbe des Buttons ersetzen, nicht ergänzen
expect(ruhe).toHaveLength(1);
expect(hover).toHaveLength(1);
for (const hintergrundFarbe of [ruhe[0], hover[0]]) {
expect(kontrast([255, 255, 255], farbe(hintergrundFarbe))).toBeGreaterThanOrEqual(4.5);
}
}
});
it("BorderGlow wählt die Kartenfarbe per CSS nach Theme und zeigt im hellen Modus einen sichtbaren Rand", () => {
const css = lies("src/components/BorderGlow.css");
expect(css).toMatch(/--card-bg:\s*var\(--card-bg-dark/);
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*\{[^}]*--card-bg:\s*var\(--card-bg-light/);
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*\{[^}]*border-color:/);
});
it("der Kantenschein der Karten wird im hellen Modus normal statt mit plus-lighter gemischt (sonst unsichtbar auf Weiß)", () => {
const css = lies("src/components/BorderGlow.css");
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*>\s*\.edge-light\s*\{[^}]*mix-blend-mode:\s*normal/);
// dunkel bleibt wie vorher
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([]);
});
});