From 10029066bc392f7da544989e84c77b2588c4f699 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 13 Feb 2026 11:38:13 -0500 Subject: [PATCH 1/2] init --- packages/ui/src/Components.tsx | 7 + .../devPrompts/Inspector/InspectorOverlay.tsx | 349 ++++++++++++++++++ .../components/devPrompts/Inspector/index.tsx | 139 +++++++ .../devPrompts/Inspector/parseClerkElement.ts | 88 +++++ .../devPrompts/Inspector/useInspectorState.ts | 133 +++++++ packages/ui/src/lazyModules/components.ts | 3 + 6 files changed, 719 insertions(+) create mode 100644 packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx create mode 100644 packages/ui/src/components/devPrompts/Inspector/index.tsx create mode 100644 packages/ui/src/components/devPrompts/Inspector/parseClerkElement.ts create mode 100644 packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index 647cd63eb78..9dbe56ebaac 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -30,6 +30,7 @@ import { CreateOrganizationModal, EnableOrganizationsPrompt, ImpersonationFab, + Inspector, KeylessPrompt, OrganizationProfileModal, preloadComponent, @@ -714,6 +715,12 @@ const Components = (props: ComponentsProps) => { )} + {__DEV__ && ( + + + + )} + {state.organizationSwitcherPrefetch && } diff --git a/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx b/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx new file mode 100644 index 00000000000..a43bc44f3b2 --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx @@ -0,0 +1,349 @@ +// eslint-disable-next-line no-restricted-imports +import { css } from '@emotion/react'; +import { flip, offset, shift, useFloating } from '@floating-ui/react'; +import copy from 'copy-to-clipboard'; +import { useEffect, useMemo, useState } from 'react'; + +import type { InspectedData } from './parseClerkElement'; + +const CSS_RESET = css` + margin: 0; + padding: 0; + box-sizing: border-box; + background: none; + border: none; + font-family: + -apple-system, + BlinkMacSystemFont, + avenir next, + avenir, + segoe ui, + helvetica neue, + helvetica, + Cantarell, + Ubuntu, + roboto, + noto, + arial, + sans-serif; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + text-decoration: none; + color: inherit; + appearance: none; +`; + +interface InspectorOverlayProps { + inspectedData: InspectedData; + isFrozen: boolean; + copiedValue: string | null; + onCopy: (value: string) => void; + onClose: () => void; +} + +function CopyIcon() { + return ( + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +function CopyRow({ value, copiedValue, onCopy }: { value: string; copiedValue: string | null; onCopy: () => void }) { + const isCopied = copiedValue === value; + return ( + + ); +} + +export function InspectorOverlay({ inspectedData, isFrozen, copiedValue, onCopy, onClose }: InspectorOverlayProps) { + const [rect, setRect] = useState(null); + + useEffect(() => { + const el = inspectedData.element; + setRect(el.getBoundingClientRect()); + + if (!isFrozen) { + let raf: number; + const update = () => { + setRect(el.getBoundingClientRect()); + raf = requestAnimationFrame(update); + }; + raf = requestAnimationFrame(update); + return () => cancelAnimationFrame(raf); + } + }, [inspectedData.element, isFrozen]); + + // Virtual reference element for floating-ui + const virtualRef = useMemo(() => { + if (!rect) { + return null; + } + return { + getBoundingClientRect: () => rect, + }; + }, [rect]); + + const { floatingStyles, refs } = useFloating({ + placement: 'bottom-start', + middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })], + elements: { + reference: virtualRef, + }, + }); + + if (!rect) { + return null; + } + + return ( + <> + {/* Highlight box */} +
+ + {/* Tooltip */} +
+ {/* Close button — only interactive when frozen */} + {isFrozen && ( + + )} + + {/* Classes section */} +
+
+ Classes +
+ {inspectedData.publicClasses.map(cls => ( + onCopy(cls)} + /> + ))} + {inspectedData.stateClasses.map(cls => ( + onCopy(cls)} + /> + ))} +
+ + {/* Localization key section */} + {inspectedData.localizationKey && ( +
+
+ Localization Key +
+ onCopy(inspectedData.localizationKey!)} + /> +
+ )} + + {/* Hint when not frozen */} + {!isFrozen && ( +
+ Click to pin +
+ )} +
+ + ); +} diff --git a/packages/ui/src/components/devPrompts/Inspector/index.tsx b/packages/ui/src/components/devPrompts/Inspector/index.tsx new file mode 100644 index 00000000000..0d516799e41 --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/index.tsx @@ -0,0 +1,139 @@ +// eslint-disable-next-line no-restricted-imports +import { css } from '@emotion/react'; +import { createPortal } from 'react-dom'; + +import { InternalThemeProvider } from '../../../styledSystem'; +import { InspectorOverlay } from './InspectorOverlay'; +import { useInspectorState } from './useInspectorState'; + +const CSS_RESET = css` + margin: 0; + padding: 0; + box-sizing: border-box; + background: none; + border: none; + font-family: + -apple-system, + BlinkMacSystemFont, + avenir next, + avenir, + segoe ui, + helvetica neue, + helvetica, + Cantarell, + Ubuntu, + roboto, + noto, + arial, + sans-serif; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + text-decoration: none; + color: inherit; + appearance: none; +`; + +function InspectorIcon() { + return ( + + + + + ); +} + +function InspectorInternal() { + const { isActive, inspectedData, isFrozen, copiedValue, toggle, unfreeze, setCopiedValue } = useInspectorState(); + + return ( + <> + {/* Toggle button */} + + + {/* Overlay portal */} + {isActive && + inspectedData && + createPortal( + , + document.body, + )} + + ); +} + +export function Inspector() { + return ( + + + + ); +} diff --git a/packages/ui/src/components/devPrompts/Inspector/parseClerkElement.ts b/packages/ui/src/components/devPrompts/Inspector/parseClerkElement.ts new file mode 100644 index 00000000000..394d9090505 --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/parseClerkElement.ts @@ -0,0 +1,88 @@ +const CLASS_PREFIX = 'cl-'; +const EMOJI_SEPARATOR = '\u{1F512}'; +const INTERNAL_CLASS_PREFIX = 'cl-internal-'; + +export interface InspectedData { + publicClasses: string[]; + stateClasses: string[]; + localizationKey: string | null; + element: HTMLElement; +} + +/** + * Parses a DOM element to extract Clerk public classnames and localization keys. + * Returns null if the element is not a Clerk-customizable element. + */ +export function parseClerkElement(target: HTMLElement): InspectedData | null { + const element = findClerkElement(target); + if (!element) { + return null; + } + + const className = element.className; + // Split on the emoji separator — everything before is public + const publicPart = className.includes(EMOJI_SEPARATOR) ? className.split(EMOJI_SEPARATOR)[0] : className; + + const tokens = publicPart.split(/\s+/).filter(Boolean); + const publicClasses: string[] = []; + const stateClasses: string[] = []; + + const stateNames = new Set(['cl-loading', 'cl-error', 'cl-open', 'cl-active', 'cl-required']); + + for (const token of tokens) { + if (!token.startsWith(CLASS_PREFIX) || token.startsWith(INTERNAL_CLASS_PREFIX)) { + continue; + } + if (stateNames.has(token)) { + stateClasses.push(token); + } else { + publicClasses.push(token); + } + } + + if (publicClasses.length === 0) { + return null; + } + + const localizationKey = findLocalizationKey(element); + + return { + publicClasses, + stateClasses, + localizationKey, + element, + }; +} + +/** + * Walks up from the target to find the nearest element whose className contains `cl-`. + */ +function findClerkElement(target: HTMLElement): HTMLElement | null { + let el: HTMLElement | null = target; + while (el) { + if (typeof el.className === 'string' && el.className.includes(CLASS_PREFIX)) { + return el; + } + el = el.parentElement; + } + return null; +} + +/** + * Checks the element and its children for a `data-localization-key` attribute. + */ +function findLocalizationKey(element: HTMLElement): string | null { + // Check the element itself first + const key = element.getAttribute('data-localization-key'); + if (key) { + return key; + } + + // Check direct children + const child = element.querySelector('[data-localization-key]'); + if (child) { + return child.getAttribute('data-localization-key'); + } + + return null; +} diff --git a/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts b/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts new file mode 100644 index 00000000000..879f101f2b4 --- /dev/null +++ b/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { InspectedData } from './parseClerkElement'; +import { parseClerkElement } from './parseClerkElement'; + +interface InspectorState { + isActive: boolean; + inspectedData: InspectedData | null; + isFrozen: boolean; + copiedValue: string | null; +} + +export function useInspectorState() { + const [state, setState] = useState({ + isActive: false, + inspectedData: null, + isFrozen: false, + copiedValue: null, + }); + + const copiedTimerRef = useRef(null); + + const toggle = useCallback(() => { + setState(prev => ({ + ...prev, + isActive: !prev.isActive, + inspectedData: null, + isFrozen: false, + copiedValue: null, + })); + }, []); + + const deactivate = useCallback(() => { + setState(prev => ({ + ...prev, + isActive: false, + inspectedData: null, + isFrozen: false, + copiedValue: null, + })); + }, []); + + const unfreeze = useCallback(() => { + setState(prev => ({ ...prev, isFrozen: false, copiedValue: null })); + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (state.isFrozen) { + return; + } + const target = e.target as HTMLElement; + if (!target) { + return; + } + const data = parseClerkElement(target); + setState(prev => ({ ...prev, inspectedData: data })); + }, + [state.isFrozen], + ); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (state.isFrozen || !state.inspectedData) { + return; + } + + // Freeze the tooltip so user can interact with copy buttons + e.preventDefault(); + e.stopPropagation(); + setState(prev => ({ ...prev, isFrozen: true })); + }, + [state.inspectedData, state.isFrozen], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (state.isFrozen) { + unfreeze(); + } else { + deactivate(); + } + } + }, + [state.isFrozen, deactivate, unfreeze], + ); + + const setCopiedValue = useCallback((value: string) => { + if (copiedTimerRef.current) { + window.clearTimeout(copiedTimerRef.current); + } + setState(prev => ({ ...prev, copiedValue: value })); + copiedTimerRef.current = window.setTimeout(() => { + setState(prev => ({ ...prev, copiedValue: null })); + }, 1500); + }, []); + + useEffect(() => { + if (!state.isActive) { + return; + } + + document.addEventListener('mousemove', handleMouseMove, true); + document.addEventListener('click', handleClick, true); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousemove', handleMouseMove, true); + document.removeEventListener('click', handleClick, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [state.isActive, handleMouseMove, handleClick, handleKeyDown]); + + useEffect(() => { + return () => { + if (copiedTimerRef.current) { + window.clearTimeout(copiedTimerRef.current); + } + }; + }, []); + + return { + isActive: state.isActive, + inspectedData: state.inspectedData, + isFrozen: state.isFrozen, + copiedValue: state.copiedValue, + toggle, + deactivate, + unfreeze, + setCopiedValue, + }; +} diff --git a/packages/ui/src/lazyModules/components.ts b/packages/ui/src/lazyModules/components.ts index 0cc57f424ca..7d6be4d6102 100644 --- a/packages/ui/src/lazyModules/components.ts +++ b/packages/ui/src/lazyModules/components.ts @@ -32,6 +32,7 @@ const componentImportPaths = { OAuthConsent: () => import(/* webpackChunkName: "oauthConsent" */ '../components/OAuthConsent/OAuthConsent'), EnableOrganizationsPrompt: () => import(/* webpackChunkName: "enableOrganizationsPrompt" */ '../components/devPrompts/EnableOrganizationsPrompt'), + Inspector: () => import(/* webpackChunkName: "inspector" */ '../components/devPrompts/Inspector'), } as const; export const SignIn = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignIn }))); @@ -150,6 +151,8 @@ export const SessionTasks = lazy(() => componentImportPaths.SessionTasks().then(module => ({ default: module.SessionTasks })), ); +export const Inspector = lazy(() => componentImportPaths.Inspector().then(module => ({ default: module.Inspector }))); + export const preloadComponent = async (component: unknown) => { return componentImportPaths[component as keyof typeof componentImportPaths]?.(); }; From 5a6d0dee45f06c9a382b1a84ab4dcf5d107dec82 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 13 Feb 2026 13:18:23 -0500 Subject: [PATCH 2/2] close on click outside --- .../devPrompts/Inspector/InspectorOverlay.tsx | 22 ++++++++++++++++--- .../components/devPrompts/Inspector/index.tsx | 4 +++- .../devPrompts/Inspector/useInspectorState.ts | 20 +++++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx b/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx index a43bc44f3b2..49227a2bc0c 100644 --- a/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx +++ b/packages/ui/src/components/devPrompts/Inspector/InspectorOverlay.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/react'; import { flip, offset, shift, useFloating } from '@floating-ui/react'; import copy from 'copy-to-clipboard'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { InspectedData } from './parseClerkElement'; @@ -40,6 +40,7 @@ interface InspectorOverlayProps { copiedValue: string | null; onCopy: (value: string) => void; onClose: () => void; + tooltipRef: (el: HTMLElement | null) => void; } function CopyIcon() { @@ -148,7 +149,14 @@ function CopyRow({ value, copiedValue, onCopy }: { value: string; copiedValue: s ); } -export function InspectorOverlay({ inspectedData, isFrozen, copiedValue, onCopy, onClose }: InspectorOverlayProps) { +export function InspectorOverlay({ + inspectedData, + isFrozen, + copiedValue, + onCopy, + onClose, + tooltipRef, +}: InspectorOverlayProps) { const [rect, setRect] = useState(null); useEffect(() => { @@ -184,6 +192,14 @@ export function InspectorOverlay({ inspectedData, isFrozen, copiedValue, onCopy, }, }); + const mergedTooltipRef = useCallback( + (node: HTMLDivElement | null) => { + refs.setFloating(node); + tooltipRef(node); + }, + [refs.setFloating, tooltipRef], + ); + if (!rect) { return null; } @@ -212,7 +228,7 @@ export function InspectorOverlay({ inspectedData, isFrozen, copiedValue, onCopy, {/* Tooltip */}
@@ -123,6 +124,7 @@ function InspectorInternal() { copiedValue={copiedValue} onCopy={setCopiedValue} onClose={unfreeze} + tooltipRef={setTooltipRef} />, document.body, )} diff --git a/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts b/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts index 879f101f2b4..12c023fe179 100644 --- a/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts +++ b/packages/ui/src/components/devPrompts/Inspector/useInspectorState.ts @@ -19,6 +19,7 @@ export function useInspectorState() { }); const copiedTimerRef = useRef(null); + const tooltipRef = useRef(null); const toggle = useCallback(() => { setState(prev => ({ @@ -61,7 +62,17 @@ export function useInspectorState() { const handleClick = useCallback( (e: MouseEvent) => { - if (state.isFrozen || !state.inspectedData) { + if (state.isFrozen) { + // If clicking inside the tooltip, let it through (for copy buttons) + if (tooltipRef.current?.contains(e.target as Node)) { + return; + } + // Clicking outside the tooltip unfreezes + unfreeze(); + return; + } + + if (!state.inspectedData) { return; } @@ -70,7 +81,7 @@ export function useInspectorState() { e.stopPropagation(); setState(prev => ({ ...prev, isFrozen: true })); }, - [state.inspectedData, state.isFrozen], + [state.inspectedData, state.isFrozen, unfreeze], ); const handleKeyDown = useCallback( @@ -120,6 +131,10 @@ export function useInspectorState() { }; }, []); + const setTooltipRef = useCallback((el: HTMLElement | null) => { + tooltipRef.current = el; + }, []); + return { isActive: state.isActive, inspectedData: state.inspectedData, @@ -129,5 +144,6 @@ export function useInspectorState() { deactivate, unfreeze, setCopiedValue, + setTooltipRef, }; }