{line.lineNumber}
@@ -740,7 +747,8 @@ function CodeRow({
)}
scrollRef.current,
- estimateSize: () => CODE_LINE_HEIGHT_PX,
+ estimateSize: () => (density === 'compact' ? COMPACT_CODE_LINE_HEIGHT_PX : CODE_LINE_HEIGHT_PX),
overscan: 5,
})
/**
- * Drop cached row measurements when leaving wrap mode: the measureElement
- * refs detach with their wrapped heights still cached, and falling back to
- * the fixed estimate is exactly correct for nowrap rows. Entering wrap needs
- * no reset — refs re-attach and re-measure as rows render.
+ * Drop cached row measurements when leaving wrap mode or changing density:
+ * the measureElement refs detach with their wrapped heights still cached,
+ * and falling back to the current fixed estimate is exactly correct for
+ * nowrap rows. Entering wrap needs no reset — refs re-attach and re-measure
+ * as rows render.
*
* Deliberately NOT keyed on content (`visibleLines`): `measure()` wipes the
* cache without re-measuring mounted rows (ResizeObserver only fires on size
@@ -1029,7 +1045,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
*/
useEffect(() => {
if (!wrapText) virtualizer.measure()
- }, [wrapText, virtualizer])
+ }, [density, wrapText, virtualizer])
useEffect(() => {
if (!searchQuery?.trim() || matchCount === 0 || !scrollRef.current) return
@@ -1107,6 +1123,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
gutterWidth={gutterWidth}
showGutter={showGutter}
gutterStyle={gutterStyle}
+ density={density}
leftOffset={paddingLeft}
wrapText={wrapText}
showCollapseColumn={effectiveShowCollapseColumn}
@@ -1131,6 +1148,7 @@ const ViewerInner = memo(function ViewerInner({
showGutter,
language,
className,
+ density,
paddingLeft,
gutterStyle,
wrapText,
@@ -1236,8 +1254,8 @@ const ViewerInner = memo(function ViewerInner({
{lineNumber}
@@ -1270,7 +1291,10 @@ const ViewerInner = memo(function ViewerInner({
)}
0 ? paddingLeft : undefined }}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
@@ -1330,6 +1357,7 @@ function Viewer({
showGutter = false,
language = 'json',
className,
+ density = 'default',
paddingLeft = 0,
gutterStyle,
wrapText = false,
@@ -1345,6 +1373,7 @@ function Viewer({
showGutter,
language,
className,
+ density,
paddingLeft,
gutterStyle,
wrapText,
diff --git a/packages/emcn/src/components/popover/popover.tsx b/packages/emcn/src/components/popover/popover.tsx
index 6ecd0d529a0..38cb02e2de3 100644
--- a/packages/emcn/src/components/popover/popover.tsx
+++ b/packages/emcn/src/components/popover/popover.tsx
@@ -56,6 +56,7 @@ import { createPortal } from 'react-dom'
import { Check, ChevronLeft, ChevronRight, Search } from '../../icons'
import { cn } from '../../lib/cn'
import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
+import { TOOLTIP_MAX_WIDTH_PX, TOOLTIP_SURFACE_CLASS } from '../tooltip/tooltip-styles'
type PopoverSize = 'sm' | 'md'
type PopoverColorScheme = 'default' | 'inverted'
@@ -388,6 +389,11 @@ interface PopoverContentProps
* @default false
*/
border?: boolean
+ /**
+ * Applies a semantic platform surface treatment.
+ * @default 'default'
+ */
+ appearance?: 'default' | 'tooltip'
/**
* Flip to avoid viewport collisions
* @default true
@@ -428,6 +434,7 @@ const PopoverContent = React.forwardRef<
sideOffset,
collisionPadding = 8,
border = false,
+ appearance = 'default',
avoidCollisions = true,
showArrow = false,
arrowClassName,
@@ -528,8 +535,14 @@ const PopoverContent = React.forwardRef<
// management to avoid conflicts between the popover's internal selection index
// and the component's custom navigation state.
+ const effectiveMaxWidth =
+ maxWidth !== undefined
+ ? `${maxWidth}px`
+ : appearance === 'tooltip'
+ ? `min(${TOOLTIP_MAX_WIDTH_PX}px, calc(100vw - 2rem))`
+ : undefined
const hasUserWidthConstraint =
- maxWidth !== undefined ||
+ effectiveMaxWidth !== undefined ||
minWidth !== undefined ||
style?.minWidth !== undefined ||
style?.maxWidth !== undefined ||
@@ -590,6 +603,7 @@ const PopoverContent = React.forwardRef<
showArrow ? 'overflow-visible' : 'overflow-auto',
STYLES.colorScheme[colorScheme].content,
STYLES.content,
+ appearance === 'tooltip' && TOOLTIP_SURFACE_CLASS,
hasUserWidthConstraint &&
'[&_.flex-1:not([data-popover-scroll])]:truncate [&_[data-popover-section]]:truncate',
border && 'border border-[var(--border-1)]',
@@ -597,7 +611,7 @@ const PopoverContent = React.forwardRef<
)}
style={{
maxHeight: `${maxHeight || 400}px`,
- maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
+ maxWidth: effectiveMaxWidth ?? 'calc(100vw - 16px)',
minWidth:
minWidth !== undefined
? `${minWidth}px`
diff --git a/packages/emcn/src/components/tooltip/tooltip-styles.ts b/packages/emcn/src/components/tooltip/tooltip-styles.ts
new file mode 100644
index 00000000000..66f9d4f1638
--- /dev/null
+++ b/packages/emcn/src/components/tooltip/tooltip-styles.ts
@@ -0,0 +1,6 @@
+/** Canonical maximum width shared by standard and interactive tooltip surfaces. */
+export const TOOLTIP_MAX_WIDTH_PX = 256
+
+/** Canonical platform tooltip chrome, without positioning or content padding. */
+export const TOOLTIP_SURFACE_CLASS =
+ 'w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text-body)] text-caption shadow-sm'
diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx
index 265dca0277b..601ec570d67 100644
--- a/packages/emcn/src/components/tooltip/tooltip.tsx
+++ b/packages/emcn/src/components/tooltip/tooltip.tsx
@@ -4,6 +4,7 @@ import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { createPortal } from 'react-dom'
import { cn } from '../../lib/cn'
+import { TOOLTIP_SURFACE_CLASS } from './tooltip-styles'
const TOOLTIP_OFFSET = 16
const EDGE_GUTTER = 16
@@ -365,7 +366,8 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({
aria-hidden={role ? undefined : 'true'}
data-native-surface-overlay=''
className={cn(
- 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,translate] duration-150 ease-out',
+ TOOLTIP_SURFACE_CLASS,
+ 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] px-2 py-1.5 opacity-100 transition-[opacity,translate] duration-150 ease-out',
'motion-reduce:transition-none',
className
)}
diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts
index ad4fdbcf999..1c8476eec05 100644
--- a/packages/workflow-renderer/src/index.ts
+++ b/packages/workflow-renderer/src/index.ts
@@ -38,7 +38,14 @@ export {
type SubflowNodeViewProps,
SubflowStartView,
} from './subflow/subflow-node-view'
-export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types'
+export type {
+ BlockRunStatus,
+ CodePreview,
+ CodePreviewLanguage,
+ DiffStatus,
+ EdgeDiffStatus,
+ EdgeRunStatus,
+} from './types'
export {
type CanvasSentenceSegment,
CanvasSentenceView,
diff --git a/packages/workflow-renderer/src/lib/code-hover-card.tsx b/packages/workflow-renderer/src/lib/code-hover-card.tsx
new file mode 100644
index 00000000000..16a7faca328
--- /dev/null
+++ b/packages/workflow-renderer/src/lib/code-hover-card.tsx
@@ -0,0 +1,183 @@
+'use client'
+
+import {
+ type KeyboardEvent as ReactKeyboardEvent,
+ type ReactNode,
+ type PointerEvent as ReactPointerEvent,
+ useCallback,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from 'react'
+import { Code, isTextClipped, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
+import type { CodePreview } from '../types'
+
+const OPEN_DELAY_MS = 300
+const TRIGGER_EXIT_GRACE_MS = 600
+const CONTENT_EXIT_GRACE_MS = 120
+const CODE_TOOLTIP_MAX_HEIGHT_PX = 256
+
+interface CodeHoverCardProps {
+ preview: CodePreview
+ className: string
+ children: ReactNode
+}
+
+/** Interactive, two-axis-scrollable source preview anchored to a canvas code chip. */
+export function CodeHoverCard({ preview, className, children }: CodeHoverCardProps) {
+ const [open, setOpen] = useState(false)
+ const contentId = useId()
+ const triggerRef = useRef(null)
+ const openTimerRef = useRef(null)
+ const closeTimerRef = useRef(null)
+
+ const clearOpenTimer = useCallback(() => {
+ if (openTimerRef.current === null) return
+ window.clearTimeout(openTimerRef.current)
+ openTimerRef.current = null
+ }, [])
+
+ const clearCloseTimer = useCallback(() => {
+ if (closeTimerRef.current === null) return
+ window.clearTimeout(closeTimerRef.current)
+ closeTimerRef.current = null
+ }, [])
+
+ const handleTriggerPointerEnter = (event: ReactPointerEvent) => {
+ if (!isTextClipped(event.currentTarget)) return
+ clearCloseTimer()
+ if (open || openTimerRef.current !== null) return
+ openTimerRef.current = window.setTimeout(() => {
+ openTimerRef.current = null
+ setOpen(true)
+ }, OPEN_DELAY_MS)
+ }
+
+ const scheduleClose = (delay: number) => {
+ clearOpenTimer()
+ clearCloseTimer()
+ closeTimerRef.current = window.setTimeout(() => {
+ closeTimerRef.current = null
+ setOpen(false)
+ }, delay)
+ }
+
+ const openImmediatelyIfClipped = (trigger: HTMLSpanElement) => {
+ if (!isTextClipped(trigger)) return
+ clearOpenTimer()
+ clearCloseTimer()
+ setOpen(true)
+ }
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (!nextOpen) {
+ clearOpenTimer()
+ clearCloseTimer()
+ }
+ setOpen(nextOpen)
+ },
+ [clearCloseTimer, clearOpenTimer]
+ )
+
+ useEffect(
+ () => () => {
+ clearOpenTimer()
+ clearCloseTimer()
+ },
+ [clearCloseTimer, clearOpenTimer]
+ )
+
+ const handleTriggerPointerDown = (event: ReactPointerEvent) => {
+ event.preventDefault()
+ if (event.pointerType === 'touch' || event.pointerType === 'pen') {
+ event.stopPropagation()
+ if (open) {
+ handleOpenChange(false)
+ } else {
+ openImmediatelyIfClipped(event.currentTarget)
+ }
+ return
+ }
+ handleOpenChange(false)
+ }
+
+ const handleTriggerKeyDown = (event: ReactKeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.stopPropagation()
+ handleOpenChange(false)
+ return
+ }
+ if (event.key !== 'Enter' && event.key !== ' ') return
+
+ event.preventDefault()
+ event.stopPropagation()
+ if (open) {
+ handleOpenChange(false)
+ } else {
+ openImmediatelyIfClipped(event.currentTarget)
+ }
+ }
+
+ const handleContentKeyDown = (event: ReactKeyboardEvent) => {
+ if (event.key !== 'Escape') return
+ event.stopPropagation()
+ handleOpenChange(false)
+ triggerRef.current?.focus()
+ }
+
+ return (
+
+
+ {
+ if (document.activeElement !== triggerRef.current) {
+ scheduleClose(TRIGGER_EXIT_GRACE_MS)
+ }
+ }}
+ onPointerDown={handleTriggerPointerDown}
+ onFocus={(event) => openImmediatelyIfClipped(event.currentTarget)}
+ onBlur={() => scheduleClose(TRIGGER_EXIT_GRACE_MS)}
+ onKeyDown={handleTriggerKeyDown}
+ >
+ {children}
+
+
+ scheduleClose(CONTENT_EXIT_GRACE_MS)}
+ onFocus={clearCloseTimer}
+ onBlur={() => scheduleClose(CONTENT_EXIT_GRACE_MS)}
+ onKeyDown={handleContentKeyDown}
+ className='nodrag nowheel overflow-hidden overscroll-contain p-0'
+ >
+
+
+
+ )
+}
diff --git a/packages/workflow-renderer/src/lib/overflow-span-mount.test.tsx b/packages/workflow-renderer/src/lib/overflow-span-mount.test.tsx
new file mode 100644
index 00000000000..7f959a3f520
--- /dev/null
+++ b/packages/workflow-renderer/src/lib/overflow-span-mount.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { OverflowSpan } from './overflow-span'
+
+let root: Root | null = null
+let host: HTMLDivElement | null = null
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ host = document.createElement('div')
+ document.body.appendChild(host)
+ root = createRoot(host)
+})
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ host?.remove()
+ root = null
+ host = null
+ vi.runOnlyPendingTimers()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+describe('OverflowSpan code preview', () => {
+ it('renders the complete source after the code-card dwell', () => {
+ const code = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join('\n')
+ act(() => {
+ root?.render(
+
+ )
+ })
+
+ const trigger = host?.querySelector('span')
+ if (!trigger) throw new Error('Overflow trigger did not render')
+ Object.defineProperties(trigger, {
+ clientWidth: { configurable: true, value: 50 },
+ scrollWidth: { configurable: true, value: 200 },
+ })
+
+ act(() => {
+ trigger.dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
+ )
+ vi.advanceTimersByTime(299)
+ })
+ expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
+
+ act(() => {
+ vi.advanceTimersByTime(1)
+ })
+ const preview = document.querySelector('[data-code-hover-card]')
+ expect(preview).toHaveTextContent('line 20')
+ expect(preview).toHaveClass('w-fit', 'max-w-[min(16rem,calc(100vw-2rem))]', 'shadow-sm')
+ expect(preview).toHaveStyle({ maxWidth: 'min(256px, calc(100vw - 2rem))' })
+ expect(preview?.querySelector('.overflow-x-auto')).toHaveClass('overflow-y-auto')
+ expect(preview?.querySelector('.tabular-nums')).toBeNull()
+ expect(preview?.querySelector('pre')).toHaveClass('px-2', 'py-1.5', 'text-caption', 'leading-5')
+ })
+
+ it('stays open while the pointer crosses into the scrollable preview', () => {
+ act(() => {
+ root?.render(
+
+ )
+ })
+
+ const trigger = host?.querySelector('span')
+ if (!trigger) throw new Error('Overflow trigger did not render')
+ Object.defineProperties(trigger, {
+ clientWidth: { configurable: true, value: 50 },
+ scrollWidth: { configurable: true, value: 200 },
+ })
+
+ act(() => {
+ trigger.dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
+ )
+ vi.advanceTimersByTime(300)
+ })
+ const preview = document.querySelector('[data-code-hover-card]')
+ if (!preview) throw new Error('Code preview did not render')
+
+ act(() => {
+ trigger.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
+ vi.advanceTimersByTime(500)
+ preview.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))
+ vi.advanceTimersByTime(600)
+ })
+ expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+
+ act(() => {
+ preview.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
+ vi.advanceTimersByTime(119)
+ })
+ expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+
+ act(() => {
+ vi.advanceTimersByTime(1)
+ })
+ expect(document.querySelector('[data-code-hover-card]')).toBeNull()
+ })
+
+ it('opens from the keyboard and keeps the preview available while it is focused', () => {
+ act(() => {
+ root?.render(
+
+ )
+ })
+
+ const trigger = host?.querySelector('span[role="button"]')
+ if (!trigger) throw new Error('Overflow trigger did not render')
+ Object.defineProperties(trigger, {
+ clientWidth: { configurable: true, value: 50 },
+ scrollWidth: { configurable: true, value: 200 },
+ })
+
+ act(() => trigger.focus())
+ const preview = document.querySelector('[data-code-hover-card]')
+ expect(preview).not.toBeNull()
+ expect(trigger).toHaveAttribute('aria-expanded', 'true')
+
+ act(() => preview?.focus())
+ act(() => vi.advanceTimersByTime(600))
+ expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+
+ act(() =>
+ preview?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }))
+ )
+ expect(document.querySelector('[data-code-hover-card]')).toBeNull()
+ expect(trigger).toHaveFocus()
+ })
+
+ it('toggles the clipped preview on touch', () => {
+ act(() => {
+ root?.render(
+
+ )
+ })
+
+ const trigger = host?.querySelector('span[role="button"]')
+ if (!trigger) throw new Error('Overflow trigger did not render')
+ Object.defineProperties(trigger, {
+ clientWidth: { configurable: true, value: 50 },
+ scrollWidth: { configurable: true, value: 200 },
+ })
+ const pointerDown = () => {
+ const event = new MouseEvent('pointerdown', { bubbles: true, cancelable: true })
+ Object.defineProperty(event, 'pointerType', { value: 'touch' })
+ trigger.dispatchEvent(event)
+ }
+
+ act(pointerDown)
+ expect(document.querySelector('[data-code-hover-card]')).not.toBeNull()
+ act(pointerDown)
+ expect(document.querySelector('[data-code-hover-card]')).toBeNull()
+ })
+})
diff --git a/packages/workflow-renderer/src/lib/overflow-span.tsx b/packages/workflow-renderer/src/lib/overflow-span.tsx
index b86caeff67d..9ddfdc11d0e 100644
--- a/packages/workflow-renderer/src/lib/overflow-span.tsx
+++ b/packages/workflow-renderer/src/lib/overflow-span.tsx
@@ -1,9 +1,13 @@
import type { ReactNode } from 'react'
import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn'
+import type { CodePreview } from '../types'
+import { CodeHoverCard } from './code-hover-card'
interface OverflowSpanProps {
value: string
className: string
+ /** Rich content shown instead of the plain value when this is clipped code. */
+ codePreview?: CodePreview
/**
* Decorated rendering of `value` — the same characters, wrapped. Used to mark
* a search hit inside a name without letting the decoration reach the
@@ -19,7 +23,24 @@ interface OverflowSpanProps {
* attribute here: on the canvas it pops the browser's raw, unstyled tooltip
* with the full untruncated value (including raw code/JSON) over the graph.
*/
-export function OverflowSpan({ value, className, children }: OverflowSpanProps) {
+export function OverflowSpan({ value, className, codePreview, children }: OverflowSpanProps) {
+ if (codePreview) {
+ return (
+
+ {children ?? value}
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+/** Plain clipped text keeps the platform tooltip behavior unchanged. */
+function TextOverflowSpan({ value, className, children }: Omit) {
const { state, handlers } = useFloatingTooltip(isTextClipped)
return (
diff --git a/packages/workflow-renderer/src/types.ts b/packages/workflow-renderer/src/types.ts
index ec7615fab41..5cd52a10729 100644
--- a/packages/workflow-renderer/src/types.ts
+++ b/packages/workflow-renderer/src/types.ts
@@ -17,3 +17,12 @@ export type DiffStatus = 'new' | 'edited' | undefined
/** Execution outcome of a block on its run path. */
export type BlockRunStatus = 'success' | 'error' | undefined
+
+/** Syntax languages supported by canvas code previews. */
+export type CodePreviewLanguage = 'javascript' | 'json' | 'python' | 'bash'
+
+/** Rich preview payload for a code value in the pure workflow renderer. */
+export interface CodePreview {
+ code: string
+ language: CodePreviewLanguage
+}
diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
index c74d21bac3c..2f7eec00e64 100644
--- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
+++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
@@ -1,6 +1,7 @@
import type { ComponentType } from 'react'
import { cn } from '@sim/emcn'
import { OverflowSpan } from '../lib/overflow-span'
+import type { CodePreview } from '../types'
import { InlineChip } from './inline-chip'
/**
@@ -16,6 +17,8 @@ export interface SubBlockRowViewProps {
displayValue?: string
/** Render the value in a monospace font (e.g. filter expressions). */
isMonospace?: boolean
+ /** Rich preview for an inline code value; ordinary values keep the text tooltip. */
+ codePreview?: CodePreview
/**
* Leading icon for the `meta` variant; without one the variant falls back
* to the labeled `row` presentation.
@@ -46,6 +49,7 @@ export function SubBlockRowView({
title,
displayValue,
isMonospace,
+ codePreview,
icon: Icon,
variant = 'row',
}: SubBlockRowViewProps) {
@@ -55,6 +59,7 @@ export function SubBlockRowView({
)