diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
index c75ef9c2457..a1a2acfba9c 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
@@ -15,6 +15,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
Duplicate,
+ OverlayActionButton,
Search as SearchIcon,
Tooltip,
useCopyToClipboard,
@@ -504,39 +505,35 @@ function DetailCodeSection({
-
+
{copied ? 'Copied' : 'Copy'}
-
+
Search
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
index 492d2fb1d5e..60dea0fb0ed 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
@@ -27,6 +27,7 @@ import {
Duplicate,
Eye,
handleKeyboardActivation,
+ OverlayActionButton,
Redo,
Search as SearchIcon,
Tooltip,
@@ -168,39 +169,35 @@ export const WorkflowOutputSection = memo(
-
+
{copied ? 'Copied' : 'Copy'}
-
+
Search
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
index 231ea553c16..b38f2324a58 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
@@ -2,7 +2,6 @@
import { useId, useState } from 'react'
import {
- Button,
ChipButtonGroup,
ChipButtonGroupItem,
ChipConfirmModal,
@@ -12,6 +11,7 @@ import {
cn,
Expand,
Label,
+ OverlayActionButton,
Skeleton,
Tooltip,
} from '@sim/emcn'
@@ -241,15 +241,15 @@ export function GeneralDeploy({
-
+
See preview
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
index 6a1ce331611..8dd52e81c14 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
@@ -12,6 +12,7 @@ import {
Input,
Label,
OverflowText,
+ OverlayActionButton,
Tooltip,
} from '@sim/emcn'
import {
@@ -1235,22 +1236,20 @@ function PreviewEditorContent({
-
+
{copiedSection === 'input' ? 'Copied' : 'Copy'}
@@ -1258,18 +1257,16 @@ function PreviewEditorContent({
-
+
Search
@@ -1309,22 +1306,20 @@ function PreviewEditorContent({
-
+
{copiedSection === 'output' ? 'Copied' : 'Copy'}
@@ -1332,18 +1327,16 @@ function PreviewEditorContent({
-
+
Search
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index 6628b0a2d1b..8b6df9af42c 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -196,6 +196,11 @@ export {
overflowTextClipClass,
overflowTextFadeClass,
} from './overflow-text/overflow-text'
+export {
+ OverlayActionButton,
+ type OverlayActionButtonProps,
+ overlayActionButtonVariants,
+} from './overlay-action-button/overlay-action-button'
export {
Popover,
PopoverAnchor,
diff --git a/packages/emcn/src/components/overlay-action-button/overlay-action-button.test.tsx b/packages/emcn/src/components/overlay-action-button/overlay-action-button.test.tsx
new file mode 100644
index 00000000000..cd1ecca1876
--- /dev/null
+++ b/packages/emcn/src/components/overlay-action-button/overlay-action-button.test.tsx
@@ -0,0 +1,117 @@
+/** @vitest-environment jsdom */
+import { act, createRef, type ReactNode } from 'react'
+import { Button, OverlayActionButton, Tooltip } from '@sim/emcn'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function mount(children: ReactNode) {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() => root?.render(children))
+ return container
+}
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+ vi.useRealTimers()
+})
+
+/** Pre-migration recipes from log details and the deployment preview. */
+const PREVIOUS = [
+ {
+ name: 'default 20px adaptive action',
+ props: {},
+ variant: 'default',
+ className:
+ 'size-[20px] cursor-pointer border-[var(--border-1)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]',
+ },
+ {
+ name: '28px adaptive action',
+ props: { size: 'md' },
+ variant: 'default',
+ className:
+ 'size-[28px] cursor-pointer bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]',
+ },
+] as const
+
+describe('OverlayActionButton', () => {
+ it.each(PREVIOUS)('preserves the previous $name markup', ({ props, variant, className }) => {
+ const view = mount(
+ <>
+
+
+
+
+ >
+ )
+ const [previous, current] = view.querySelectorAll('button')
+ /** The old border-1 token aliases border; class order changes when recipes are composed. */
+ for (const button of [previous, current]) {
+ button.className = button.className
+ .replaceAll('--border-1', '--border')
+ .split(/\s+/)
+ .sort()
+ .join(' ')
+ }
+ expect(current.outerHTML).toBe(previous.outerHTML)
+ })
+
+ it('forwards refs and native props through a tooltip and suppresses disabled clicks', () => {
+ vi.useFakeTimers()
+ const ref = createRef()
+ const onClick = vi.fn()
+ const onKeyDown = vi.fn()
+ const action = (disabled: boolean) => (
+
+
+
+
+ Copy output
+
+ )
+ const view = mount(action(false))
+ const button = view.querySelector('button')
+ if (!button) throw new Error('Button did not render')
+ expect(view.querySelectorAll('button')).toHaveLength(1)
+ expect(ref.current).toBe(button)
+ expect(button.type).toBe('button')
+ expect(button.dataset.action).toBe('copy')
+ expect(button.getAttribute('aria-label')).toBe('Copy')
+ act(() =>
+ button.dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
+ )
+ )
+ expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Copy output')
+ act(() => button.focus())
+ expect(document.activeElement).toBe(button)
+ const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
+ act(() => button.dispatchEvent(keyEvent))
+ expect(onKeyDown).toHaveBeenCalledTimes(1)
+ expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent)
+ act(() => button.click())
+ expect(onClick).toHaveBeenCalledTimes(1)
+ act(() => root?.render(action(true)))
+ expect(button.disabled).toBe(true)
+ act(() => button.click())
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/emcn/src/components/overlay-action-button/overlay-action-button.tsx b/packages/emcn/src/components/overlay-action-button/overlay-action-button.tsx
new file mode 100644
index 00000000000..6a2f52fc4f0
--- /dev/null
+++ b/packages/emcn/src/components/overlay-action-button/overlay-action-button.tsx
@@ -0,0 +1,49 @@
+import { forwardRef } from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '../../lib/cn'
+import { Button, type ButtonProps } from '../button/button'
+
+/** Transparent, bordered icon action over code or preview content. */
+export const overlayActionButtonVariants = cva(
+ 'cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)] dark:hover-hover:bg-[var(--surface-5)] hover-hover:border-[var(--border)]',
+ {
+ variants: {
+ size: {
+ sm: 'size-[20px]',
+ md: 'size-[28px]',
+ },
+ },
+ defaultVariants: { size: 'sm' },
+ }
+)
+
+export interface OverlayActionButtonProps
+ extends Omit {
+ /** Accessible name for the icon action; tooltip content is supplied separately. */
+ 'aria-label': string
+ /** 20px by default; `md` provides the 28px preview action. */
+ size?: NonNullable['size']>
+}
+
+/**
+ * Icon action floating over content. Owns geometry, border, blur and hover treatment;
+ * callers supply positioning, icons, labels and command behavior.
+ * Hover uses surface-3 in light mode and surface-5 in dark mode.
+ * Forwards the native button ref and props for tooltip `asChild` composition.
+ * Native form behavior is inherited from Button; pass `type` when it must be explicit.
+ *
+ * @example
+ */
+export const OverlayActionButton = forwardRef(
+ ({ size, className, ...props }, ref) => (
+
+ )
+)
+
+OverlayActionButton.displayName = 'OverlayActionButton'