From c20f4c2504b180ff90e060fd0d7df15328bf471f Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 29 May 2026 17:09:06 -0700 Subject: [PATCH] feat(analytics): enhance clickmap functionality and UI components - Introduced auto-copy feature for clickmap token snippets, improving user experience. - Updated CopyField and CopyButton components to support initial copied state. - Refactored PageClient to manage custom origin state and improve clickmap token handling. - Added viewport warning styles and functionality to enhance user guidance in the dev tool. - Cleaned up unused code and improved overall component structure for better maintainability. --- .../analytics/clickmaps/page-client.tsx | 29 +- .../src/components/ui/copy-button.tsx | 30 +- .../src/components/ui/copy-field.tsx | 5 +- .../template/src/dev-tool/dev-tool-core.ts | 403 ++++++++---------- .../template/src/dev-tool/dev-tool-styles.ts | 96 ++++- 5 files changed, 319 insertions(+), 244 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx index d44f729c9..cd4d3c82d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx @@ -21,7 +21,6 @@ import { SelectTrigger, SelectValue, Spinner, - toast, Typography, } from "@/components/ui"; import { DesignAnalyticsCard } from "@/components/design-components/analytics-card"; @@ -317,6 +316,7 @@ function installClickmapTokenForCurrentOrigin(token: AnalyticsClickmapTokenRespo function ClickmapTokenDialog(props: { origin: ClickmapOrigin | null, token: AnalyticsClickmapTokenResponse | null, + autoCopied?: boolean, open: boolean, onOpenChange: (open: boolean) => void, }) { @@ -336,7 +336,7 @@ function ClickmapTokenDialog(props: { Creating clickmap token... ) : ( <> - + The site will use normal client authentication plus this origin-bound clickmap token to fetch aggregate clickmap data. @@ -370,7 +370,12 @@ export default function PageClient() { const [dialogOpen, setDialogOpen] = useState(false); const [selectedOrigin, setSelectedOrigin] = useState(null); const [token, setToken] = useState(null); - const [customOrigin, setCustomOrigin] = useState("http://localhost:8101"); + const [autoCopied, setAutoCopied] = useState(false); + const [customOrigin, setCustomOrigin] = useState(""); + + useEffect(() => { + setCustomOrigin(window.location.origin); + }, []); const origins = useMemo(() => { const byOrigin = new Map(); @@ -403,10 +408,11 @@ export default function PageClient() { throw error; } setToken(created); - const installedInCurrentTab = installClickmapTokenForCurrentOrigin(created); + installClickmapTokenForCurrentOrigin(created); + setAutoCopied(false); try { await navigator.clipboard.writeText(createConsoleSnippet(created.token)); - toast({ title: installedInCurrentTab ? "Clickmap toolbar enabled" : "Snippet copied to clipboard" }); + setAutoCopied(true); } catch { // Clipboard access can be denied (e.g. lost user-gesture after the // network round-trip); the snippet stays available to copy manually. @@ -430,7 +436,17 @@ export default function PageClient() { setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" /> - @@ -470,6 +486,7 @@ export default function PageClient() { diff --git a/apps/dashboard/src/components/ui/copy-button.tsx b/apps/dashboard/src/components/ui/copy-button.tsx index d82e0ce69..bd1497b3b 100644 --- a/apps/dashboard/src/components/ui/copy-button.tsx +++ b/apps/dashboard/src/components/ui/copy-button.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; -import { CopyIcon, SparkleIcon } from "@phosphor-icons/react"; +import { CheckIcon, CopyIcon, SparkleIcon } from "@phosphor-icons/react"; import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react"; import React from "react"; import { Button } from "./button"; @@ -9,9 +9,27 @@ import { useToast } from "./use-toast"; const CopyButton = forwardRefIfNeeded< React.ElementRef, - React.ComponentProps & { content: string } ->((props, ref) => { + React.ComponentProps & { content: string, initialCopied?: boolean } +>(({ content, initialCopied, ...props }, ref) => { const { toast } = useToast(); + const [copied, setCopied] = React.useState(false); + const resetTimeout = React.useRef | null>(null); + + const showCopied = React.useCallback(() => { + setCopied(true); + if (resetTimeout.current != null) clearTimeout(resetTimeout.current); + resetTimeout.current = setTimeout(() => setCopied(false), 2000); + }, []); + + React.useEffect(() => () => { + if (resetTimeout.current != null) clearTimeout(resetTimeout.current); + }, []); + + // Reflect a copy that already happened elsewhere (e.g. the snippet was + // auto-copied to the clipboard when this field was rendered). + React.useEffect(() => { + if (initialCopied) showCopied(); + }, [initialCopied, showCopied]); return ( ); }); diff --git a/apps/dashboard/src/components/ui/copy-field.tsx b/apps/dashboard/src/components/ui/copy-field.tsx index b9ad10fde..a8945be55 100644 --- a/apps/dashboard/src/components/ui/copy-field.tsx +++ b/apps/dashboard/src/components/ui/copy-field.tsx @@ -10,6 +10,7 @@ export function CopyField(props: { helper?: React.ReactNode, monospace?: boolean, fixedSize?: boolean, + initialCopied?: boolean, } & ({ type: "textarea", height?: number, @@ -36,7 +37,7 @@ export function CopyField(props: { resize: props.fixedSize ? "none" : "vertical" }} /> - + ) : (
@@ -47,7 +48,7 @@ export function CopyField(props: { fontFamily: props.monospace ? "ui-monospace, monospace" : "inherit", }} /> - +
)} diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index b27eb9d79..6641a2c15 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -27,7 +27,7 @@ import { clampTriggerPosition, getSnappedTriggerPlacement, resolveTriggerPositio // Types // --------------------------------------------------------------------------- -type TabId = 'overview' | 'clickmaps' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; +type TabId = 'overview' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; type TabResult = { element: HTMLElement, cleanup?: () => void }; @@ -76,9 +76,13 @@ const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'console', label: 'Console', icon: '' }, { id: 'dashboard', label: 'Dashboard', icon: '' }, { id: 'support', label: 'Support', icon: '' }, - { id: 'clickmaps', label: 'Clickmaps', icon: '' }, ]; +// Clickmaps is intentionally NOT a dev tool tab. It's a fully independent +// feature with its own mount (see createDevTool), opened via a dashboard-minted +// token (the CLICKMAP_OVERLAY_TOKEN_UPDATED event / resume flow), never from the +// dev tool. The two coexist without affecting each other. + const DEFAULT_STATE: DevToolState = { isOpen: false, activeTab: 'overview', @@ -1811,21 +1815,23 @@ const CLICKMAP_FILTERS_STORAGE_KEY = 'hexclave-clickmap-overlay-filters'; type ClickmapRangeKey = '24h' | '7d' | '30d'; type ClickmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv'; -type ClickmapUrlPatternMode = 'glob' | 'regex'; type ClickmapFilters = { range: ClickmapRangeKey, device: ClickmapDeviceKey, urlPattern: string, - urlPatternMode: ClickmapUrlPatternMode, elementSearch: string, }; +type ClickmapViewportBucket = { + min: number, + max: number | null, +}; + const CLICKMAP_DEFAULT_FILTERS: ClickmapFilters = { range: '7d', device: 'all', urlPattern: '', - urlPatternMode: 'glob', elementSearch: '', }; @@ -1835,15 +1841,40 @@ const CLICKMAP_RANGE_MS: Record = { '30d': 30 * 24 * 60 * 60 * 1000, }; +const CLICKMAP_VIEWPORT_BUCKETS: Record, ClickmapViewportBucket> = { + mobile: { min: 0, max: 767 }, + tablet: { min: 768, max: 1023 }, + laptop: { min: 1024, max: 1199 }, + desktop: { min: 1200, max: 1439 }, + widescreen: { min: 1440, max: 1919 }, + tv: { min: 1920, max: null }, +}; + +function getClickmapViewportBucket(device: ClickmapDeviceKey): ClickmapViewportBucket | null { + if (device === 'all') return null; + return CLICKMAP_VIEWPORT_BUCKETS[device]; +} + +function isClickmapViewportWidthInBucket(width: number, bucket: ClickmapViewportBucket): boolean { + return width >= bucket.min && (bucket.max == null || width <= bucket.max); +} + +function getClickmapRecommendedViewportWidth(bucket: ClickmapViewportBucket): number { + if (bucket.max == null) return bucket.min; + return Math.round((bucket.min + bucket.max) / 2); +} + +function formatClickmapViewportBucket(bucket: ClickmapViewportBucket): string { + if (bucket.max == null) return `${bucket.min}px+`; + return `${bucket.min}-${bucket.max}px`; +} + function isClickmapRangeKey(value: unknown): value is ClickmapRangeKey { return value === '24h' || value === '7d' || value === '30d'; } function isClickmapDeviceKey(value: unknown): value is ClickmapDeviceKey { return value === 'all' || value === 'mobile' || value === 'tablet' || value === 'laptop' || value === 'desktop' || value === 'widescreen' || value === 'tv'; } -function isClickmapUrlPatternMode(value: unknown): value is ClickmapUrlPatternMode { - return value === 'glob' || value === 'regex'; -} const CLICKMAP_DOM_INDEX_DEBOUNCE_MS = 250; type DevToolServerClickmapSelector = { @@ -1978,25 +2009,17 @@ function getJwtPayloadClaim(token: string, claim: string): string | null { } } -function getClickmapTokenFromStorage(projectId: string): string | null { - const token = getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); - if (token == null) { - return null; - } - // A token minted for a different project must not apply to this app. - const tokenProjectId = getJwtPayloadClaim(token, 'project_id'); - return tokenProjectId == null || tokenProjectId === projectId ? token : null; +function getClickmapTokenFromStorage(): string | null { + return getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); } -function getClickmapOriginFromStorage(projectId: string): string | null { - const token = getClickmapTokenFromStorage(projectId); +function getClickmapOriginFromStorage(): string | null { + const token = getClickmapTokenFromStorage(); return token == null ? null : getJwtPayloadClaim(token, 'origin'); } -function clearClickmapTokenStorage(projectId: string): void { - if (getClickmapTokenFromStorage(projectId) != null) { - removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); - } +function clearClickmapTokenStorage(): void { + removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); } function parseServerClickmapResponse(value: unknown, path: string): DevToolServerClickmap { @@ -2064,32 +2087,20 @@ function globToRegexSource(glob: string): string { .join('.*'); } -function isValidRegexSource(source: string): boolean { - try { - new RegExp(source); - return true; - } catch { - return false; - } -} - // Does `path` match the active URL pattern? Used to tell the user when the page // they're on isn't covered by the pattern, so the overlay can't be drawn here -// even though aggregate data exists. Glob mode mirrors the backend's anchored -// `LIKE`; regex mode mirrors ClickHouse `match()` (unanchored RE2). -function patternMatchesPath(pattern: string, path: string, mode: ClickmapUrlPatternMode): boolean { +// even though aggregate data exists. Glob matching mirrors the backend's +// anchored `LIKE`. +function patternMatchesPath(pattern: string, path: string): boolean { if (pattern === '') return true; try { - if (mode === 'regex') { - return new RegExp(pattern).test(path); - } return new RegExp(`^${globToRegexSource(pattern)}$`).test(path); } catch { return false; } } -function createClickmapsTab(app: StackClientApp, onBack: () => void): TabResult { +function createClickmapsTab(app: StackClientApp, onClose: () => void): TabResult { const container = h('div', { className: 'sdt-hm' }); const overlayRoot = h('div', { className: 'sdt-hm-overlay-root', 'aria-hidden': 'true' }); const statsCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); @@ -2098,9 +2109,31 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const list = h('div', { className: 'sdt-hm-list' }); const empty = h('div', { className: 'sdt-hm-empty' }, 'Paste a clickmap token from the dashboard to load aggregated element clicks for this page.'); const status = h('div', { className: 'sdt-hm-token-status' }); + const viewportWarningTitle = h('div', { className: 'sdt-hm-viewport-warning-title' }); + const viewportWarningBody = h('div', { className: 'sdt-hm-viewport-warning-body' }); + const viewportWarningWidthValue = h('code', { className: 'sdt-hm-viewport-warning-code' }); + const viewportWarningHeightValue = h('code', { className: 'sdt-hm-viewport-warning-code' }); + const viewportWarningWidthCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' }); + const viewportWarningHeightCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' }); + const viewportWarning = h('div', { className: 'sdt-hm-viewport-warning', role: 'status' }, + viewportWarningTitle, + viewportWarningBody, + h('div', { className: 'sdt-hm-viewport-warning-actions' }, + h('span', { className: 'sdt-hm-viewport-warning-action' }, + h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Width'), + viewportWarningWidthValue, + viewportWarningWidthCopy, + ), + h('span', { className: 'sdt-hm-viewport-warning-action' }, + h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Height'), + viewportWarningHeightValue, + viewportWarningHeightCopy, + ), + ), + ); const overlayToggle = h('button', { className: 'sdt-hm-btn sdt-hm-btn-primary' }, 'Hide'); const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand clickmap options', title: 'Expand clickmap options' }); - const backButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Back', title: 'Back' }); + const closeButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Close clickmap', title: 'Close clickmap' }); const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); const miniElements = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); @@ -2115,7 +2148,6 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR range: isClickmapRangeKey(obj.range) ? obj.range : CLICKMAP_DEFAULT_FILTERS.range, device: isClickmapDeviceKey(obj.device) ? obj.device : CLICKMAP_DEFAULT_FILTERS.device, urlPattern: typeof obj.urlPattern === 'string' ? obj.urlPattern : CLICKMAP_DEFAULT_FILTERS.urlPattern, - urlPatternMode: isClickmapUrlPatternMode(obj.urlPatternMode) ? obj.urlPatternMode : CLICKMAP_DEFAULT_FILTERS.urlPatternMode, elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : CLICKMAP_DEFAULT_FILTERS.elementSearch, }; } catch { @@ -2141,6 +2173,23 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR let overlayMode: 'hidden' | 'elements' = 'hidden'; const groupOverlayElements = new Map(); + function resetCopyButton(button: HTMLElement, label: string) { + button.textContent = label; + } + + function copyClickmapViewportValue(button: HTMLElement, value: string, label: string) { + runAsynchronously(async () => { + try { + await navigator.clipboard.writeText(value); + button.textContent = 'Copied'; + window.setTimeout(() => resetCopyButton(button, label), 1200); + } catch { + button.textContent = 'Copy failed'; + window.setTimeout(() => resetCopyButton(button, label), 1600); + } + }); + } + // DOM-index cache for fast element-chain inference. const domIndex = new Map(); let domIndexDirty = true; @@ -2306,12 +2355,20 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR return null; } - setHtml(backButton, ''); + setHtml(closeButton, ''); const chevronUpSvg = ''; const chevronDownSvg = ''; const clicksIconSvg = ''; const elementsIconSvg = ''; setHtml(expandButton, chevronUpSvg); + resetCopyButton(viewportWarningWidthCopy, 'Copy width'); + resetCopyButton(viewportWarningHeightCopy, 'Copy height'); + viewportWarningWidthCopy.addEventListener('click', () => { + copyClickmapViewportValue(viewportWarningWidthCopy, viewportWarningWidthValue.textContent, 'Copy width'); + }); + viewportWarningHeightCopy.addEventListener('click', () => { + copyClickmapViewportValue(viewportWarningHeightCopy, viewportWarningHeightValue.textContent, 'Copy height'); + }); const stats = h('div', { className: 'sdt-hm-stats' }, h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Clicks'), statsCount), @@ -2328,16 +2385,13 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR let urlPatternUserEdited = filters.urlPattern.trim() !== ''; function getEffectiveUrlPattern(): string { - // Auto route-tracking is a glob concept; in regex mode an empty field means - // "this exact page" (the route_path fallback), never an auto-wildcard. - if (filters.urlPatternMode === 'regex') return filters.urlPattern.trim(); if (urlPatternUserEdited) return filters.urlPattern.trim(); return wildcardizePathname(window.location.pathname); } - // Reflect the current route into the field while in glob auto mode. No-op in - // regex mode or once the user has typed their own pattern. + // Reflect the current route into the field while in auto mode. No-op once the + // user has typed their own pattern. function syncAutoUrlPattern() { - if (filters.urlPatternMode === 'regex' || urlPatternUserEdited) return; + if (urlPatternUserEdited) return; const auto = wildcardizePathname(window.location.pathname); if (urlPatternInput.value !== auto) { urlPatternInput.value = auto; @@ -2428,12 +2482,14 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR }) as HTMLInputElement; urlPatternInput.value = getEffectiveUrlPattern(); // Shown only while the active pattern doesn't cover the current page (see - // render); resets the field back to the auto-wildcarded current route. + // render); reverts the field back to the auto-wildcarded current route. const urlPatternReset = h('button', { className: 'sdt-hm-filter-reset', type: 'button', - title: 'Reset the URL pattern to the current page', - }, 'Reset') as HTMLButtonElement; + 'aria-label': 'Revert the URL pattern to the current page', + title: 'Revert the URL pattern to the current page', + }) as HTMLButtonElement; + setHtml(urlPatternReset, ''); // Info button + popover explaining the URL pattern syntax. The backend // translates `*` into a SQL LIKE `%`, so `*` is the only wildcard — every @@ -2459,37 +2515,14 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const urlHelpTitle = h('div', { className: 'sdt-hm-url-help-title' }); const urlHelpBody = h('div', { className: 'sdt-hm-url-help-body' }); const urlHelpRows = h('div', { className: 'sdt-hm-url-help-rows' }); - // Cheatsheet content tracks the active matching mode so it only ever shows - // syntax that actually works. function renderUrlHelp() { - if (filters.urlPatternMode === 'regex') { - urlHelpTitle.textContent = 'URL pattern · regex'; - urlHelpBody.replaceChildren( - 'Matched against the pathname with ClickHouse ', - makeCode('match()'), - ' (RE2 syntax), unanchored — add ', - makeCode('^'), - ' / ', - makeCode('$'), - ' to pin the start and end. No domain, hash, or query string.', - ); - urlHelpRows.replaceChildren( - makeUrlHelpRow('^/pricing$', 'Exactly /pricing'), - makeUrlHelpRow('^/products/', 'Anything starting with /products/'), - makeUrlHelpRow('/(privacy|terms)$', 'Ends in /privacy or /terms'), - makeUrlHelpRow('^/teams/[^/]+/members$', 'One id segment in the middle'), - makeUrlHelpRow('\\.html$', 'Paths ending in .html'), - makeUrlHelpRow('(empty)', 'This exact page only'), - ); - return; - } urlHelpTitle.textContent = 'URL pattern · glob'; urlHelpBody.replaceChildren( 'Limits the clickmap to pages whose path matches. Matched against the pathname only — no domain, hash, or query string. ', makeCode('*'), ' is the only wildcard and stands in for any characters (including ', makeCode('/'), - '). Everything else is matched literally — switch to regex for full RE2 syntax.', + '). Everything else is matched literally.', ); urlHelpRows.replaceChildren( makeUrlHelpRow('/pricing', 'That exact page'), @@ -2519,58 +2552,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR urlPatternHelp.addEventListener('click', (event) => { event.stopPropagation(); }); - - // Glob vs. regex matching. The backend already supports both (`url_pattern` - // → SQL LIKE, `route_regex` → ClickHouse match()); this toggle picks which - // one we send and keeps the local coverage check in sync. - const urlModeButtons = new Map(); - const urlPatternModeToggle = h('div', { - className: 'sdt-hm-mode', - role: 'radiogroup', - 'aria-label': 'URL pattern matching mode', - }); - const urlModeOptions: Array<[ClickmapUrlPatternMode, string, string]> = [ - ['glob', '*', 'Glob matching — * is the only wildcard'], - ['regex', '.*', 'Regex matching — full RE2 syntax'], - ]; - function applyUrlPatternMode() { - for (const [mode, btn] of urlModeButtons) { - const active = mode === filters.urlPatternMode; - btn.setAttribute('aria-checked', String(active)); - btn.classList.toggle('sdt-hm-mode-btn-active', active); - } - urlPatternInput.placeholder = filters.urlPatternMode === 'regex' ? '^/products/.*$' : '/products/*'; - renderUrlHelp(); - } - function setUrlPatternMode(mode: ClickmapUrlPatternMode) { - if (filters.urlPatternMode === mode) return; - let nextPattern: string; - if (mode === 'regex') { - // Seed regex mode from the current glob, translated to an equivalent - // anchored regex, so the switch leaves a working starting point. - const current = getEffectiveUrlPattern(); - nextPattern = current === '' ? '' : `^${globToRegexSource(current)}$`; - urlPatternUserEdited = nextPattern !== ''; - urlPatternInput.value = nextPattern; - } else { - // A regex can't be reversed into a glob, so fall back to auto route-tracking. - nextPattern = ''; - urlPatternUserEdited = false; - urlPatternInput.value = wildcardizePathname(window.location.pathname); - } - filters = { ...filters, urlPatternMode: mode, urlPattern: nextPattern }; - persistFilters(filters); - applyUrlPatternMode(); - scheduleFilterReload(); - scheduleRender(); - } - for (const [mode, label, title] of urlModeOptions) { - const btn = h('button', { className: 'sdt-hm-mode-btn', type: 'button', role: 'radio', title }, label) as HTMLButtonElement; - btn.addEventListener('click', () => setUrlPatternMode(mode)); - urlModeButtons.set(mode, btn); - urlPatternModeToggle.appendChild(btn); - } - applyUrlPatternMode(); + renderUrlHelp(); const elementSearchInput = h('input', { className: 'sdt-hm-filter-input', @@ -2604,11 +2586,11 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR setHtml(clicksIcon, clicksIconSvg); setHtml(elementsIcon, elementsIconSvg); const toolbar = h('div', { className: 'sdt-hm-toolbar' }, - backButton, + closeButton, h('div', { className: 'sdt-hm-toolbar-title' }, 'Clickmap'), h('div', { className: 'sdt-hm-toolbar-filters' }, rangeSelect, - h('div', { className: 'sdt-hm-toolbar-url' }, urlPatternInput, urlPatternReset, urlPatternModeToggle, urlPatternInfo, urlPatternHelp), + h('div', { className: 'sdt-hm-toolbar-url' }, urlPatternInput, urlPatternReset, urlPatternInfo, urlPatternHelp), ), h('div', { className: 'sdt-hm-toolbar-metrics' }, h('span', { className: 'sdt-hm-toolbar-metric', title: 'Aggregate clicks' }, miniClicks, clicksIcon), @@ -2662,10 +2644,9 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR }); urlPatternReset.addEventListener('click', () => { // Hand control back to auto mode and reflect the current route immediately, - // so the pattern covers the page the overlay is bound to. In regex mode - // there's no auto-wildcard, so clearing the field means "this exact page". + // so the pattern covers the page the overlay is bound to. urlPatternUserEdited = false; - urlPatternInput.value = filters.urlPatternMode === 'regex' ? '' : wildcardizePathname(window.location.pathname); + urlPatternInput.value = wildcardizePathname(window.location.pathname); updateFilters({ urlPattern: '' }); }); elementSearchInput.addEventListener('input', () => { @@ -2678,7 +2659,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR // bordered bands that made the expanded panel feel congested. const actions = h('div', { className: 'sdt-hm-actions' }, stats, overlayToggle); const head = h('div', { className: 'sdt-hm-head' }, filterRow, actions); - const body = h('div', { className: 'sdt-hm-body' }, status, list); + const body = h('div', { className: 'sdt-hm-body' }, status, viewportWarning, list); const details = h('div', { className: 'sdt-hm-details' }, head, body); function getGroups(): DevToolClickGroup[] { @@ -2848,25 +2829,34 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const mappedClicks = groups.reduce((sum, group) => sum + group.count, 0); const aggregateClicks = serverClickmap.path === currentPath ? serverClickmap.totalClicks : 0; const viewport = getClickmapViewportSize(); + const roundedViewportWidth = Math.round(viewport.width); + const roundedViewportHeight = Math.round(viewport.height); + const selectedViewportBucket = getClickmapViewportBucket(filters.device); + const viewportFilterMatches = selectedViewportBucket == null || isClickmapViewportWidthInBucket(roundedViewportWidth, selectedViewportBucket); statsCount.textContent = formatClickmapCount(aggregateClicks); selectorCount.textContent = formatClickmapCount(groups.length); - viewportValue.textContent = `${Math.round(viewport.width)}x${Math.round(viewport.height)}`; + viewportValue.textContent = `${roundedViewportWidth}x${roundedViewportHeight}`; overlayToggle.textContent = overlayVisible ? 'Hide overlay' : 'Show overlay'; + viewportWarning.classList.toggle('sdt-hm-viewport-warning-visible', !viewportFilterMatches); + if (selectedViewportBucket != null && !viewportFilterMatches) { + const recommendedWidth = getClickmapRecommendedViewportWidth(selectedViewportBucket); + const recommendedHeight = Math.max(1, roundedViewportHeight); + viewportWarningTitle.textContent = 'Viewport filter mismatch'; + viewportWarningBody.textContent = `This page is ${roundedViewportWidth}px wide, but ${filters.device} is ${formatClickmapViewportBucket(selectedViewportBucket)}. Update the Google DevTools device toolbar before comparing this clickmap.`; + viewportWarningWidthValue.textContent = String(recommendedWidth); + viewportWarningHeightValue.textContent = String(recommendedHeight); + } // A pattern that doesn't cover the current page means the overlay can't draw // here, so offer a one-click reset back to the current route. const effectiveUrlPattern = getEffectiveUrlPattern(); - const regexInvalid = filters.urlPatternMode === 'regex' && effectiveUrlPattern !== '' && !isValidRegexSource(effectiveUrlPattern); - const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath, filters.urlPatternMode); + const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath); urlPatternReset.classList.toggle('sdt-hm-filter-reset-visible', !urlPatternMatchesPath); - urlPatternInput.classList.toggle('sdt-hm-filter-input-error', regexInvalid); - const token = getClickmapTokenFromStorage(app.projectId); - const tokenOrigin = getClickmapOriginFromStorage(app.projectId); + const token = getClickmapTokenFromStorage(); + const tokenOrigin = getClickmapOriginFromStorage(); if (token == null) { status.textContent = serverClickmapError ?? 'No clickmap token in sessionStorage. Paste one from the dashboard to load this page.'; } else if (tokenOrigin != null && tokenOrigin !== window.location.origin) { status.textContent = `Token was minted for ${tokenOrigin}, but this page is ${window.location.origin}. Generate a token for this exact origin.`; - } else if (regexInvalid) { - status.textContent = 'Invalid regular expression — fix the pattern to load the clickmap.'; } else if (loadingServerClickmap) { status.textContent = 'Loading aggregate clickmap...'; } else if (serverClickmapError != null) { @@ -2887,7 +2877,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR } status.textContent = message; } - status.classList.toggle('sdt-hm-token-status-error', regexInvalid || serverClickmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin)); + status.classList.toggle('sdt-hm-token-status-error', serverClickmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin)); miniClicks.textContent = formatClickmapCount(aggregateClicks); miniElements.textContent = formatClickmapCount(groups.length); container.classList.toggle('sdt-hm-expanded', expanded); @@ -2906,7 +2896,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const requestId = serverClickmapRequestId + 1; serverClickmapRequestId = requestId; const isLatestRequest = () => requestId === serverClickmapRequestId; - const token = getClickmapTokenFromStorage(app.projectId); + const token = getClickmapTokenFromStorage(); if (token == null) { serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; serverClickmapError = null; @@ -2914,7 +2904,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR render(); return; } - const tokenOrigin = getClickmapOriginFromStorage(app.projectId); + const tokenOrigin = getClickmapOriginFromStorage(); if (tokenOrigin != null && tokenOrigin !== window.location.origin) { serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; serverClickmapError = null; @@ -2923,16 +2913,6 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR return; } - // Don't round-trip a regex we know ClickHouse will reject; surface it locally. - const pendingPattern = getEffectiveUrlPattern(); - if (filters.urlPatternMode === 'regex' && pendingPattern !== '' && !isValidRegexSource(pendingPattern)) { - serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; - serverClickmapError = null; - loadingServerClickmap = false; - render(); - return; - } - loadingServerClickmap = true; serverClickmapError = null; render(); @@ -2948,11 +2928,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR until: until.toISOString(), }; if (effectiveUrlPattern !== '') { - if (filters.urlPatternMode === 'regex') { - body.route_regex = effectiveUrlPattern; - } else { - body.url_pattern = effectiveUrlPattern; - } + body.url_pattern = effectiveUrlPattern; } else { body.route_path = requestedPath; } @@ -2978,7 +2954,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR } serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; if (error instanceof Error && error.message.includes('Clickmap token does not belong to this project')) { - clearClickmapTokenStorage(app.projectId); + clearClickmapTokenStorage(); serverClickmapError = 'The stored clickmap token belongs to another project. Generate a fresh token for this project.'; } else { serverClickmapError = error instanceof Error ? error.message : 'Failed to load clickmap data'; @@ -2995,8 +2971,8 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR // navigates away with a token loaded, drop a sentinel so the dev tool on the // next page can auto-reopen straight back into the clickmap tab. const onBeforeUnloadResume = () => { - const token = getClickmapTokenFromStorage(app.projectId); - const tokenOrigin = getClickmapOriginFromStorage(app.projectId); + const token = getClickmapTokenFromStorage(); + const tokenOrigin = getClickmapOriginFromStorage(); if (token == null || (tokenOrigin != null && tokenOrigin !== window.location.origin)) { return; } @@ -3011,7 +2987,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR overlayVisible = !overlayVisible; render(); }); - backButton.addEventListener('click', onBack); + closeButton.addEventListener('click', onClose); expandButton.addEventListener('click', () => { expanded = !expanded; render(); @@ -3511,7 +3487,6 @@ function createPanel( animateNextPanelGeometryChange(); } - panel.classList.toggle('sdt-panel-clickmap', tabId === 'clickmaps'); if (tabId === 'dashboard') { panel.classList.add('sdt-panel-fullscreen'); panel.style.width = ''; @@ -3520,11 +3495,6 @@ function createPanel( } panel.classList.remove('sdt-panel-fullscreen'); - if (tabId === 'clickmaps') { - panel.style.width = ''; - panel.style.height = ''; - return; - } panel.style.width = state.get().panelWidth + 'px'; panel.style.height = state.get().panelHeight + 'px'; } @@ -3532,7 +3502,6 @@ function createPanel( const tabs = getTabsForApp(app); const storedActiveTab = state.get().activeTab; const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab; - let lastNonClickmapTab: TabId = activeTab === 'clickmaps' ? 'overview' : activeTab; applyPanelMode(activeTab); @@ -3551,9 +3520,6 @@ function createPanel( const trailingControls = h('div', { className: 'sdt-tabbar-actions' }, docsLink, closeBtn); const tabBar = createTabBar(tabs, activeTab, (id) => { - if (id !== 'clickmaps') { - lastNonClickmapTab = id as TabId; - } state.update({ activeTab: id as TabId }); applyPanelMode(id as TabId, { animate: true }); showTab(id as TabId); @@ -3579,22 +3545,6 @@ function createPanel( } } - let clickmapsCleanup: (() => void) | null = null; - // The clickmap tab installs an overlay root, a MutationObserver, and - // background polling. Unlike other panes it must not be cached-and-hidden: - // tear it down (running its cleanup) whenever we leave it, so the overlay and - // its work don't linger after the tab is closed. - function teardownClickmapsPane() { - const pane = mountedPanes.get('clickmaps'); - if (pane == null) return; - if (clickmapsCleanup != null) { - clickmapsCleanup(); - clickmapsCleanup = null; - } - pane.remove(); - mountedPanes.delete('clickmaps'); - } - function getOrCreatePane(tabId: TabId): HTMLElement { if (mountedPanes.has(tabId)) { return mountedPanes.get(tabId)!; @@ -3608,17 +3558,6 @@ function createPanel( mountTab(pane, createOverviewTab(app)); break; } - case 'clickmaps': { - const result = createClickmapsTab(app, () => { - state.update({ activeTab: lastNonClickmapTab }); - applyPanelMode(lastNonClickmapTab, { animate: true }); - showTab(lastNonClickmapTab); - }); - pane.appendChild(result.element); - // Tracked separately from `cleanups` so it can run on tab-switch, not just unmount. - clickmapsCleanup = result.cleanup ?? null; - break; - } case 'customize': { mountTab(pane, createComponentsTab(app)); break; @@ -3646,9 +3585,6 @@ function createPanel( } function showTab(tabId: TabId) { - if (tabId !== 'clickmaps') { - teardownClickmapsPane(); - } const pane = getOrCreatePane(tabId); tabBar.setActive(tabId); for (const [, p] of mountedPanes) { @@ -3713,7 +3649,6 @@ function createPanel( if (panelAnimationTimeout !== null) { clearTimeout(panelAnimationTimeout); } - teardownClickmapsPane(); for (const fn of cleanups) fn(); }, }; @@ -3763,17 +3698,6 @@ export function createDevTool(app: StackClientApp): () => void { wrapper.appendChild(panel.element); } - function openClickmapPanel() { - state.update({ activeTab: 'clickmaps', isOpen: true }); - if (panel) { - const currentPanel = panel; - panel = null; - currentPanel.cleanup(); - currentPanel.element.remove(); - } - openPanel(); - } - function closePanel() { if (!panel) return; state.update({ isOpen: false }); @@ -3800,10 +3724,42 @@ export function createDevTool(app: StackClientApp): () => void { const trigger = createTrigger(togglePanel); wrapper.appendChild(trigger.element); - // Resume the clickmap panel after navigating to a new page. While the overlay - // is mounted it drops a sentinel into sessionStorage on unload; if it's - // present here, restore the clickmap tab as the active one and reopen the - // panel so the user picks up where they left off. + // Clickmaps is an independent feature with its own mount, completely separate + // from the dev tool panel above: opening or closing one never touches the + // other, and both can be on screen at once. It's driven entirely by the + // dashboard-minted token (the update event + the navigation resume sentinel), + // never by the dev tool trigger. + let clickmapMount: { element: HTMLElement, cleanup: () => void } | null = null; + + function openClickmap() { + if (clickmapMount) return; + const result = createClickmapsTab(app, () => closeClickmap()); + // Reuse the panel's clickmap-mode chrome (bottom-center positioning, no tab + // bar) by replicating the structure those styles target. + const element = h('div', { className: 'sdt-panel sdt-panel-clickmap' }, + h('div', { className: 'sdt-panel-inner' }, + h('div', { className: 'sdt-content' }, + h('div', { className: 'sdt-tab-layers' }, + h('div', { className: 'sdt-tab-pane sdt-tab-pane-active' }, result.element), + ), + ), + ), + ); + clickmapMount = { element, cleanup: result.cleanup ?? (() => {}) }; + wrapper.appendChild(element); + } + + function closeClickmap() { + if (!clickmapMount) return; + const closing = clickmapMount; + clickmapMount = null; + closing.cleanup(); + closing.element.remove(); + } + + // Resume the clickmap after navigating to a new page. While the overlay is + // mounted it drops a sentinel into sessionStorage on unload; if it's present + // here, reopen the clickmap so the user picks up where they left off. let shouldResumeClickmap = false; try { if (sessionStorage.getItem(CLICKMAP_OVERLAY_RESUME_STORAGE_KEY) === '1') { @@ -3813,15 +3769,15 @@ export function createDevTool(app: StackClientApp): () => void { } catch { // ignore } - if (shouldResumeClickmap) { - state.update({ activeTab: 'clickmaps', isOpen: true }); - } if (state.get().isOpen) { openPanel(); } + if (shouldResumeClickmap) { + openClickmap(); + } - window.addEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmapPanel); + window.addEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmap); const removeRequestListener = app[stackAppInternalsSymbol].addRequestListener((entry: RequestLogEntry) => { const timestamp = Date.now(); @@ -3850,9 +3806,10 @@ export function createDevTool(app: StackClientApp): () => void { setGlobalDevToolInstance(null); } trigger.cleanup(); - window.removeEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmapPanel); + window.removeEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmap); removeRequestListener(); panel?.cleanup(); + clickmapMount?.cleanup(); if (root.parentNode) { root.parentNode.removeChild(root); } diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index 1497fd7cc..e066990c3 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -3008,26 +3008,26 @@ export const devToolCSS = ` .stack-devtool .sdt-hm-filter-reset { display: none; + flex-shrink: 0; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; border: 0; + border-radius: 999px; background: transparent; padding: 0; - font: inherit; - font-family: var(--sdt-font); - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; color: var(--sdt-accent); cursor: pointer; } .stack-devtool .sdt-hm-filter-reset:hover { + background: var(--sdt-bg-hover); color: var(--sdt-accent-hover); } .stack-devtool .sdt-hm-filter-reset-visible { display: inline-flex; - align-items: center; } .stack-devtool .sdt-hm-filter-input { @@ -3149,6 +3149,88 @@ export const devToolCSS = ` color: var(--sdt-error); } + .stack-devtool .sdt-hm-viewport-warning { + display: none; + gap: 8px; + padding: 10px; + border-radius: var(--sdt-radius); + border: 1px solid rgba(234, 179, 8, 0.24); + background: var(--sdt-warning-muted); + color: var(--sdt-text); + } + + .stack-devtool .sdt-hm-viewport-warning-visible { + display: flex; + flex-direction: column; + } + + .stack-devtool .sdt-hm-viewport-warning-title { + font-size: 12px; + font-weight: 650; + color: var(--sdt-text); + line-height: 1.2; + } + + .stack-devtool .sdt-hm-viewport-warning-body { + font-size: 11.5px; + line-height: 1.45; + color: var(--sdt-text-secondary); + } + + .stack-devtool .sdt-hm-viewport-warning-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .stack-devtool .sdt-hm-viewport-warning-action { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: var(--sdt-radius); + border: 1px solid var(--sdt-border-subtle); + background: var(--sdt-bg-elevated); + padding: 4px 5px 4px 8px; + } + + .stack-devtool .sdt-hm-viewport-warning-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--sdt-text-tertiary); + } + + .stack-devtool .sdt-hm-viewport-warning-code { + font-family: var(--sdt-font-mono); + font-size: 11.5px; + font-weight: 650; + color: var(--sdt-text); + font-variant-numeric: tabular-nums; + } + + .stack-devtool .sdt-hm-copy-btn { + height: 22px; + border: 1px solid var(--sdt-border-subtle); + border-radius: 999px; + background: var(--sdt-bg); + color: var(--sdt-text-secondary); + padding: 0 8px; + font: inherit; + font-size: 10.5px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + } + + .stack-devtool .sdt-hm-copy-btn:hover { + background: var(--sdt-bg-hover); + border-color: var(--sdt-border); + color: var(--sdt-text); + transition: none; + } + .stack-devtool .sdt-hm-list { display: flex; flex-direction: column;