From ee4e24d8b9c93c6ac172cf143b528a2735b02b84 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Tue, 30 Jun 2026 11:00:06 +0200 Subject: [PATCH] fix(ui): stabilize Lighthouse decrypted text rendering --- .../_components/chat/decrypted-text-hooks.ts | 374 ++++++++++++++++++ .../_components/chat/decrypted-text.test.tsx | 36 ++ .../_components/chat/decrypted-text.tsx | 334 ++-------------- 3 files changed, 433 insertions(+), 311 deletions(-) create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/decrypted-text-hooks.ts create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.test.tsx diff --git a/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text-hooks.ts b/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text-hooks.ts new file mode 100644 index 0000000000..ff039329e1 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text-hooks.ts @@ -0,0 +1,374 @@ +import { useEffect, useRef, useState } from "react"; + +export const REVEAL_DIRECTION = { + START: "start", + END: "end", + CENTER: "center", +} as const; + +export const DECRYPTED_TEXT_ANIMATE_ON = { + VIEW: "view", + HOVER: "hover", + IN_VIEW_HOVER: "inViewHover", + CLICK: "click", +} as const; + +export const DECRYPTED_TEXT_CLICK_MODE = { + ONCE: "once", + TOGGLE: "toggle", +} as const; + +const DIRECTION = { + FORWARD: "forward", + REVERSE: "reverse", +} as const; + +export type RevealDirection = + (typeof REVEAL_DIRECTION)[keyof typeof REVEAL_DIRECTION]; +export type DecryptedTextAnimateOn = + (typeof DECRYPTED_TEXT_ANIMATE_ON)[keyof typeof DECRYPTED_TEXT_ANIMATE_ON]; +export type DecryptedTextClickMode = + (typeof DECRYPTED_TEXT_CLICK_MODE)[keyof typeof DECRYPTED_TEXT_CLICK_MODE]; + +export const DEFAULT_CHARACTERS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+"; + +interface DecryptedTextControllerOptions { + text: string; + speed: number; + maxIterations: number; + sequential: boolean; + revealDirection: RevealDirection; + useOriginalCharsOnly: boolean; + characters: string; + animateOn: DecryptedTextAnimateOn; + clickMode: DecryptedTextClickMode; +} + +type Direction = (typeof DIRECTION)[keyof typeof DIRECTION]; + +function buildPool( + text: string, + characters: string, + useOriginalCharsOnly: boolean, +): string[] { + return useOriginalCharsOnly + ? Array.from(new Set(text.split(""))).filter((char) => char !== " ") + : characters.split(""); +} + +function shuffleText( + originalText: string, + revealed: Set, + pool: string[], +): string { + return originalText + .split("") + .map((char, i) => { + if (char === " ") return " "; + if (revealed.has(i)) return originalText[i]; + return pool[Math.floor(Math.random() * pool.length)]; + }) + .join(""); +} + +function computeOrder(len: number, revealDirection: RevealDirection): number[] { + const order: number[] = []; + if (len <= 0) return order; + if (revealDirection === "start") { + for (let i = 0; i < len; i++) order.push(i); + return order; + } + if (revealDirection === "end") { + for (let i = len - 1; i >= 0; i--) order.push(i); + return order; + } + const middle = Math.floor(len / 2); + let offset = 0; + while (order.length < len) { + if (offset % 2 === 0) { + const idx = middle + offset / 2; + if (idx >= 0 && idx < len) order.push(idx); + } else { + const idx = middle - Math.ceil(offset / 2); + if (idx >= 0 && idx < len) order.push(idx); + } + offset++; + } + return order.slice(0, len); +} + +function fillAllIndices(len: number): Set { + const s = new Set(); + for (let i = 0; i < len; i++) s.add(i); + return s; +} + +function removeRandomIndices(set: Set, count: number): Set { + const arr = Array.from(set); + for (let i = 0; i < count && arr.length > 0; i++) { + const idx = Math.floor(Math.random() * arr.length); + arr.splice(idx, 1); + } + return new Set(arr); +} + +export function useDecryptedTextController({ + text, + speed, + maxIterations, + sequential, + revealDirection, + useOriginalCharsOnly, + characters, + animateOn, + clickMode, +}: DecryptedTextControllerOptions) { + const [displayText, setDisplayText] = useState(text); + const [isAnimating, setIsAnimating] = useState(false); + const [revealedIndices, setRevealedIndices] = useState>( + new Set(), + ); + const [hasAnimated, setHasAnimated] = useState(false); + const [isDecrypted, setIsDecrypted] = useState( + animateOn !== "click", + ); + const [direction, setDirection] = useState(DIRECTION.FORWARD); + + const containerRef = useRef(null); + const orderRef = useRef([]); + const pointerRef = useRef(0); + const intervalRef = useRef | null>(null); + + const pool = buildPool(text, characters, useOriginalCharsOnly); + + const triggerDecrypt = () => { + if (sequential) { + orderRef.current = computeOrder(text.length, revealDirection); + pointerRef.current = 0; + } + setRevealedIndices(new Set()); + setDirection(DIRECTION.FORWARD); + setIsAnimating(true); + }; + + const triggerReverse = () => { + if (sequential) { + orderRef.current = computeOrder(text.length, revealDirection) + .slice() + .reverse(); + pointerRef.current = 0; + } + setRevealedIndices(fillAllIndices(text.length)); + setDisplayText(shuffleText(text, fillAllIndices(text.length), pool)); + setDirection(DIRECTION.REVERSE); + setIsAnimating(true); + }; + + useEffect(() => { + if (!isAnimating) return; + + let currentIteration = 0; + const effectPool = buildPool(text, characters, useOriginalCharsOnly); + + const getNextIndex = (revealedSet: Set): number => { + const textLength = text.length; + switch (revealDirection) { + case "start": + return revealedSet.size; + case "end": + return textLength - 1 - revealedSet.size; + case "center": { + const middle = Math.floor(textLength / 2); + const offset = Math.floor(revealedSet.size / 2); + const nextIndex = + revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1; + + if ( + nextIndex >= 0 && + nextIndex < textLength && + !revealedSet.has(nextIndex) + ) { + return nextIndex; + } + for (let i = 0; i < textLength; i++) { + if (!revealedSet.has(i)) return i; + } + return 0; + } + default: + return revealedSet.size; + } + }; + + intervalRef.current = setInterval(() => { + setRevealedIndices((prevRevealed) => { + if (sequential) { + if (direction === DIRECTION.FORWARD) { + if (prevRevealed.size < text.length) { + const nextIndex = getNextIndex(prevRevealed); + const newRevealed = new Set(prevRevealed); + newRevealed.add(nextIndex); + setDisplayText(shuffleText(text, newRevealed, effectPool)); + return newRevealed; + } + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(true); + return prevRevealed; + } + if (pointerRef.current < orderRef.current.length) { + const idxToRemove = orderRef.current[pointerRef.current++]; + const newRevealed = new Set(prevRevealed); + newRevealed.delete(idxToRemove); + setDisplayText(shuffleText(text, newRevealed, effectPool)); + if (newRevealed.size === 0) { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(false); + } + return newRevealed; + } + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(false); + return prevRevealed; + } + + if (direction === DIRECTION.FORWARD) { + setDisplayText(shuffleText(text, prevRevealed, effectPool)); + currentIteration++; + if (currentIteration >= maxIterations) { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setDisplayText(text); + setIsDecrypted(true); + } + return prevRevealed; + } + + const currentSet = + prevRevealed.size === 0 ? fillAllIndices(text.length) : prevRevealed; + const removeCount = Math.max( + 1, + Math.ceil(text.length / Math.max(1, maxIterations)), + ); + const nextSet = removeRandomIndices(currentSet, removeCount); + setDisplayText(shuffleText(text, nextSet, effectPool)); + currentIteration++; + if (nextSet.size === 0 || currentIteration >= maxIterations) { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setIsDecrypted(false); + setDisplayText(shuffleText(text, new Set(), effectPool)); + return new Set(); + } + return nextSet; + }); + }, speed); + + return () => clearInterval(intervalRef.current ?? undefined); + }, [ + isAnimating, + text, + speed, + maxIterations, + sequential, + revealDirection, + direction, + characters, + useOriginalCharsOnly, + ]); + + const handleClick = () => { + if (animateOn !== "click") return; + + if (clickMode === "once") { + if (isDecrypted) return; + triggerDecrypt(); + } + + if (clickMode === "toggle") { + if (isDecrypted) { + triggerReverse(); + } else { + triggerDecrypt(); + } + } + }; + + const triggerHoverDecrypt = () => { + if (isAnimating) return; + setRevealedIndices(new Set()); + setIsDecrypted(false); + setDisplayText(text); + setDirection(DIRECTION.FORWARD); + setIsAnimating(true); + }; + + const resetToPlainText = () => { + clearInterval(intervalRef.current ?? undefined); + setIsAnimating(false); + setRevealedIndices(new Set()); + setDisplayText(text); + setIsDecrypted(true); + setDirection(DIRECTION.FORWARD); + }; + + useEffect(() => { + if (animateOn !== "view" && animateOn !== "inViewHover") return; + if (typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasAnimated) { + if (sequential) { + orderRef.current = computeOrder(text.length, revealDirection); + pointerRef.current = 0; + } + setRevealedIndices(new Set()); + setDirection(DIRECTION.FORWARD); + setIsAnimating(true); + setHasAnimated(true); + } + }); + }, + { root: null, rootMargin: "0px", threshold: 0.1 }, + ); + + const currentRef = containerRef.current; + if (currentRef) observer.observe(currentRef); + + return () => { + if (currentRef) observer.unobserve(currentRef); + }; + }, [animateOn, hasAnimated, sequential, text, revealDirection]); + + useEffect(() => { + if (animateOn === "click") { + const emptySet = new Set(); + setRevealedIndices(emptySet); + setDisplayText( + shuffleText(text, emptySet, buildPool(text, characters, false)), + ); + setIsDecrypted(false); + } else { + setRevealedIndices(new Set()); + setDisplayText(text); + setIsDecrypted(true); + } + setDirection(DIRECTION.FORWARD); + }, [animateOn, text, characters]); + + return { + containerRef, + displayText, + handleClick, + isAnimating, + isDecrypted, + resetToPlainText, + revealedIndices, + triggerHoverDecrypt, + }; +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.test.tsx new file mode 100644 index 0000000000..99413f8962 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.test.tsx @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DecryptedText } from "./decrypted-text"; + +describe("DecryptedText", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "decrypted-text.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("delegates effects to local hooks instead of calling useEffect in the component", () => { + expect(source).not.toContain("useEffect"); + expect(source).not.toContain("useEffect("); + }); + + it("keeps the accessible text stable while the visible text is encrypted", async () => { + // Given / When + const { container } = render( + , + ); + + // Then + await waitFor(() => + expect(container.querySelector('[aria-hidden="true"]')).toHaveTextContent( + "XXXXXX XXXX", + ), + ); + expect( + screen.getByText("Secret Text", { selector: ".sr-only" }), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.tsx b/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.tsx index a13bd1a43a..43e95966fe 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/decrypted-text.tsx @@ -1,12 +1,16 @@ "use client"; import { type HTMLMotionProps, motion } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; -type RevealDirection = "start" | "end" | "center"; -type Direction = "forward" | "reverse"; +import { + type DecryptedTextAnimateOn, + type DecryptedTextClickMode, + DEFAULT_CHARACTERS, + type RevealDirection, + useDecryptedTextController, +} from "./decrypted-text-hooks"; interface DecryptedTextProps extends HTMLMotionProps<"span"> { text: string; @@ -19,79 +23,8 @@ interface DecryptedTextProps extends HTMLMotionProps<"span"> { className?: string; encryptedClassName?: string; parentClassName?: string; - animateOn?: "view" | "hover" | "inViewHover" | "click"; - clickMode?: "once" | "toggle"; -} - -const DEFAULT_CHARACTERS = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+"; - -// Pure helpers live at module scope so they are never reactive effect deps — -// this is what lets us drop useCallback (banned here) without lint warnings. -function buildPool( - text: string, - characters: string, - useOriginalCharsOnly: boolean, -): string[] { - return useOriginalCharsOnly - ? Array.from(new Set(text.split(""))).filter((char) => char !== " ") - : characters.split(""); -} - -function shuffleText( - originalText: string, - revealed: Set, - pool: string[], -): string { - return originalText - .split("") - .map((char, i) => { - if (char === " ") return " "; - if (revealed.has(i)) return originalText[i]; - return pool[Math.floor(Math.random() * pool.length)]; - }) - .join(""); -} - -function computeOrder(len: number, revealDirection: RevealDirection): number[] { - const order: number[] = []; - if (len <= 0) return order; - if (revealDirection === "start") { - for (let i = 0; i < len; i++) order.push(i); - return order; - } - if (revealDirection === "end") { - for (let i = len - 1; i >= 0; i--) order.push(i); - return order; - } - const middle = Math.floor(len / 2); - let offset = 0; - while (order.length < len) { - if (offset % 2 === 0) { - const idx = middle + offset / 2; - if (idx >= 0 && idx < len) order.push(idx); - } else { - const idx = middle - Math.ceil(offset / 2); - if (idx >= 0 && idx < len) order.push(idx); - } - offset++; - } - return order.slice(0, len); -} - -function fillAllIndices(len: number): Set { - const s = new Set(); - for (let i = 0; i < len; i++) s.add(i); - return s; -} - -function removeRandomIndices(set: Set, count: number): Set { - const arr = Array.from(set); - for (let i = 0; i < count && arr.length > 0; i++) { - const idx = Math.floor(Math.random() * arr.length); - arr.splice(idx, 1); - } - return new Set(arr); + animateOn?: DecryptedTextAnimateOn; + clickMode?: DecryptedTextClickMode; } // Ported from reactbits DecryptedText, adapted to Prowler conventions: @@ -112,247 +45,26 @@ export function DecryptedText({ clickMode = "once", ...props }: DecryptedTextProps) { - const [displayText, setDisplayText] = useState(text); - const [isAnimating, setIsAnimating] = useState(false); - const [revealedIndices, setRevealedIndices] = useState>( - new Set(), - ); - const [hasAnimated, setHasAnimated] = useState(false); - const [isDecrypted, setIsDecrypted] = useState( - animateOn !== "click", - ); - const [direction, setDirection] = useState("forward"); - - const containerRef = useRef(null); - const orderRef = useRef([]); - const pointerRef = useRef(0); - const intervalRef = useRef | null>(null); - - const pool = buildPool(text, characters, useOriginalCharsOnly); - - const triggerDecrypt = () => { - if (sequential) { - orderRef.current = computeOrder(text.length, revealDirection); - pointerRef.current = 0; - } - setRevealedIndices(new Set()); - setDirection("forward"); - setIsAnimating(true); - }; - - const triggerReverse = () => { - if (sequential) { - orderRef.current = computeOrder(text.length, revealDirection) - .slice() - .reverse(); - pointerRef.current = 0; - } - setRevealedIndices(fillAllIndices(text.length)); - setDisplayText(shuffleText(text, fillAllIndices(text.length), pool)); - setDirection("reverse"); - setIsAnimating(true); - }; - - useEffect(() => { - if (!isAnimating) return; - - let currentIteration = 0; - const effectPool = buildPool(text, characters, useOriginalCharsOnly); - - const getNextIndex = (revealedSet: Set): number => { - const textLength = text.length; - switch (revealDirection) { - case "start": - return revealedSet.size; - case "end": - return textLength - 1 - revealedSet.size; - case "center": { - const middle = Math.floor(textLength / 2); - const offset = Math.floor(revealedSet.size / 2); - const nextIndex = - revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1; - - if ( - nextIndex >= 0 && - nextIndex < textLength && - !revealedSet.has(nextIndex) - ) { - return nextIndex; - } - for (let i = 0; i < textLength; i++) { - if (!revealedSet.has(i)) return i; - } - return 0; - } - default: - return revealedSet.size; - } - }; - - intervalRef.current = setInterval(() => { - setRevealedIndices((prevRevealed) => { - if (sequential) { - if (direction === "forward") { - if (prevRevealed.size < text.length) { - const nextIndex = getNextIndex(prevRevealed); - const newRevealed = new Set(prevRevealed); - newRevealed.add(nextIndex); - setDisplayText(shuffleText(text, newRevealed, effectPool)); - return newRevealed; - } - clearInterval(intervalRef.current ?? undefined); - setIsAnimating(false); - setIsDecrypted(true); - return prevRevealed; - } - // reverse - if (pointerRef.current < orderRef.current.length) { - const idxToRemove = orderRef.current[pointerRef.current++]; - const newRevealed = new Set(prevRevealed); - newRevealed.delete(idxToRemove); - setDisplayText(shuffleText(text, newRevealed, effectPool)); - if (newRevealed.size === 0) { - clearInterval(intervalRef.current ?? undefined); - setIsAnimating(false); - setIsDecrypted(false); - } - return newRevealed; - } - clearInterval(intervalRef.current ?? undefined); - setIsAnimating(false); - setIsDecrypted(false); - return prevRevealed; - } - - // non-sequential - if (direction === "forward") { - setDisplayText(shuffleText(text, prevRevealed, effectPool)); - currentIteration++; - if (currentIteration >= maxIterations) { - clearInterval(intervalRef.current ?? undefined); - setIsAnimating(false); - setDisplayText(text); - setIsDecrypted(true); - } - return prevRevealed; - } - - // non-sequential reverse - const currentSet = - prevRevealed.size === 0 ? fillAllIndices(text.length) : prevRevealed; - const removeCount = Math.max( - 1, - Math.ceil(text.length / Math.max(1, maxIterations)), - ); - const nextSet = removeRandomIndices(currentSet, removeCount); - setDisplayText(shuffleText(text, nextSet, effectPool)); - currentIteration++; - if (nextSet.size === 0 || currentIteration >= maxIterations) { - clearInterval(intervalRef.current ?? undefined); - setIsAnimating(false); - setIsDecrypted(false); - setDisplayText(shuffleText(text, new Set(), effectPool)); - return new Set(); - } - return nextSet; - }); - }, speed); - - return () => clearInterval(intervalRef.current ?? undefined); - }, [ + const { + containerRef, + displayText, + handleClick, isAnimating, + isDecrypted, + resetToPlainText, + revealedIndices, + triggerHoverDecrypt, + } = useDecryptedTextController({ text, speed, maxIterations, sequential, revealDirection, - direction, - characters, useOriginalCharsOnly, - ]); - - const handleClick = () => { - if (animateOn !== "click") return; - - if (clickMode === "once") { - if (isDecrypted) return; - triggerDecrypt(); - } - - if (clickMode === "toggle") { - if (isDecrypted) { - triggerReverse(); - } else { - triggerDecrypt(); - } - } - }; - - const triggerHoverDecrypt = () => { - if (isAnimating) return; - setRevealedIndices(new Set()); - setIsDecrypted(false); - setDisplayText(text); - setDirection("forward"); - setIsAnimating(true); - }; - - const resetToPlainText = () => { - clearInterval(intervalRef.current ?? undefined); - setIsAnimating(false); - setRevealedIndices(new Set()); - setDisplayText(text); - setIsDecrypted(true); - setDirection("forward"); - }; - - useEffect(() => { - if (animateOn !== "view" && animateOn !== "inViewHover") return; - // SSR / jsdom (tests) lack IntersectionObserver: skip the reveal animation, - // the init effect already renders the plain text so nothing is lost. - if (typeof IntersectionObserver === "undefined") return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !hasAnimated) { - if (sequential) { - orderRef.current = computeOrder(text.length, revealDirection); - pointerRef.current = 0; - } - setRevealedIndices(new Set()); - setDirection("forward"); - setIsAnimating(true); - setHasAnimated(true); - } - }); - }, - { root: null, rootMargin: "0px", threshold: 0.1 }, - ); - - const currentRef = containerRef.current; - if (currentRef) observer.observe(currentRef); - - return () => { - if (currentRef) observer.unobserve(currentRef); - }; - }, [animateOn, hasAnimated, sequential, text, revealDirection]); - - useEffect(() => { - if (animateOn === "click") { - const emptySet = new Set(); - setRevealedIndices(emptySet); - setDisplayText( - shuffleText(text, emptySet, buildPool(text, characters, false)), - ); - setIsDecrypted(false); - } else { - setRevealedIndices(new Set()); - setDisplayText(text); - setIsDecrypted(true); - } - setDirection("forward"); - }, [animateOn, text, characters]); + characters, + animateOn, + clickMode, + }); const animateProps = animateOn === "hover" || animateOn === "inViewHover" @@ -368,7 +80,7 @@ export function DecryptedText({ {...animateProps} {...props} > - {displayText} + {text}