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>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-01 10:43:43 +00:00
1 parent 0d12bbc06a
commit 194301c6af
2 files changed
+179 -25

No files matched your search

+65 -25
View File
@@ -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>
+114
View File
@@ -486,3 +486,117 @@ describe("A3 Navigation: Theme-Schalter, Kontakt und Logo bekommen echte Mauskli
}
});
});
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 ab etwa 180 px vor der Box 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"]));
});
});