From dc5371a2a64e06cfc62dd564e3eb933fe36e4a29 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Fri, 17 Jul 2026 08:28:09 -0600 Subject: [PATCH] Fix immediate mobile copy feedback (#262) --- scripts/e2e/run-playwright-smoke.js | 42 ++- src/components/ui/sonner.tsx | 77 +++--- src/core/feedback/toast-live-region-state.ts | 1 + .../inline-tool-action-feedback.tsx | 87 +++++++ .../tool-shell/lazy-tool-action-feedback.ts | 43 ++++ src/features/tools/crontab-generator/page.tsx | 23 +- .../tools/csv-json-converter/page.tsx | 27 +- src/features/tools/jwt-decoder/page.tsx | 55 ++-- src/features/tools/list-randomizer/page.tsx | 18 +- src/features/tools/markdown-preview/page.tsx | 27 +- src/features/tools/qr-code-generator/page.tsx | 33 ++- src/generated/route-width-inventory.json | 12 +- src/hooks/use-visual-viewport-rect.ts | 39 +++ tests/component/jwt-decoder-actions.test.tsx | 34 ++- ...azy-tool-action-feedback-fallback.test.tsx | 241 ++++++++++++++++++ .../qr-code-generator-logo-upload.test.tsx | 31 +++ tests/component/toaster-live-region.test.tsx | 109 ++++++++ ...v-json-converter-performance-guard.test.ts | 12 +- .../playwright-smoke-matrix-guard.test.ts | 9 + .../tool-action-consistency-guard.test.ts | 39 ++- 20 files changed, 816 insertions(+), 143 deletions(-) create mode 100644 src/features/tool-shell/inline-tool-action-feedback.tsx create mode 100644 src/features/tool-shell/lazy-tool-action-feedback.ts create mode 100644 src/hooks/use-visual-viewport-rect.ts create mode 100644 tests/component/lazy-tool-action-feedback-fallback.test.tsx diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index 5b61da9a..fa04d8ae 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -1081,19 +1081,51 @@ async function assertMobileTouchTargets(page, routeLabel) { async function clickCopyAndExpectToast(page, button, label) { await button.waitFor({ state: "visible", timeout: 15_000 }); - await page.waitForTimeout(2500); await button.scrollIntoViewIfNeeded(); await button.evaluate((element) => { element.scrollIntoView({ block: "center", inline: "nearest" }); }); await button.click(); - const copiedToast = page.getByText(/copied/i).first(); - await copiedToast.waitFor({ state: "visible", timeout: 5_000 }).catch(() => { - throw new Error(`Expected copied toast after ${label}.`); + const copiedFeedback = page.locator("[data-sonner-toast], [data-inline-tool-action-feedback]") + .filter({ hasText: /copied/i }) + .first(); + await copiedFeedback.waitFor({ state: "visible", timeout: 5_000 }).catch(() => { + throw new Error(`Expected copied feedback after ${label}.`); + }); + await page.waitForFunction(() => { + const feedback = [...document.querySelectorAll("[data-sonner-toast], [data-inline-tool-action-feedback]")] + .find((element) => element.textContent?.toLowerCase().includes("copied")); + if (!(feedback instanceof HTMLElement)) return false; + + const style = getComputedStyle(feedback); + const rect = feedback.getBoundingClientRect(); + const viewport = visualViewport || { + offsetLeft: 0, + offsetTop: 0, + width: innerWidth, + height: innerHeight, + }; + const viewportRight = viewport.offsetLeft + viewport.width; + const viewportBottom = viewport.offsetTop + viewport.height; + const sonnerReady = !feedback.matches("[data-sonner-toast]") + || (feedback.dataset.mounted === "true" && feedback.dataset.visible === "true"); + return sonnerReady + && Number.parseFloat(style.opacity) >= 0.99 + && style.visibility !== "hidden" + && rect.width > 0 + && rect.height > 0 + && rect.left >= viewport.offsetLeft - 1 + && rect.right <= viewportRight + 1 + && rect.top >= viewport.offsetTop - 1 + && rect.bottom <= viewportBottom + 1; + }, null, { timeout: 5_000 }).catch(() => { + throw new Error(`Expected opaque copied feedback fully inside the visual viewport after ${label}.`); }); - await page.locator("[data-sonner-toast]").filter({ hasText: /copied/i }).first() + await page.locator("[data-sonner-toast], [data-inline-tool-action-feedback]") + .filter({ hasText: /copied/i }) + .first() .waitFor({ state: "hidden", timeout: 8_000 }) .catch(() => {}); } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index c6736089..cf225153 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -11,31 +11,36 @@ import * as React from "react" import { Toaster as Sonner, toast, type ToasterProps } from "sonner" import { drainQueuedToastFeedback, setToastLiveRegionReady } from "@/core/feedback/toast-live-region-state" import { useThemePreference } from "@/hooks/use-theme-preference" +import { useVisualViewportRect } from "@/hooks/use-visual-viewport-rect" -const Toaster = ({ ...props }: ToasterProps) => { +const Toaster = ({ className, style, ...props }: ToasterProps) => { const { resolvedTheme } = useThemePreference() + const visualViewportRect = useVisualViewportRect() const sonnerRef = React.useRef(null) React.useEffect(() => { const liveRegion = sonnerRef.current const queuedFeedback = drainQueuedToastFeedback() const previousLiveMode = liveRegion?.getAttribute("aria-live") || "polite" + const replay = (feedback: (typeof queuedFeedback)[number]) => { + const options = { id: feedback.id, description: feedback.description } + if (feedback.type === "error") { + toast.error(feedback.message, options) + } else { + toast.success(feedback.message, options) + } + } if (queuedFeedback.length > 0) { liveRegion?.setAttribute("aria-live", "off") - for (const feedback of queuedFeedback) { - const options = { id: feedback.id, description: feedback.description } - if (feedback.type === "error") { - toast.error(feedback.message, options) - } else { - toast.success(feedback.message, options) - } - } + queuedFeedback.forEach(replay) } setToastLiveRegionReady(true) if (queuedFeedback.length > 0) { - globalThis.setTimeout(() => liveRegion?.setAttribute("aria-live", previousLiveMode), 0) + globalThis.setTimeout(() => { + if (liveRegion?.isConnected) liveRegion.setAttribute("aria-live", previousLiveMode) + }, 0) } return () => { @@ -44,27 +49,37 @@ const Toaster = ({ ...props }: ToasterProps) => { }, []) return ( - , - info: , - warning: , - error: , - loading: , - }} - style={ - { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", - "--border-radius": "var(--radius)", - } as React.CSSProperties - } - {...props} - /> +
+ , + info: , + warning: , + error: , + loading: , + }} + style={ + { + position: "absolute", + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + ...style, + } as React.CSSProperties + } + {...props} + /> +
) } diff --git a/src/core/feedback/toast-live-region-state.ts b/src/core/feedback/toast-live-region-state.ts index 7e440438..372ecb27 100644 --- a/src/core/feedback/toast-live-region-state.ts +++ b/src/core/feedback/toast-live-region-state.ts @@ -13,6 +13,7 @@ export function isToastLiveRegionReady() { } export function setToastLiveRegionReady(ready: boolean) { + if (liveRegionReady === ready) return liveRegionReady = ready } diff --git a/src/features/tool-shell/inline-tool-action-feedback.tsx b/src/features/tool-shell/inline-tool-action-feedback.tsx new file mode 100644 index 00000000..9f006380 --- /dev/null +++ b/src/features/tool-shell/inline-tool-action-feedback.tsx @@ -0,0 +1,87 @@ +"use client" + +import * as React from "react" +import { createPortal } from "react-dom" +import { useVisualViewportRect } from "@/hooks/use-visual-viewport-rect" +import type { ToolActionResult } from "./tool-action-bar" + +export type InlineToolActionFeedbackState = { + id: number + result: ToolActionResult +} | null + +export function useInlineToolActionFeedback() { + const sequenceRef = React.useRef(0) + const mountedRef = React.useRef(true) + const dismissTimerRef = React.useRef | null>(null) + const [feedback, setFeedback] = React.useState(null) + + const clearDismissTimer = React.useCallback(() => { + if (dismissTimerRef.current !== null) { + globalThis.clearTimeout(dismissTimerRef.current) + dismissTimerRef.current = null + } + }, []) + + React.useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + clearDismissTimer() + } + }, [clearDismissTimer]) + + const run = React.useCallback(async (action: () => Promise) => { + const result = await action() + if (!mountedRef.current) return result + clearDismissTimer() + if (result.announce === true) { + sequenceRef.current += 1 + const id = sequenceRef.current + setFeedback({ id, result }) + dismissTimerRef.current = globalThis.setTimeout(() => { + setFeedback((current) => current?.id === id ? null : current) + dismissTimerRef.current = null + }, result.status === "failed" ? 8000 : 5000) + } else { + setFeedback(null) + } + return result + }, [clearDismissTimer]) + + return { feedback, run } +} + +export function InlineToolActionFeedback({ feedback }: { feedback: InlineToolActionFeedbackState }) { + const visualViewportRect = useVisualViewportRect() + + if (!feedback?.result.message || typeof document === "undefined") return null + + const { message, description, status } = feedback.result + const text = description + ? `${message}${/[.!?]$/.test(message) ? " " : ". "}${description}` + : message + + return createPortal( +
+

+ {text} +

+
, + document.body, + ) +} diff --git a/src/features/tool-shell/lazy-tool-action-feedback.ts b/src/features/tool-shell/lazy-tool-action-feedback.ts new file mode 100644 index 00000000..84b6d53d --- /dev/null +++ b/src/features/tool-shell/lazy-tool-action-feedback.ts @@ -0,0 +1,43 @@ +"use client" + +import type { TranslationType } from "@/core/i18n/lang-provider" +import { safeClipboardWrite } from "@/core/clipboard/clipboard" +import { isToastLiveRegionReady } from "@/core/feedback/toast-live-region-state" +import type { ToolActionResult } from "./tool-action-bar" + +let feedbackSequence = 0 + +function nextFeedbackId() { + feedbackSequence += 1 + return `lazy-tool-action-feedback-${feedbackSequence}` +} + +export async function copyTextWithLazyToolFeedback( + t: TranslationType, + text: string, + label: string, + description?: string, +): Promise { + const copyResult = await safeClipboardWrite(text) + const status = copyResult.ok ? "success" : "failed" + const message = copyResult.ok ? t.common.copied : t.common.copy_failed + const detail = copyResult.ok ? description || `${label}: ${t.common.copied_desc}` : label + const toastId = nextFeedbackId() + + if (!isToastLiveRegionReady()) { + return { status, message, description: detail, announce: true } + } + + try { + const { toast } = await import("sonner") + const options = { id: toastId, description: detail } + if (copyResult.ok) { + toast.success(message, options) + } else { + toast.error(message, options) + } + return { status, message, description: detail, announce: false } + } catch { + return { status, message, description: detail, announce: true } + } +} diff --git a/src/features/tools/crontab-generator/page.tsx b/src/features/tools/crontab-generator/page.tsx index d52ce213..8d2a71c7 100644 --- a/src/features/tools/crontab-generator/page.tsx +++ b/src/features/tools/crontab-generator/page.tsx @@ -2,11 +2,11 @@ import * as React from "react" import { Copy, CalendarClock, Eraser } from "lucide-react" -import { toast } from "sonner" import { Button } from "@/components/ui/button" import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" -import { safeClipboardWrite } from "@/core/clipboard/clipboard" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" import type { Locale } from "@/core/i18n/i18n" import cronstrue from "cronstrue/i18n.js" import { @@ -43,6 +43,7 @@ export function CrontabGeneratorPage() { const [cronString, setCronString] = React.useState("* * * * *") const [description, setDescription] = React.useState("") const [error, setError] = React.useState(null) + const { feedback: copyFeedback, run: runCopyAction } = useInlineToolActionFeedback() const parts = parseCronParts(cronString) const fieldDefinitions = getCronFieldDefinitions(parts.length) @@ -86,16 +87,14 @@ export function CrontabGeneratorPage() { } }, [cronLocale, cronString, toolT.invalid_expression]) - const handleCopy = async () => { + const handleCopy = () => { if (!cronString || error) return - const result = await safeClipboardWrite(cronString) - if (!result.ok) { - toast.error(t.common.copy_failed) - return - } - toast.success(t.common.copied, { - description: toolT.copy_expression_success, - }) + return runCopyAction(() => copyTextWithLazyToolFeedback( + t, + cronString, + toolT.copy_expression, + toolT.copy_expression_success, + )) } const handleClear = () => { @@ -125,6 +124,8 @@ export function CrontabGeneratorPage() { + +
{/* Output Section */} diff --git a/src/features/tools/csv-json-converter/page.tsx b/src/features/tools/csv-json-converter/page.tsx index 728b84eb..927d51b0 100644 --- a/src/features/tools/csv-json-converter/page.tsx +++ b/src/features/tools/csv-json-converter/page.tsx @@ -6,8 +6,9 @@ import { useLang } from "@/core/i18n/lang-provider" import { useThemePreference } from "@/hooks/use-theme-preference" import { ensureByteflowMonacoThemes, getByteflowMonacoThemeName } from "@/core/utils/monaco-theme" import { MonacoEditor } from "@/features/tool-shell/monaco-editors" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" import { RelatedTools } from "@/core/seo/components/related-tools" -import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { buildToolHandoffLink } from "@/core/routing/tool-handoff" import { buildInputTooLargeMessage, countNonEmptyLines, isOverUtf8Budget, TOOL_RUNTIME_BUDGETS } from "@/core/performance/tool-runtime-budgets" import { FILE_INPUT_POLICIES, readTextFileWithPolicy, validateFileAgainstPolicy } from "@/core/files/file-input-policy" @@ -25,10 +26,6 @@ import { InlineButton } from "./components" import { CsvSettingsPanel } from "./settings-panel" import type { CsvJsonDiagnostic, Direction } from "./types" import { WideToolPageContainer } from "@/components/layout/page-container" -async function loadToast() { - const { toast } = await import("sonner") - return toast -} // ─── Component ────────────────────────────────────────────────────────────── @@ -45,18 +42,11 @@ export function CsvJsonConverterPage() { const [typeInference, setTypeInference] = React.useState(true) const [showSettings, setShowSettings] = React.useState(false) const [isConverting, setIsConverting] = React.useState(false) + const { feedback: copyFeedback, run: runCopyAction } = useInlineToolActionFeedback() const convertRequestIdRef = React.useRef(0) const convertAbortControllerRef = React.useRef(null) const { resolvedTheme } = useThemePreference() const monacoTheme = getByteflowMonacoThemeName(resolvedTheme) - const notifyError = React.useCallback(async (message: string) => { - const toast = await loadToast() - toast.error(message) - }, []) - const notifySuccess = React.useCallback(async (message: string, description?: string) => { - const toast = await loadToast() - toast.success(message, description ? { description } : undefined) - }, []) React.useEffect(() => { removeStorageKey(INPUT_STORAGE_KEY) @@ -161,14 +151,9 @@ export function CsvJsonConverterPage() { }) } - const handleCopy = async () => { + const handleCopy = () => { if (!output) return - const result = await safeClipboardWrite(output) - if (!result.ok) { - await notifyError(t.common.copy_failed) - return - } - await notifySuccess(t.common.copied, t.common.copied_desc) + return runCopyAction(() => copyTextWithLazyToolFeedback(t, output, t.common.output, t.common.copied_desc)) } const handleClear = () => { @@ -295,6 +280,8 @@ export function CsvJsonConverterPage() {
+ + {/* Direction indicator */}
diff --git a/src/features/tools/jwt-decoder/page.tsx b/src/features/tools/jwt-decoder/page.tsx index e044af79..987fe5e3 100644 --- a/src/features/tools/jwt-decoder/page.tsx +++ b/src/features/tools/jwt-decoder/page.tsx @@ -3,16 +3,16 @@ import * as React from "react" import { ShieldAlert, Eraser, TestTube2, Copy, KeyRound, Clock3 } from "lucide-react" import Link from "next/link" -import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" import { useThemePreference } from "@/hooks/use-theme-preference" import { ensureByteflowMonacoThemes, getByteflowMonacoThemeName } from "@/core/utils/monaco-theme" import { Textarea } from "@/components/ui/textarea" import { Button } from "@/components/ui/button" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" import { SensitiveInputWarning } from "@/features/tool-shell/sensitive-input-warning" import { MonacoEditor } from "@/features/tool-shell/monaco-editors" -import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { decodeJwtParts, JwtDecodeError, type JwtClaimSemantic, type JwtDecodeErrorCode, type JwtSemanticSummary } from "./utils" import { WideToolPageContainer } from "@/components/layout/page-container" @@ -52,6 +52,7 @@ export function JwtDecoderPage() { const [payload, setPayload] = React.useState("") const [error, setError] = React.useState(null) const [semantics, setSemantics] = React.useState(null) + const { feedback: copyFeedback, run: runCopyAction } = useInlineToolActionFeedback() const { resolvedTheme } = useThemePreference() const monacoTheme = getByteflowMonacoThemeName(resolvedTheme) @@ -93,41 +94,35 @@ export function JwtDecoderPage() { setInput(sampleJwt) } - const handleCopyHeader = async () => { + const handleCopyHeader = () => { if (!header) return - const result = await safeClipboardWrite(header) - if (!result.ok) { - toast.error(t.common.copy_failed) - return - } - toast.success(t.common.copied, { - description: toolT.header_copied || "Header copied", - }) + return runCopyAction(() => copyTextWithLazyToolFeedback( + t, + header, + toolT.header_label, + toolT.header_copied || "Header copied", + )) } - const handleCopyPayload = async () => { + const handleCopyPayload = () => { if (!payload) return - const result = await safeClipboardWrite(payload) - if (!result.ok) { - toast.error(t.common.copy_failed) - return - } - toast.success(t.common.copied, { - description: toolT.payload_copied || "Payload copied", - }) + return runCopyAction(() => copyTextWithLazyToolFeedback( + t, + payload, + toolT.payload_label, + toolT.payload_copied || "Payload copied", + )) } - const handleCopyClaim = async (claim: JwtClaimSemantic) => { + const handleCopyClaim = (claim: JwtClaimSemantic) => { const value = getClaimCopyValue(claim) if (!value) return - const result = await safeClipboardWrite(value) - if (!result.ok) { - toast.error(t.common.copy_failed) - return - } - toast.success(t.common.copied, { - description: toolT.claim_copied.replace("{claim}", claim.claim), - }) + return runCopyAction(() => copyTextWithLazyToolFeedback( + t, + value, + claim.claim, + toolT.claim_copied.replace("{claim}", claim.claim), + )) } const actions: ToolAction[] = [ @@ -160,6 +155,8 @@ export function JwtDecoderPage() {
+ +
diff --git a/src/features/tools/list-randomizer/page.tsx b/src/features/tools/list-randomizer/page.tsx index ec6e1c4e..0d5fbc08 100644 --- a/src/features/tools/list-randomizer/page.tsx +++ b/src/features/tools/list-randomizer/page.tsx @@ -8,7 +8,8 @@ import { RelatedTools } from "@/core/seo/components/related-tools" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" -import { safeClipboardWrite } from "@/core/clipboard/clipboard" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" import { readStorageJson, writeStorageJson } from "@/core/storage/tool-persistence" import { importTextFile, TEXT_FILE_IMPORT_ACCEPT } from "@/core/files/text-file-import" import { randomizeList, type RandomizeMode } from "@/features/tools/list-randomizer/utils" @@ -80,6 +81,7 @@ export function ListRandomizerPage() { const [hydrated, setHydrated] = React.useState(false) const [importError, setImportError] = React.useState(null) const [isImportDragActive, setIsImportDragActive] = React.useState(false) + const { feedback: copyFeedback, run: runCopyAction } = useInlineToolActionFeedback() const fileInputRef = React.useRef(null) React.useEffect(() => { @@ -181,12 +183,12 @@ export function ListRandomizerPage() { } const handleCopy = async () => { - const result = await safeClipboardWrite(output) - if (!result.ok) { - toast.error(t.common.copy_failed) - return - } - toast.success(t.common.copied) + const result = await runCopyAction(() => copyTextWithLazyToolFeedback( + t, + output, + t.common.output, + )) + return result.announce ? { ...result, announce: false } : result } const handleDownload = () => downloadTextFile(output, "randomized-list.txt") @@ -215,6 +217,8 @@ export function ListRandomizerPage() {
+ +
{ diff --git a/src/features/tools/markdown-preview/page.tsx b/src/features/tools/markdown-preview/page.tsx index 0aeb36a6..1662dda7 100644 --- a/src/features/tools/markdown-preview/page.tsx +++ b/src/features/tools/markdown-preview/page.tsx @@ -4,7 +4,8 @@ import * as React from "react" import dynamic from "next/dynamic" import { Copy, Eye, Code2, Download, Trash2, ShieldCheck } from "lucide-react" import { useLang } from "@/core/i18n/lang-provider" -import { safeClipboardWrite } from "@/core/clipboard/clipboard" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" import { buildMarkdownExportDocument, sanitizeMarkdownPreviewHtml } from "./export" import { WideToolPageContainer } from "@/components/layout/page-container" @@ -138,6 +139,7 @@ export function MarkdownPreviewPage() { const deferredMarkdown = React.useDeferredValue(markdown) const [previewEnabled, setPreviewEnabled] = React.useState(false) const toolT = t.tools["markdown_preview"] as Record + const { feedback: copyFeedback, run: runCopyAction } = useInlineToolActionFeedback() const editorVisible = view === "editor" || view === "split" const previewVisible = view === "preview" || view === "split" @@ -186,13 +188,8 @@ export function MarkdownPreviewPage() { } }, [previewEnabled, previewVisible]) - const handleCopy = async () => { - const result = await safeClipboardWrite(markdown) - if (!result.ok) { - await notifyError(t.common.copy_failed) - return - } - await notifySuccess(t.common.copied, toolT.copied_md) + const handleCopy = () => { + return runCopyAction(() => copyTextWithLazyToolFeedback(t, markdown, toolT.md_source, toolT.copied_md)) } const handleCopyHtml = async () => { @@ -202,12 +199,12 @@ export function MarkdownPreviewPage() { return } - const result = await safeClipboardWrite(sanitizeMarkdownPreviewHtml(previewHtml)) - if (!result.ok) { - await notifyError(t.common.copy_failed) - return - } - await notifySuccess(t.common.copied, toolT.copied_html) + return runCopyAction(() => copyTextWithLazyToolFeedback( + t, + sanitizeMarkdownPreviewHtml(previewHtml), + toolT.copy_html, + toolT.copied_html, + )) } const handleDownloadHtml = () => { @@ -304,6 +301,8 @@ export function MarkdownPreviewPage() {
+ + {/* Content */}
{/* Editor Pane */} diff --git a/src/features/tools/qr-code-generator/page.tsx b/src/features/tools/qr-code-generator/page.tsx index 60e2edca..d92d23df 100644 --- a/src/features/tools/qr-code-generator/page.tsx +++ b/src/features/tools/qr-code-generator/page.tsx @@ -4,8 +4,9 @@ import * as React from "react" import { Copy, Download, ExternalLink, QrCode, RotateCcw, TestTube2, Trash2, Upload } from "lucide-react" import { useLang } from "@/core/i18n/lang-provider" import { ToolActionBar, type ToolAction, type ToolActionResult } from "@/features/tool-shell/tool-action-bar" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" import { RelatedTools } from "@/core/seo/components/related-tools" -import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { FILE_INPUT_POLICIES, validateFileAgainstPolicy } from "@/core/files/file-input-policy" import { parseSafeExternalUrl } from "@/core/security/external-url" import { @@ -67,6 +68,7 @@ export function QrCodeGeneratorPage() { const [decodeError, setDecodeError] = React.useState("") const [decodeFileName, setDecodeFileName] = React.useState("") const [decodeDragActive, setDecodeDragActive] = React.useState(false) + const { feedback: copyFeedback, run: runCopyAction } = useInlineToolActionFeedback() const canvasRef = React.useRef(null) const logoInputRef = React.useRef(null) @@ -221,12 +223,13 @@ export function QrCodeGeneratorPage() { const handleCopyDataUrl = async () => { if (!dataUrl) return - const result = await safeClipboardWrite(dataUrl) - if (!result.ok) { - await notifyError(t.common.copy_failed) - return - } - await notifySuccess(t.common.copied, textFor("copied_data_url")) + const result = await runCopyAction(() => copyTextWithLazyToolFeedback( + t, + dataUrl, + t.common.output, + textFor("copied_data_url"), + )) + return result.announce ? { ...result, announce: false } : result } const handleReset = () => { @@ -279,13 +282,13 @@ export function QrCodeGeneratorPage() { const handleCopyDecoded = async (): Promise => { if (!decodedPayload) return { status: "failed", message: t.common.copy_failed } - const result = await safeClipboardWrite(decodedPayload) - if (!result.ok) { - await notifyError(t.common.copy_failed) - return { status: "failed", message: t.common.copy_failed } - } - await notifySuccess(t.common.copied, textFor("decode_copy_success")) - return { status: "success", message: textFor("decode_copy_success") } + const result = await runCopyAction(() => copyTextWithLazyToolFeedback( + t, + decodedPayload, + textFor("decode_result"), + textFor("decode_copy_success"), + )) + return result.announce ? { ...result, announce: false } : result } const generateActions: ToolAction[] = [ @@ -377,6 +380,8 @@ export function QrCodeGeneratorPage() {
+ + {mode === "generate" ? ( (null) + + React.useEffect(() => { + if (!active) return + const viewport = window.visualViewport + if (!viewport) return + + const syncRect = () => { + setRect({ + top: viewport.offsetTop, + left: viewport.offsetLeft, + width: viewport.width, + height: viewport.height, + }) + } + + syncRect() + viewport.addEventListener("resize", syncRect) + viewport.addEventListener("scroll", syncRect) + return () => { + viewport.removeEventListener("resize", syncRect) + viewport.removeEventListener("scroll", syncRect) + } + }, [active]) + + return rect +} diff --git a/tests/component/jwt-decoder-actions.test.tsx b/tests/component/jwt-decoder-actions.test.tsx index 845c137f..7631feb8 100644 --- a/tests/component/jwt-decoder-actions.test.tsx +++ b/tests/component/jwt-decoder-actions.test.tsx @@ -1,5 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { toast } from "sonner" +import { drainQueuedToastFeedback, setToastLiveRegionReady } from "@/core/feedback/toast-live-region-state" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" import { JwtDecoderPage } from "@/features/tools/jwt-decoder/page" @@ -48,6 +50,9 @@ function localStorageValues() { describe("JwtDecoderPage actions", () => { beforeEach(() => { + vi.clearAllMocks() + setToastLiveRegionReady(false) + drainQueuedToastFeedback() const store = new Map() Object.defineProperty(window, "localStorage", { configurable: true, @@ -68,6 +73,27 @@ describe("JwtDecoderPage actions", () => { }) }) + it("shows immediate first-copy feedback without depending on the toaster", async () => { + renderJwtDecoder() + + fireEvent.click(screen.getByRole("button", { name: "Sample" })) + const copyHeader = await screen.findByRole("button", { name: "Copy: Header" }) + await waitFor(() => expect(copyHeader).toBeEnabled()) + fireEvent.click(copyHeader) + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('"alg": "HS256"')) + }) + const inlineFeedback = await waitFor(() => { + const element = document.querySelector("[data-inline-tool-action-feedback]") + expect(element).toBeVisible() + return element! + }) + expect(inlineFeedback).toHaveTextContent("Copied to clipboard. Header copied") + expect(toast.success).not.toHaveBeenCalled() + expect(drainQueuedToastFeedback()).toEqual([]) + }) + it("keeps decode-only warning visible and copies claim values", async () => { renderJwtDecoder() @@ -80,10 +106,14 @@ describe("JwtDecoderPage actions", () => { }) fireEvent.click(screen.getByRole("button", { name: "Copy exp claim value" })) - expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith("1516242622") + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith("1516242622") + }) fireEvent.click(screen.getByRole("button", { name: "Copy iat claim value" })) - expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith("1516239022") + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith("1516239022") + }) expect(screen.getByText("This token is expired according to its exp claim.")).toBeInTheDocument() }) diff --git a/tests/component/lazy-tool-action-feedback-fallback.test.tsx b/tests/component/lazy-tool-action-feedback-fallback.test.tsx new file mode 100644 index 00000000..5d43487b --- /dev/null +++ b/tests/component/lazy-tool-action-feedback-fallback.test.tsx @@ -0,0 +1,241 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { createRoot } from "react-dom/client" +import { afterEach, describe, expect, it, vi } from "vitest" +import { getTranslation } from "@/core/i18n/translations/catalog" +import { drainQueuedToastFeedback, setToastLiveRegionReady } from "@/core/feedback/toast-live-region-state" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" + +vi.mock("sonner", () => { + throw new Error("simulated toast chunk load failure") +}) + +describe("lazy tool action feedback fallback", () => { + afterEach(() => { + setToastLiveRegionReady(false) + drainQueuedToastFeedback() + }) + + it("keeps copy success visible and announced when the toast chunk cannot load", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }) + setToastLiveRegionReady(true) + drainQueuedToastFeedback() + const translations = getTranslation("en") + + function CopyHarness() { + const { feedback, run } = useInlineToolActionFeedback() + return ( + <> + + + + ) + } + + render() + fireEvent.click(screen.getByRole("button", { name: "Copy output" })) + + await waitFor(() => expect(writeText).toHaveBeenCalledWith("offline output")) + const status = await screen.findByRole("status") + expect(status).toBeVisible() + expect(status).toHaveTextContent("Copied to clipboard. Output copied.") + expect(status).toHaveClass("max-w-full", "break-words", "[overflow-wrap:anywhere]") + const viewport = document.querySelector("[data-inline-tool-action-feedback-viewport]") + expect(viewport).toHaveClass("fixed", "inset-0", "z-[100]") + expect(viewport?.parentElement).toBe(document.body) + }) + + it("dismisses successful fallback feedback and resets the timer after repeated actions", async () => { + vi.useFakeTimers() + + try { + function FeedbackHarness() { + const { feedback, run } = useInlineToolActionFeedback() + return ( + <> + + + + ) + } + + render() + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy" }))) + expect(screen.getByRole("status")).toBeVisible() + + act(() => vi.advanceTimersByTime(4999)) + expect(screen.getByRole("status")).toBeVisible() + + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy" }))) + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole("status")).toBeVisible() + + act(() => vi.advanceTimersByTime(4999)) + expect(screen.queryByRole("status")).not.toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + + it("anchors fallback feedback to the visual viewport", async () => { + const originalVisualViewport = Object.getOwnPropertyDescriptor(window, "visualViewport") + const viewport = { + offsetTop: 20, + offsetLeft: 0, + width: 390, + height: 844, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: viewport, + }) + + const view = render( + , + ) + + try { + const feedbackViewport = await waitFor(() => { + const element = document.querySelector("[data-inline-tool-action-feedback-viewport]") + expect(element).toHaveStyle({ top: "20px", left: "0px", width: "390px", height: "844px" }) + return element! + }) + expect(feedbackViewport).not.toHaveClass("inset-0") + expect(viewport.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function)) + expect(viewport.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function)) + } finally { + view.unmount() + if (originalVisualViewport) { + Object.defineProperty(window, "visualViewport", originalVisualViewport) + } else { + Reflect.deleteProperty(window, "visualViewport") + } + } + }) + + it("does not create a dismissal timer when an action finishes after unmount", async () => { + vi.useFakeTimers() + let resolveAction: ((result: { + status: "success" + message: string + announce: true + }) => void) | undefined + + try { + function DeferredFeedbackHarness() { + const { run } = useInlineToolActionFeedback() + return ( + + ) + } + + const view = render() + fireEvent.click(screen.getByRole("button", { name: "Copy later" })) + expect(resolveAction).toBeTypeOf("function") + view.unmount() + + await act(async () => resolveAction?.({ + status: "success", + message: "Copied", + announce: true, + })) + + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it("keeps feedback for a click between commit and passive effects", async () => { + const container = document.createElement("div") + document.body.appendChild(container) + const root = createRoot(container) + const reactEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + const previousActEnvironment = reactEnvironment.IS_REACT_ACT_ENVIRONMENT + reactEnvironment.IS_REACT_ACT_ENVIRONMENT = false + const observerRef: { current: MutationObserver | null } = { current: null } + + function CommitWindowHarness() { + const { feedback, run } = useInlineToolActionFeedback() + return ( + + ) + } + + try { + const clickedAfterCommit = new Promise((resolve) => { + observerRef.current = new MutationObserver(() => { + const button = container.querySelector("button") + if (!button) return + observerRef.current?.disconnect() + button.dispatchEvent(new window.MouseEvent("click", { bubbles: true })) + resolve() + }) + observerRef.current.observe(container, { childList: true, subtree: true }) + }) + + root.render() + await clickedAfterCommit + await waitFor(() => { + expect(container.querySelector("button")).toHaveTextContent("Copied before passive effects") + }) + } finally { + observerRef.current?.disconnect() + root.unmount() + container.remove() + reactEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment + } + }) +}) diff --git a/tests/component/qr-code-generator-logo-upload.test.tsx b/tests/component/qr-code-generator-logo-upload.test.tsx index 93fe9057..fd14ab4b 100644 --- a/tests/component/qr-code-generator-logo-upload.test.tsx +++ b/tests/component/qr-code-generator-logo-upload.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { FILE_INPUT_POLICIES } from "@/core/files/file-input-policy" +import { drainQueuedToastFeedback, setToastLiveRegionReady } from "@/core/feedback/toast-live-region-state" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" import { QrCodeGeneratorPage } from "@/features/tools/qr-code-generator/page" @@ -62,6 +63,8 @@ describe("QR code generator logo uploads", () => { }) toCanvasMock.mockResolvedValue(undefined) decodeQrImageFileMock.mockResolvedValue({ ok: false, error: "no_qr" }) + setToastLiveRegionReady(false) + drainQueuedToastFeedback() HTMLCanvasElement.prototype.getContext = vi.fn(() => null) as unknown as HTMLCanvasElement["getContext"] HTMLCanvasElement.prototype.toDataURL = vi.fn(() => "data:image/png;base64,qr-code") }) @@ -134,6 +137,34 @@ describe("QR code generator logo uploads", () => { expect(screen.getByText(/Size:\s*256px/)).toBeInTheDocument() }) + it("uses one inline announcement while copy toast feedback is not mounted", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }) + const { container } = renderPage() + const content = container.querySelector("#qr-content") + + fireEvent.change(content!, { target: { value: "https://byteflow.tools/qr-copy" } }) + const copyButton = screen.getByRole("button", { name: "Copy" }) + await waitFor(() => expect(copyButton).toBeEnabled()) + fireEvent.click(copyButton) + + await waitFor(() => expect(writeText).toHaveBeenCalledWith("data:image/png;base64,qr-code")) + const inlineFeedback = await waitFor(() => { + const element = document.querySelector("[data-inline-tool-action-feedback]") + expect(element).toBeVisible() + return element! + }) + expect(inlineFeedback).toHaveTextContent("Copied to clipboard. Data URL copied to clipboard") + await waitFor(() => { + expect(document.querySelector("[data-tool-action-status]")).not.toBeInTheDocument() + }) + + expect(drainQueuedToastFeedback()).toEqual([]) + }) + it("decodes an uploaded QR image without opening URL payloads automatically", async () => { const open = vi.spyOn(window, "open") decodeQrImageFileMock.mockResolvedValueOnce({ diff --git a/tests/component/toaster-live-region.test.tsx b/tests/component/toaster-live-region.test.tsx index d95e8051..84aed190 100644 --- a/tests/component/toaster-live-region.test.tsx +++ b/tests/component/toaster-live-region.test.tsx @@ -4,8 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { toast } from "sonner" import { AppToaster } from "@/components/ui/app-toaster" import { Toaster } from "@/components/ui/sonner" +import { drainQueuedToastFeedback, isToastLiveRegionReady, setToastLiveRegionReady } from "@/core/feedback/toast-live-region-state" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" +import { InlineToolActionFeedback, useInlineToolActionFeedback } from "@/features/tool-shell/inline-tool-action-feedback" +import { copyTextWithLazyToolFeedback } from "@/features/tool-shell/lazy-tool-action-feedback" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" import { notifyToolActionSuccess } from "@/features/tool-shell/tool-action-feedback" @@ -16,6 +19,8 @@ vi.mock("next/navigation", () => ({ describe("Toaster live region", () => { beforeEach(() => { toast.dismiss() + setToastLiveRegionReady(false) + drainQueuedToastFeedback() }) it("announces the latest toast title and description for assistive technology", async () => { @@ -89,4 +94,108 @@ describe("Toaster live region", () => { expect(replayLiveModes).toEqual(new Set(["off"])) expect(screen.getAllByRole("status")).toHaveLength(1) }) + + it("uses inline feedback before toaster mount and toast feedback afterward", async () => { + const translations = getTranslation("en") + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }) + + function NativeCopyAction() { + const { feedback, run } = useInlineToolActionFeedback() + return ( + <> + + + + ) + } + + const view = render() + fireEvent.click(screen.getByRole("button", { name: "Copy native output" })) + await waitFor(() => expect(writeText).toHaveBeenCalledWith("native output")) + const immediateFeedback = await screen.findByRole("status") + expect(immediateFeedback).toHaveAttribute("data-inline-tool-action-feedback") + expect(immediateFeedback).toHaveTextContent("Copied to clipboard. Output copied.") + + const replayLiveModes = new Set() + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const addedNode of record.addedNodes) { + if (!(addedNode instanceof Element)) continue + const toastNode = addedNode.matches("[data-sonner-toast]") + ? addedNode + : addedNode.querySelector("[data-sonner-toast]") + if (toastNode) replayLiveModes.add(toastNode.closest("section")?.getAttribute("aria-live") ?? null) + } + } + }) + observer.observe(document.body, { childList: true, subtree: true }) + + view.rerender( + <> + + + , + ) + + await waitFor(() => { + expect(isToastLiveRegionReady()).toBe(true) + }) + expect(document.querySelector("[data-sonner-toast]")).not.toBeInTheDocument() + expect(document.querySelector("[data-inline-tool-action-feedback]")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Copy native output" })) + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2)) + await waitFor(() => { + expect(document.querySelector('section[aria-live="polite"]')).toHaveTextContent(/Copied to clipboard\s*Output copied\./) + }) + await waitFor(() => { + expect(document.querySelector("[data-inline-tool-action-feedback]")).not.toBeInTheDocument() + }) + observer.disconnect() + expect(replayLiveModes).toEqual(new Set(["polite"])) + }) + + it("shows an accessible inline fallback when toast feedback cannot load", async () => { + function FallbackAction() { + const { feedback, run } = useInlineToolActionFeedback() + return ( + <> + + + + ) + } + + render() + fireEvent.click(screen.getByRole("button", { name: "Copy with fallback" })) + + const status = await screen.findByRole("status") + expect(status).toBeVisible() + expect(status).toHaveTextContent("Copied to clipboard. Output copied.") + expect(status).toHaveAttribute("data-inline-tool-action-feedback") + }) }) diff --git a/tests/guards/csv-json-converter-performance-guard.test.ts b/tests/guards/csv-json-converter-performance-guard.test.ts index 553f7ccd..dfd98b40 100644 --- a/tests/guards/csv-json-converter-performance-guard.test.ts +++ b/tests/guards/csv-json-converter-performance-guard.test.ts @@ -12,11 +12,21 @@ describe("csv json converter performance guard", () => { path.join(process.cwd(), "src/features/tools/csv-json-converter/components.tsx"), "utf8", ) + const lazyFeedbackSource = fs.readFileSync( + path.join(process.cwd(), "src/features/tool-shell/lazy-tool-action-feedback.ts"), + "utf8", + ) expect(source).not.toContain('from "sonner"') + expect(source).not.toContain('from "@/features/tool-shell/tool-action-feedback"') expect(source).not.toContain('from "@/components/ui/button"') expect(componentsSource).not.toContain('from "@/components/ui/button"') - expect(source).toContain('await import("sonner")') + expect(source).toContain("copyTextWithLazyToolFeedback") + expect(lazyFeedbackSource).toContain('await import("sonner")') + expect(lazyFeedbackSource).toContain('import { safeClipboardWrite } from "@/core/clipboard/clipboard"') + expect(lazyFeedbackSource.indexOf("await safeClipboardWrite")).toBeLessThan( + lazyFeedbackSource.indexOf('await import("sonner")'), + ) expect(componentsSource).toContain("function InlineButton(") }) diff --git a/tests/guards/playwright-smoke-matrix-guard.test.ts b/tests/guards/playwright-smoke-matrix-guard.test.ts index c7fd86fd..429d5c1b 100644 --- a/tests/guards/playwright-smoke-matrix-guard.test.ts +++ b/tests/guards/playwright-smoke-matrix-guard.test.ts @@ -74,6 +74,15 @@ describe("playwright smoke matrix guard", () => { expect(SMOKE_SOURCE).toContain('getByRole("dialog", { name: /Show filters/i })') expect(SMOKE_SOURCE).toContain("assertNoHorizontalOverflow") expect(SMOKE_SOURCE).toContain("clickCopyAndExpectToast") + const copyHelper = SMOKE_SOURCE.match( + /async function clickCopyAndExpectToast[\s\S]*?(?=\nasync function assertMobileToolJourney)/ + )?.[0] + expect(copyHelper).toBeDefined() + expect(copyHelper).not.toContain("waitForTimeout") + expect(copyHelper).toContain("[data-sonner-toast], [data-inline-tool-action-feedback]") + expect(copyHelper).toContain("Number.parseFloat(style.opacity) >= 0.99") + expect(copyHelper).toContain("rect.bottom <= viewportBottom + 1") + expect(copyHelper).not.toContain("page.getByText(/copied/i)") expect(SMOKE_SOURCE).toContain("MOBILE_REVIEW_ROUTES") expect(SMOKE_SOURCE).toContain('"/en/install-app"') expect(SMOKE_SOURCE).toContain('"/en/trust-center"') diff --git a/tests/guards/tool-action-consistency-guard.test.ts b/tests/guards/tool-action-consistency-guard.test.ts index 4d2b9ac3..ecbd72de 100644 --- a/tests/guards/tool-action-consistency-guard.test.ts +++ b/tests/guards/tool-action-consistency-guard.test.ts @@ -113,6 +113,12 @@ describe("tool action consistency guard", () => { "src/features/tools/curl-to-code/page.tsx", "src/features/tools/url-parser/page.tsx", "src/features/tools/pipeline-builder/page.tsx", + "src/features/tools/jwt-decoder/page.tsx", + "src/features/tools/markdown-preview/page.tsx", + "src/features/tools/crontab-generator/page.tsx", + "src/features/tools/qr-code-generator/page.tsx", + "src/features/tools/csv-json-converter/page.tsx", + "src/features/tools/list-randomizer/page.tsx", ] expect(read("src/features/tool-shell/tool-action-feedback.ts")).toContain("copyTextWithToolFeedback") @@ -121,14 +127,41 @@ describe("tool action consistency guard", () => { for (const file of sharedFeedbackTools) { const source = read(file) - expect(source, file).toMatch(/copyTextWithToolFeedback|downloadedFileFeedback|notifyToolAction(Failure|Success)/) + expect(source, file).toMatch(/copyTextWith(?:Lazy)?ToolFeedback|downloadedFileFeedback|notifyToolAction(Failure|Success)/) + } + + const lazyFeedbackTools = [ + "src/features/tools/jwt-decoder/page.tsx", + "src/features/tools/markdown-preview/page.tsx", + "src/features/tools/crontab-generator/page.tsx", + "src/features/tools/qr-code-generator/page.tsx", + "src/features/tools/csv-json-converter/page.tsx", + "src/features/tools/list-randomizer/page.tsx", + ] + for (const file of lazyFeedbackTools) { + const source = read(file) + expect(source, file).toContain("copyTextWithLazyToolFeedback") + expect(source, file).not.toContain('from "@/features/tool-shell/tool-action-feedback"') + } + + for (const file of lazyFeedbackTools) { + const source = read(file) + expect(source, file).toContain("useInlineToolActionFeedback") + expect(source, file).toContain("