From 7108d6e12042cc74e7d7ca9423f8bd9dc3f4fc55 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 11:47:37 -0700 Subject: [PATCH 1/4] refactor(emcn): own shared shimmer text treatment --- .../agent-group/tool-call-item.test.tsx | 6 +--- .../agent-stream/agent-stream-chrome.test.tsx | 11 +++---- .../agent-stream/agent-stream-chrome.tsx | 3 +- apps/sim/components/ui/activity-status.tsx | 3 +- apps/sim/components/ui/index.ts | 1 - apps/sim/components/ui/shimmer-text.tsx | 29 ------------------ .../components/ui/thinking-loader.module.css | 7 ++--- apps/sim/components/ui/thinking-loader.tsx | 14 +++++++-- packages/emcn/src/components/index.ts | 1 + .../shimmer-text}/shimmer-text.module.css | 2 +- .../shimmer-text/shimmer-text.test.tsx | 17 +++++++++++ .../components/shimmer-text/shimmer-text.tsx | 30 +++++++++++++++++++ 12 files changed, 69 insertions(+), 55 deletions(-) delete mode 100644 apps/sim/components/ui/shimmer-text.tsx rename {apps/sim/components/ui => packages/emcn/src/components/shimmer-text}/shimmer-text.module.css (94%) create mode 100644 packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx create mode 100644 packages/emcn/src/components/shimmer-text/shimmer-text.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx index 4bc151bce2a..21874c6c0aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act, type ReactNode, type SVGProps } from 'react' +import { act, type SVGProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,10 +11,6 @@ import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay' import { getBlock, getBlockByToolName } from '@/blocks/registry' -vi.mock('@/components/ui', () => ({ - ShimmerText: ({ children }: { children: ReactNode }) => {children}, -})) - describe('ToolCallItem', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx index 79aedd736c4..9e989c6b2cf 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -7,13 +7,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/emcn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), -})) - -vi.mock('@/lib/mothership/tools/tool-display', () => ({ - humanizeToolName: (name: string) => name, -})) - -vi.mock('@/components/ui', () => ({ ShimmerText: ({ as: Comp = 'span', children, @@ -34,6 +27,10 @@ vi.mock('@/components/ui', () => ({ }, })) +vi.mock('@/lib/mothership/tools/tool-display', () => ({ + humanizeToolName: (name: string) => name, +})) + import { AgentStreamThinkingChrome, AgentStreamToolCallsChrome, diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx index 3e264f716ef..b7678d832bf 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -1,13 +1,12 @@ 'use client' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { cn } from '@sim/emcn' +import { cn, ShimmerText } from '@sim/emcn' import { Check, ChevronDown, Circle, Square, X } from '@sim/emcn/icons' import type { AgentStreamToolCall, AgentStreamToolStatus, } from '@/components/agent-stream/tool-call-lifecycle' -import { ShimmerText } from '@/components/ui' import { humanizeToolName } from '@/lib/mothership/tools/tool-display' /** Distance from bottom (px) within which we keep following new thinking text. */ diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx index 5c45b2b1b14..1e99f65b0a7 100644 --- a/apps/sim/components/ui/activity-status.tsx +++ b/apps/sim/components/ui/activity-status.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' -import { cn, OverflowText } from '@sim/emcn' -import { ShimmerText } from '@/components/ui/shimmer-text' +import { cn, OverflowText, ShimmerText } from '@sim/emcn' /** The icon column every activity row shares, sized for the default 14px icon. */ export const ACTIVITY_ICON_SLOT_CLASS = 'flex size-[14px] shrink-0 items-center justify-center' diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts index f343ed3b309..55e7b018e90 100644 --- a/apps/sim/components/ui/index.ts +++ b/apps/sim/components/ui/index.ts @@ -13,5 +13,4 @@ export { SelectTrigger, SelectValue, } from './select' -export { ShimmerText } from './shimmer-text' export { ThinkingLoader, type ThinkingLoaderVariant } from './thinking-loader' diff --git a/apps/sim/components/ui/shimmer-text.tsx b/apps/sim/components/ui/shimmer-text.tsx deleted file mode 100644 index 56f2b37662c..00000000000 --- a/apps/sim/components/ui/shimmer-text.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ComponentPropsWithoutRef, ElementType } from 'react' -import { cn } from '@sim/emcn' -import styles from '@/components/ui/shimmer-text.module.css' - -type ShimmerTextProps = { - as?: T - children: React.ReactNode - className?: string -} & Omit, 'as' | 'children' | 'className'> - -/** - * Sweeping-highlight shimmer over a text phrase — the same treatment as the - * ThinkingLoader's "Thinking…" label, reusable on any active/streaming row. - * Size and weight come from the consumer's className; the gradient replaces - * the text color, so color classes are ignored while shimmering. - */ -export function ShimmerText({ - as, - children, - className, - ...props -}: ShimmerTextProps) { - const Comp = as ?? 'span' - return ( - - {children} - - ) -} diff --git a/apps/sim/components/ui/thinking-loader.module.css b/apps/sim/components/ui/thinking-loader.module.css index 52703157b5e..c63b730fa36 100644 --- a/apps/sim/components/ui/thinking-loader.module.css +++ b/apps/sim/components/ui/thinking-loader.module.css @@ -79,12 +79,9 @@ white-space: nowrap; } -/* The sweeping-band treatment itself (gradient, timing, dark mode, reduced - motion) is owned by the shared shimmer-text module; this class only adds the - loader-scaled font sizing. Canonical normal weight per emcn rules: body text - is 400, never medium. */ +/* ShimmerText owns the gradient, timing, dark mode, and reduced motion. This + class only adds loader-scaled font sizing and canonical normal weight. */ .label { - composes: shimmer from "./shimmer-text.module.css"; font-size: var(--tl-label-size, 14px); font-weight: 400; } diff --git a/apps/sim/components/ui/thinking-loader.tsx b/apps/sim/components/ui/thinking-loader.tsx index 2aafa83915d..8a88b175963 100644 --- a/apps/sim/components/ui/thinking-loader.tsx +++ b/apps/sim/components/ui/thinking-loader.tsx @@ -1,7 +1,7 @@ 'use client' import { type CSSProperties, type ReactNode, useEffect, useId, useState } from 'react' -import { cn } from '@sim/emcn' +import { cn, ShimmerText } from '@sim/emcn' import styles from '@/components/ui/thinking-loader.module.css' const VARIANTS = [ @@ -532,11 +532,19 @@ export function ThinkingLoader({ {exitingLabel ? ( - {exitingLabel} + {shimmer ? ( + {exitingLabel} + ) : ( + {exitingLabel} + )} ) : null} - {shownLabel} + {shimmer ? ( + {shownLabel} + ) : ( + {shownLabel} + )} diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 8a6c0391020..99a057b0922 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -215,6 +215,7 @@ export { POPOVER_ANIMATION_CLASSES } from './popover/popover-animation' export { ProgressItem } from './progress-item/progress-item' export { SecretInput } from './secret-input/secret-input' export { SecretReveal } from './secret-reveal/secret-reveal' +export { ShimmerText, type ShimmerTextProps } from './shimmer-text/shimmer-text' export { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from './sim-wordmark/paths' export { SimWordmark, type SimWordmarkProps } from './sim-wordmark/sim-wordmark' export { Skeleton } from './skeleton/skeleton' diff --git a/apps/sim/components/ui/shimmer-text.module.css b/packages/emcn/src/components/shimmer-text/shimmer-text.module.css similarity index 94% rename from apps/sim/components/ui/shimmer-text.module.css rename to packages/emcn/src/components/shimmer-text/shimmer-text.module.css index fffd7322138..45ab55fbcb7 100644 --- a/apps/sim/components/ui/shimmer-text.module.css +++ b/packages/emcn/src/components/shimmer-text/shimmer-text.module.css @@ -1,7 +1,7 @@ /** * Claude-style text shimmer: the text paints from a gradient with a light band * that sweeps across the glyphs via background-clip. This is the single source - * of truth for the treatment — the ThinkingLoader label composes it. Under + * of truth for the treatment, including the ThinkingLoader label. Under * reduced motion the sweep is replaced by a gentle opacity pulse in solid ink: * the shimmer conveys essential in-progress state, so it needs a vestibular-safe * fallback rather than none. Consumers whose resting text is not body ink set diff --git a/packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx b/packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx new file mode 100644 index 00000000000..1a6c4f722b4 --- /dev/null +++ b/packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx @@ -0,0 +1,17 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { ShimmerText } from './shimmer-text' + +describe('ShimmerText', () => { + it('preserves the requested element, consumer classes, and native attributes', () => { + const html = renderToStaticMarkup( + + Writing a reply + + ) + expect(html).toMatch(/^
') + }) +}) diff --git a/packages/emcn/src/components/shimmer-text/shimmer-text.tsx b/packages/emcn/src/components/shimmer-text/shimmer-text.tsx new file mode 100644 index 00000000000..37495f79ee7 --- /dev/null +++ b/packages/emcn/src/components/shimmer-text/shimmer-text.tsx @@ -0,0 +1,30 @@ +import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react' +import { cn } from '../../lib/cn' +import styles from './shimmer-text.module.css' + +export type ShimmerTextProps = { + as?: T + children: ReactNode + className?: string +} & Omit, 'as' | 'children' | 'className'> + +/** + * Sweeping-highlight shimmer for active text. Size and weight come from the + * consumer; `--shimmer-rest` supplies resting ink under reduced motion. + * + * @example + * Working… + */ +export function ShimmerText({ + as, + children, + className, + ...props +}: ShimmerTextProps) { + const Comp = as ?? 'span' + return ( + + {children} + + ) +} From 8d9f48c84eaf9e05eff24c3657c03923a3c6f21b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 12:18:19 -0700 Subject: [PATCH 2/4] test(emcn): assert shimmer class wiring --- packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx b/packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx index 1a6c4f722b4..6a673535253 100644 --- a/packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx +++ b/packages/emcn/src/components/shimmer-text/shimmer-text.test.tsx @@ -1,6 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { ShimmerText } from './shimmer-text' +import styles from './shimmer-text.module.css' describe('ShimmerText', () => { it('preserves the requested element, consumer classes, and native attributes', () => { @@ -10,6 +11,7 @@ describe('ShimmerText', () => { ) expect(html).toMatch(/^
') From 097555398c01d3663c116034c9fed62e468a0317 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 12:24:30 -0700 Subject: [PATCH 3/4] test(chat): preserve EMCN exports in message mock --- .../app/(interfaces)/chat/components/message/message.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index 1726c423397..b6efe4b357c 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -5,7 +5,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/emcn', () => ({ +vi.mock('@sim/emcn', async (importOriginal) => ({ + ...(await importOriginal()), Button: ({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) => ( ), + Check: () => null, Duplicate: () => null, + ShimmerText: ({ children }: { children?: React.ReactNode }) => {children}, Tooltip: { Provider: ({ children }: { children?: React.ReactNode }) => <>{children}, Root: ({ children }: { children?: React.ReactNode }) => <>{children}, @@ -20,6 +21,7 @@ vi.mock('@sim/emcn', async (importOriginal) => ({ Content: ({ children }: { children?: React.ReactNode }) => <>{children}, }, cn: (...args: unknown[]) => args.filter(Boolean).join(' '), + handleKeyboardActivation: vi.fn(), })) vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', () => ({