diff --git a/frontend/src/app/(public)/vrt/page.tsx b/frontend/src/app/(public)/vrt/page.tsx index f606e5a..d40a26d 100644 --- a/frontend/src/app/(public)/vrt/page.tsx +++ b/frontend/src/app/(public)/vrt/page.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Modal } from "@/components/ui/Modal"; +import CopyButton from "@/components/CopyButton"; export default function VRTPage() { const [isModalOpen, setIsModalOpen] = useState(false); @@ -20,6 +21,12 @@ export default function VRTPage() { +
+ + sk_test_vrt_copy_glitch_signal + + +
diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx index 97892dd..35a8578 100644 --- a/frontend/src/components/CopyButton.tsx +++ b/frontend/src/components/CopyButton.tsx @@ -1,23 +1,123 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion, AnimatePresence, type Variants } from "framer-motion"; interface CopyButtonProps { text: string; className?: string; } +const glitchButtonVariants: Variants = { + idle: { + x: 0, + y: 0, + scale: 1, + rotate: 0, + boxShadow: "0 0 0 rgba(94, 242, 192, 0)", + borderColor: "rgba(255,255,255,0.1)", + transition: { + duration: 0.24, + ease: "easeOut" + } + }, + success: { + x: [0, -1.5, 1.75, -1.25, 0.75, 0], + y: [0, 0.5, -0.75, 0.5, 0, 0], + scale: [1, 1.02, 0.985, 1.015, 1], + rotate: [0, -1, 1, -0.5, 0], + boxShadow: [ + "0 0 0 rgba(94, 242, 192, 0)", + "0 0 18px rgba(94, 242, 192, 0.3)", + "0 0 28px rgba(56, 189, 248, 0.28)", + "0 0 22px rgba(94, 242, 192, 0.24)", + "0 0 0 rgba(94, 242, 192, 0)" + ], + borderColor: [ + "rgba(255,255,255,0.1)", + "rgba(94,242,192,0.55)", + "rgba(56,189,248,0.45)", + "rgba(94,242,192,0.45)", + "rgba(255,255,255,0.1)" + ], + transition: { + duration: 0.48, + times: [0, 0.18, 0.38, 0.7, 1], + ease: "easeInOut" + } + } +}; + +const glitchLayerVariants: Variants = { + initial: { + opacity: 0, + x: 0, + scaleX: 1 + }, + success: (xOffset: number) => ({ + opacity: [0, 0.75, 0.2, 0.55, 0], + x: [0, xOffset, -xOffset * 0.8, xOffset * 0.45, 0], + scaleX: [1, 1.06, 0.98, 1.02, 1], + transition: { + duration: 0.46, + times: [0, 0.16, 0.42, 0.72, 1], + ease: "easeInOut" + } + }) +}; + +const iconGlitchVariants: Variants = { + idle: { + x: 0, + filter: "drop-shadow(0 0 0 rgba(94, 242, 192, 0))" + }, + success: { + x: [0, -1, 1, -0.5, 0], + filter: [ + "drop-shadow(0 0 0 rgba(94, 242, 192, 0))", + "drop-shadow(0 0 6px rgba(94, 242, 192, 0.7))", + "drop-shadow(0 0 10px rgba(56, 189, 248, 0.55))", + "drop-shadow(0 0 6px rgba(94, 242, 192, 0.45))", + "drop-shadow(0 0 0 rgba(94, 242, 192, 0))" + ], + transition: { + duration: 0.4, + times: [0, 0.18, 0.42, 0.72, 1], + ease: "easeInOut" + } + } +}; + export default function CopyButton({ text, className = "" }: CopyButtonProps) { const t = useTranslations("copyButton"); const [copied, setCopied] = useState(false); + const resetTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + } + }; + }, []); + + const showCopiedState = () => { + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + } + + setCopied(true); + resetTimerRef.current = setTimeout(() => { + setCopied(false); + resetTimerRef.current = null; + }, 2000); + }; const handleCopy = async () => { try { await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + showCopiedState(); } catch { const el = document.createElement("textarea"); el.value = text; @@ -25,8 +125,7 @@ export default function CopyButton({ text, className = "" }: CopyButtonProps) { el.select(); document.execCommand("copy"); document.body.removeChild(el); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + showCopiedState(); } }; @@ -35,61 +134,95 @@ export default function CopyButton({ text, className = "" }: CopyButtonProps) { - - {copied ? ( - - + )} + + {copied && ( diff --git a/frontend/tests/e2e/vrt.spec.ts b/frontend/tests/e2e/vrt.spec.ts index a52df08..b323ed6 100644 --- a/frontend/tests/e2e/vrt.spec.ts +++ b/frontend/tests/e2e/vrt.spec.ts @@ -16,6 +16,22 @@ test.describe("Visual Regression Tests for Core Components", () => { expect(await buttonsSection.screenshot()).toMatchSnapshot("buttons-core.png"); }); + test("Copy button shows premium glitch success feedback", async ({ page, browserName }) => { + test.skip(browserName !== "chromium", "Clipboard permission check is only verified in chromium."); + + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + + const copyButton = page.getByRole("button", { name: "Copy to clipboard" }); + await expect(copyButton).toBeVisible(); + + await copyButton.click(); + + await expect(page.getByText("Copied!")).toBeVisible(); + await expect(copyButton).toHaveScreenshot("copy-button-success.png", { + animations: "allow", + }); + }); + test("Inputs should match visual baseline", async ({ page }) => { const inputsSection = page.locator("#vrt-inputs"); await expect(inputsSection).toBeVisible(); diff --git a/frontend/tests/e2e/vrt.spec.ts-snapshots/copy-button-success-chromium-darwin.png b/frontend/tests/e2e/vrt.spec.ts-snapshots/copy-button-success-chromium-darwin.png new file mode 100644 index 0000000..0099843 Binary files /dev/null and b/frontend/tests/e2e/vrt.spec.ts-snapshots/copy-button-success-chromium-darwin.png differ