Archived
Compare commits
10
Commits
2e65829e15
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0546ae396f | ||
|
|
80925c2a43 | ||
|
|
b46527e65d | ||
|
|
d78338a969 | ||
|
|
e6d603cd0c | ||
|
|
003f9e67aa | ||
|
|
897d594dde | ||
|
|
194301c6af | ||
|
|
0d12bbc06a | ||
|
|
b2e17d96c2 |
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"enabled": false,
|
||||
"type": "static",
|
||||
"branch": "main",
|
||||
"displayName": "Webklar",
|
||||
"displayName": "webklar.com alt (Archiv)",
|
||||
"templateName": "webklar-preview-template",
|
||||
"projectType": "project",
|
||||
"public": false,
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
# WEBklar – Cold-Email-Paket (B2B KMU)
|
||||
|
||||
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
@@ -10,11 +10,39 @@ import { AppRoutes } from "./routes";
|
||||
|
||||
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();
|
||||
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]);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
--border-radius: 28px;
|
||||
--glow-padding: 40px;
|
||||
--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;
|
||||
border-radius: var(--border-radius);
|
||||
isolation: isolate;
|
||||
@@ -25,6 +27,12 @@
|
||||
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::after,
|
||||
.border-glow-card > .edge-light {
|
||||
@@ -147,6 +155,20 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -56,7 +56,10 @@ interface BorderGlowProps {
|
||||
className?: string;
|
||||
edgeSensitivity?: number;
|
||||
glowColor?: string;
|
||||
/** Kartenfarbe im dunklen Modus (und Standard, wenn lightBackgroundColor fehlt). */
|
||||
backgroundColor?: string;
|
||||
/** Kartenfarbe im hellen Modus; ohne Angabe gilt backgroundColor in beiden Themes. */
|
||||
lightBackgroundColor?: string;
|
||||
borderRadius?: number;
|
||||
glowRadius?: number;
|
||||
glowIntensity?: number;
|
||||
@@ -72,6 +75,7 @@ const BorderGlow = ({
|
||||
edgeSensitivity = 30,
|
||||
glowColor = '40 80 80',
|
||||
backgroundColor = '#120F17',
|
||||
lightBackgroundColor,
|
||||
borderRadius = 28,
|
||||
glowRadius = 40,
|
||||
glowIntensity = 1.0,
|
||||
@@ -183,7 +187,8 @@ const BorderGlow = ({
|
||||
onPointerMove={animated ? undefined : handlePointerMove}
|
||||
className={`border-glow-card ${className}`}
|
||||
style={{
|
||||
'--card-bg': backgroundColor,
|
||||
'--card-bg-dark': backgroundColor,
|
||||
'--card-bg-light': lightBackgroundColor ?? backgroundColor,
|
||||
'--edge-sensitivity': edgeSensitivity,
|
||||
'--border-radius': `${borderRadius}px`,
|
||||
'--glow-padding': `${glowRadius}px`,
|
||||
|
||||
@@ -12,6 +12,7 @@ const Contact = () => {
|
||||
edgeSensitivity={30}
|
||||
glowColor="40 80 80"
|
||||
backgroundColor="#120F17"
|
||||
lightBackgroundColor="hsl(0 0% 96%)"
|
||||
borderRadius={28}
|
||||
glowRadius={40}
|
||||
glowIntensity={1}
|
||||
@@ -38,9 +39,11 @@ const Contact = () => {
|
||||
data-analytics-cta="Potenzialanalyse starten"
|
||||
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
|
||||
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
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
@@ -32,7 +32,7 @@ const Header = () => {
|
||||
<NavBody>
|
||||
<NavbarLogo href="#">
|
||||
<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
|
||||
</span>
|
||||
</NavbarLogo>
|
||||
@@ -59,7 +59,7 @@ const Header = () => {
|
||||
<MobileNavHeader>
|
||||
<NavbarLogo href="#">
|
||||
<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
|
||||
</span>
|
||||
</NavbarLogo>
|
||||
|
||||
@@ -58,9 +58,9 @@ const ProblemSection = () => {
|
||||
</Suspense>
|
||||
</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
|
||||
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={{
|
||||
backgroundImage: "url(/problem_blitz.jpg)",
|
||||
}}
|
||||
|
||||
@@ -47,9 +47,9 @@ const SolutionSection = () => {
|
||||
</Suspense>
|
||||
</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
|
||||
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={{
|
||||
backgroundImage: "url(/loesung.jpg)",
|
||||
}}
|
||||
|
||||
@@ -42,9 +42,9 @@ const Values = () => {
|
||||
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
||||
<TextHoverEffect text="Leistungen" />
|
||||
</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
|
||||
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={{
|
||||
backgroundImage: "url(/backgroud_effect.webp)",
|
||||
}}
|
||||
@@ -69,6 +69,7 @@ const Values = () => {
|
||||
edgeSensitivity={30}
|
||||
glowColor="40 80 80"
|
||||
backgroundColor="hsl(0 0% 6%)"
|
||||
lightBackgroundColor="hsl(0 0% 96%)"
|
||||
borderRadius={8}
|
||||
glowRadius={30}
|
||||
glowIntensity={0.8}
|
||||
|
||||
@@ -105,7 +105,9 @@ export const NavItems = ({
|
||||
<motion.div
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
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
|
||||
)}
|
||||
>
|
||||
@@ -114,7 +116,7 @@ export const NavItems = ({
|
||||
const commonProps = {
|
||||
onMouseEnter: () => setHovered(idx),
|
||||
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}`,
|
||||
};
|
||||
const inner = (
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useId } from "react";
|
||||
import { useRef, useState, useEffect, useId } from "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 = ({
|
||||
text,
|
||||
}: {
|
||||
@@ -10,24 +14,64 @@ export const TextHoverEffect = ({
|
||||
duration?: number;
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [active, setActive] = useState(false);
|
||||
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
|
||||
const id = useId();
|
||||
const gradientId = `textGradient-${id}`;
|
||||
const revealMaskId = `revealMask-${id}`;
|
||||
const textMaskId = `textMask-${id}`;
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
// Map screen coords to viewBox coords (0 0 300 60)
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 300;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 60;
|
||||
setCursorPos({ x, y });
|
||||
},
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
// Nur echte Maus: Touch hat kein Hover und wuerde den Glow "kleben" lassen.
|
||||
if (!svg || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
|
||||
|
||||
let last: { x: number; y: number } | null = null;
|
||||
let raf = 0;
|
||||
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 (
|
||||
<svg
|
||||
@@ -36,9 +80,8 @@ export const TextHoverEffect = ({
|
||||
height="100%"
|
||||
viewBox="0 0 300 60"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
role="img"
|
||||
aria-label={text}
|
||||
className="select-none"
|
||||
>
|
||||
<defs>
|
||||
@@ -62,13 +105,7 @@ export const TextHoverEffect = ({
|
||||
</radialGradient>
|
||||
|
||||
<mask id={textMaskId}>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="300"
|
||||
height="60"
|
||||
fill={hovered ? `url(#${revealMaskId})` : "black"}
|
||||
/>
|
||||
<rect x="0" y="0" width="300" height="60" fill={`url(#${revealMaskId})`} />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
@@ -79,6 +116,7 @@ export const TextHoverEffect = ({
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
strokeWidth="0.3"
|
||||
aria-hidden="true"
|
||||
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
||||
style={{ opacity: 0.15, fontSize: "2rem" }}
|
||||
>
|
||||
@@ -92,6 +130,7 @@ export const TextHoverEffect = ({
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
strokeWidth="0.3"
|
||||
aria-hidden="true"
|
||||
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
|
||||
style={{ fontSize: "2rem" }}
|
||||
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
|
||||
@@ -101,7 +140,7 @@ export const TextHoverEffect = ({
|
||||
{text}
|
||||
</motion.text>
|
||||
|
||||
{/* Colored gradient revealed on hover */}
|
||||
{/* Colored gradient revealed near the pointer */}
|
||||
<text
|
||||
x="150"
|
||||
y="30"
|
||||
@@ -110,8 +149,9 @@ export const TextHoverEffect = ({
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth="0.3"
|
||||
mask={`url(#${textMaskId})`}
|
||||
aria-hidden="true"
|
||||
className="font-display font-bold fill-transparent"
|
||||
style={{ fontSize: "2rem" }}
|
||||
style={{ fontSize: "2rem", opacity: active ? 1 : 0, transition: "opacity 300ms ease-out" }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ const AGB = () => {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
+4
-1
@@ -18,7 +18,7 @@ const About = () => {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</span>
|
||||
</Link>
|
||||
@@ -105,6 +105,7 @@ const About = () => {
|
||||
edgeSensitivity={30}
|
||||
glowColor="40 80 80"
|
||||
backgroundColor="hsl(0 0% 4%)"
|
||||
lightBackgroundColor="hsl(0 0% 96%)"
|
||||
borderRadius={8}
|
||||
glowRadius={30}
|
||||
glowIntensity={0.8}
|
||||
@@ -128,6 +129,7 @@ const About = () => {
|
||||
edgeSensitivity={30}
|
||||
glowColor="40 80 80"
|
||||
backgroundColor="hsl(0 0% 4%)"
|
||||
lightBackgroundColor="hsl(0 0% 96%)"
|
||||
borderRadius={8}
|
||||
glowRadius={30}
|
||||
glowIntensity={0.8}
|
||||
@@ -151,6 +153,7 @@ const About = () => {
|
||||
edgeSensitivity={30}
|
||||
glowColor="40 80 80"
|
||||
backgroundColor="hsl(0 0% 4%)"
|
||||
lightBackgroundColor="hsl(0 0% 96%)"
|
||||
borderRadius={8}
|
||||
glowRadius={30}
|
||||
glowIntensity={0.8}
|
||||
|
||||
@@ -64,7 +64,7 @@ const Contact = () => {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</span>
|
||||
</Link>
|
||||
@@ -184,6 +184,8 @@ const Contact = () => {
|
||||
Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer{" "}
|
||||
<a
|
||||
href="https://project.webklar.com/datenschutz.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
|
||||
@@ -40,7 +40,7 @@ const HostingAbo = () => {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
|
||||
<Link to="/">
|
||||
|
||||
@@ -17,7 +17,7 @@ const Impressum = () => {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
|
||||
const NotFound = () => {
|
||||
const location = useLocation();
|
||||
usePageMeta("Seite nicht gefunden");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center bg-background px-6 text-foreground">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -40,7 +40,7 @@ const WartungsAbo = () => {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
|
||||
<Link to="/">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -17,6 +17,7 @@ import KontaktAbschnitt from "@/components/Contact";
|
||||
import About from "@/pages/About";
|
||||
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ScrollToTop } from "@/App";
|
||||
|
||||
// WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und
|
||||
// das Kontaktformular-Backend werden durch Attrappen ersetzt.
|
||||
@@ -326,7 +327,6 @@ const THEMEN: Thema[] = ["hell", "dunkel"];
|
||||
|
||||
describe("C5 404-Seite", () => {
|
||||
it("zeigt Überschrift, Erklärung und Link zur Startseite auf Deutsch", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
seite("/gibt-es-nicht");
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Seite nicht gefunden" })).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", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { container } = seite("/gibt-es-nicht");
|
||||
expect(container.querySelector(".bg-background.text-foreground")).not.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"])(
|
||||
"%s: die Marke steht als WEBklar da",
|
||||
(pfad) => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
seite(pfad);
|
||||
seite(pfad);
|
||||
expect(screen.queryAllByText(/Webklar/)).toHaveLength(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«", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
seite("/gibt-es-nicht");
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user