From 194301c6af3eab20bdc07c4212f38c69b26a2da0 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 1 Oct 2026 10:43:43 +0000 Subject: [PATCH] =?UTF-8?q?Glow-=C3=9Cberschriften:=20Fix=20A=20aus=20der?= =?UTF-8?q?=20Hover-Analyse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/ui/text-hover-effect.tsx | 90 +++++++++++++------ src/test/sofort-paket.test.tsx | 114 ++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 25 deletions(-) diff --git a/src/components/ui/text-hover-effect.tsx b/src/components/ui/text-hover-effect.tsx index f3df46b..7eeed64 100644 --- a/src/components/ui/text-hover-effect.tsx +++ b/src/components/ui/text-hover-effect.tsx @@ -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(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) => { - 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 ( setHovered(true)} - onMouseLeave={() => setHovered(false)} - onMouseMove={handleMouseMove} + role="img" + aria-label={text} className="select-none" > @@ -62,13 +105,7 @@ export const TextHoverEffect = ({ - + @@ -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} - {/* Colored gradient revealed on hover */} + {/* Colored gradient revealed near the pointer */} diff --git a/src/test/sofort-paket.test.tsx b/src/test/sofort-paket.test.tsx index 829fa7e..5c0b54d 100644 --- a/src/test/sofort-paket.test.tsx +++ b/src/test/sofort-paket.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + unmount(); + expect(weg.mock.calls.map(([typ]) => typ)).toEqual(expect.arrayContaining(["pointermove", "scroll"])); + }); +});