From 4b371e5c36824dc03cd8e1c976f9dfaa46c8d532 Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 17 Jul 2026 01:51:25 -0500 Subject: [PATCH] [Preview App] Updates visuals of walkthrough on preview app (#1511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the UI of the walkthrough image image --- ## Summary by cubic Makes the Preview App walkthrough smoother and clearer with a glowing spotlight, speech-bubble tooltip, improved cursor motion with a dwell pulse, and a timed progress card. Also removes preview project pooling and its cron jobs, restoring the standard environment health gate. - **New Features** - Spotlight: rounded cutout with a blue border/glow and smoother easing; tooltip has a speech-bubble pointer, flips above/below, and supports dark mode. - Cursor: smoother 500ms moves with a dwell pulse ring and a crisper shadow. - Progress: bottom “Tour” card shows step count and a timed % fill that advances during dwell, with a “navigating…” state; the tour auto-restarts after a brief pause; the “Click to take control” overlay now fades/blurs in; Escape-to-stop is guarded by `isTrusted`. Updated step copy for clarity. - **Refactors** - Removed preview pooling and its cron loop; restored the default health gate and `Loading` import for preview environments. Written for commit efdbe6fc6b62b1dbedb6dbe2bfabb7c23a4c3d4d. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **New Features** * Added a preview dashboard experience with short-lived, auto-expiring access leases. * Enhanced walkthrough tour with improved visual animations, phase-based styling, and optimized pacing for better user guidance. * **Documentation** * Updated internal testing guidance for managed email onboarding workflows. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1511?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Cursor --- apps/dashboard/src/app/globals.css | 19 ++ .../components/walkthrough/mock-cursor.tsx | 57 ++-- .../walkthrough/walkthrough-overlay.tsx | 282 +++++++++++++++--- .../walkthrough/walkthrough-provider.tsx | 95 +++--- .../walkthrough/walkthrough-steps.ts | 16 +- 5 files changed, 353 insertions(+), 116 deletions(-) diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index ef1738cf4..1d8d25d8a 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -453,11 +453,30 @@ body:has(.show-site-loading-indicator) .site-loading-indicator { } } +@keyframes walkthrough-pulse-ring { + 0% { + transform: scale(0.85); + opacity: 0.9; + } + 100% { + transform: scale(1.6); + opacity: 0; + } +} + +.walkthrough-pulse-ring { + animation: walkthrough-pulse-ring 1.4s ease-out infinite; +} + @media (prefers-reduced-motion: reduce) { .data-grid-export-progress-shimmer { animation: none; transform: translateX(75%); } + + .walkthrough-pulse-ring { + animation: none; + } } diff --git a/apps/dashboard/src/components/walkthrough/mock-cursor.tsx b/apps/dashboard/src/components/walkthrough/mock-cursor.tsx index 022d3e87f..c7c8714a8 100644 --- a/apps/dashboard/src/components/walkthrough/mock-cursor.tsx +++ b/apps/dashboard/src/components/walkthrough/mock-cursor.tsx @@ -1,21 +1,42 @@ -export function MockCursor({ className }: { className?: string }) { +import { cn } from '@/lib/utils'; +import type { WalkthroughPhase } from './walkthrough-steps'; + +export function MockCursor({ + className, + phase, +}: { + className?: string, + phase?: WalkthroughPhase, +}) { return ( - - - +
+ {phase === 'dwelling' && ( + + )} + + + +
); } diff --git a/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx b/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx index 75429e742..a60eec3a2 100644 --- a/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx +++ b/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx @@ -4,7 +4,11 @@ import { cn } from '@/lib/utils'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { MockCursor } from './mock-cursor'; -import { type SpotlightRect, type WalkthroughStep } from './walkthrough-steps'; +import { type SpotlightRect, type WalkthroughPhase, type WalkthroughStep } from './walkthrough-steps'; + +export type { WalkthroughPhase }; + +const SPOTLIGHT_BORDER = 'rgb(59 130 246)'; // blue-500 export function WalkthroughOverlay({ step, @@ -13,7 +17,9 @@ export function WalkthroughOverlay({ spotlightRect, cursorPosition, showSpotlight, + phase, isHovering, + dwellMs, onStop, }: { step: WalkthroughStep | null, @@ -22,19 +28,18 @@ export function WalkthroughOverlay({ spotlightRect: SpotlightRect | null, cursorPosition: { x: number, y: number }, showSpotlight: boolean, + phase: WalkthroughPhase, isHovering: boolean, + dwellMs: number, onStop: () => void, }) { - // Track whether the spotlight has animated in from full-screen to target const [animatedIn, setAnimatedIn] = useState(false); - // When showSpotlight turns on or stepIndex changes, reset to full-screen and then animate in useEffect(() => { if (!showSpotlight) { setAnimatedIn(false); return; } - // Start at full viewport, then on next frame animate to target setAnimatedIn(false); const raf = requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -46,7 +51,6 @@ export function WalkthroughOverlay({ if (typeof document === 'undefined') return null; - // When not yet animated in, spotlight covers the full viewport (no visible cutout) const fullViewport = { top: 0, left: 0, @@ -59,25 +63,27 @@ export function WalkthroughOverlay({ : null; const padding = step?.spotlightPadding ?? 8; - const overlayOpacity = showSpotlight ? (animatedIn ? 0.55 : 0) : 0; + const overlayOpacity = showSpotlight ? (animatedIn ? 0.62 : 0) : 0; return createPortal( <> - {/* Walkthrough layer — spotlight, tooltip (z-40, below CmdK) */} + {/* Spotlight layer (below CmdK at z-40) */}
- {/* Spotlight cutout overlay */} {showSpotlight && displayRect && step && ( <>
@@ -86,39 +92,55 @@ export function WalkthroughOverlay({ step={step} stepIndex={stepIndex} totalSteps={totalSteps} + phase={phase} spotlightRect={spotlightRect!} + padding={padding} /> )} )}
- {/* Mock mouse cursor — own stacking context above CmdK (z-[55]) */} + {/* Mock cursor — above CmdK */}
- +
- {/* "Click to take control" hover overlay — above everything including CmdK */} - {isHovering && ( -
{ - e.preventDefault(); - e.stopPropagation(); - onStop(); - }} - > -

Click to take control

-
+ {step && ( + )} - {/* Invisible click catcher — catches clicks when not hovering (z-40, below CmdK) */} + {/* "Click to take control" hover overlay */} +
{ + e.preventDefault(); + e.stopPropagation(); + onStop(); + }} + > +

+ Click to take control +

+
+ + {/* Invisible click catcher (active only when not hovering) */} {!isHovering && (
{ + let raf1 = 0; + let raf2 = 0; + + if (phase === 'finishing') { + setTransitionMs(300); + setProgress(1); + } else if (phase === 'dwelling') { + setTransitionMs(0); + setProgress(stepIndex / totalSteps); + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + setTransitionMs(dwellMs); + setProgress((stepIndex + 1) / totalSteps); + }); + }); + } else { + setTransitionMs(250); + setProgress(stepIndex / totalSteps); + } + + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [phase, stepIndex, totalSteps, dwellMs]); + + return ( +
+
+
+
+ + Tour + + + Step {stepIndex + 1} of {totalSteps} + + {phase === 'navigating' && ( + + · navigating… + + )} +
+ + {Math.round(progress * 100)}% + +
+ +
+
+
0 + ? `transform ${transitionMs}ms linear` + : 'none', + }} + /> +
+
+
+
+ ); +} + function SpotlightTooltip({ step, stepIndex, totalSteps, + phase, spotlightRect, + padding, }: { step: WalkthroughStep, stepIndex: number, totalSteps: number, + phase: WalkthroughPhase, spotlightRect: SpotlightRect, + padding: number, }) { const tooltipWidth = 280; - const tooltipHeight = 90; - const tooltipGap = 16; - const viewportMargin = 16; - const padding = step.spotlightPadding ?? 8; + const tooltipGap = 14; + const viewportMarginTop = 16; + const viewportMarginBottom = 112; + const viewportMarginX = 16; const spotlightTop = spotlightRect.top - padding; const spotlightBottom = spotlightRect.top + spotlightRect.height + padding; const spotlightCenterX = spotlightRect.left + spotlightRect.width / 2; - // Default: below the spotlight + let placement: 'below' | 'above' = 'below'; let top = spotlightBottom + tooltipGap; - let left = spotlightCenterX - tooltipWidth / 2; - // If tooltip would go off-screen bottom, position above - if (top + tooltipHeight > window.innerHeight - viewportMargin) { - top = spotlightTop - tooltipGap - tooltipHeight; + const estimatedHeight = 96; + if (top + estimatedHeight > window.innerHeight - viewportMarginBottom) { + placement = 'above'; + top = spotlightTop - tooltipGap - estimatedHeight; } - // Clamp to viewport - top = Math.max(viewportMargin, Math.min(top, window.innerHeight - tooltipHeight - viewportMargin)); - left = Math.max(viewportMargin, Math.min(left, window.innerWidth - tooltipWidth - viewportMargin)); + top = Math.max( + viewportMarginTop, + Math.min(top, window.innerHeight - estimatedHeight - viewportMarginBottom), + ); + + let left = spotlightCenterX - tooltipWidth / 2; + left = Math.max( + viewportMarginX, + Math.min(left, window.innerWidth - tooltipWidth - viewportMarginX), + ); + + const pointerLeft = Math.max( + 20, + Math.min(spotlightCenterX - left, tooltipWidth - 20), + ); return (
-
- {step.title} - {stepIndex + 1} / {totalSteps} + {/* Speech bubble pointer */} +
+ +
+
+ {step.title} + + {stepIndex + 1} / {totalSteps} + +
+

{step.description}

+ {phase === 'navigating' && ( +

navigating…

+ )}
-

{step.description}

); } + diff --git a/apps/dashboard/src/components/walkthrough/walkthrough-provider.tsx b/apps/dashboard/src/components/walkthrough/walkthrough-provider.tsx index 4cc5487ce..668dbf063 100644 --- a/apps/dashboard/src/components/walkthrough/walkthrough-provider.tsx +++ b/apps/dashboard/src/components/walkthrough/walkthrough-provider.tsx @@ -3,10 +3,15 @@ import { getPublicEnvVar } from '@/lib/env'; import { useCallback, useEffect, useRef, useState } from 'react'; import { type SpotlightRect, WALKTHROUGH_STEPS } from './walkthrough-steps'; -import { WalkthroughOverlay } from './walkthrough-overlay'; +import { WalkthroughOverlay, type WalkthroughPhase } from './walkthrough-overlay'; -// Timing multiplier for debugging — set to 1.0 for production, lower to speed up +// Tuning knobs. Lower TIMING_MULTIPLIER for debugging. const TIMING_MULTIPLIER = 1.0; +const STEP_DWELL_MS = 4000; +const INTER_LOOP_PAUSE_MS = 2000; +const CURSOR_MOVE_MS = 500; +const TYPE_MIN_MS = 25; +const TYPE_MAX_MS = 40; function useProjectId() { if (typeof window === 'undefined') return null; @@ -21,13 +26,12 @@ function waitForElement(selector: string, timeoutMs = 3000): Promise { const el = document.querySelector(selector); if (el && el.getBoundingClientRect().height > 0) { resolve(el); - } else if (Date.now() - start > timeoutMs) { + } else if (performance.now() - start > timeoutMs) { resolve(null); } else { requestAnimationFrame(check); @@ -41,7 +45,7 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms * TIMING_MULTIPLIER)); } -// For waits that depend on CSS animations / external timing, not walkthrough pacing +// For waits tied to external CSS animation durations rather than walkthrough pacing. function sleepFixed(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -56,7 +60,7 @@ async function typeIntoInput(input: HTMLInputElement, text: string, cancelled: ( const currentValue = input.value + char; nativeInputValueSetter.call(input, currentValue); input.dispatchEvent(new Event('input', { bubbles: true })); - await sleep(60 + Math.random() * 40); + await sleep(TYPE_MIN_MS + Math.random() * (TYPE_MAX_MS - TYPE_MIN_MS)); } } @@ -78,6 +82,7 @@ export function WalkthroughProvider({ children }: { children: React.ReactNode }) function WalkthroughEngine() { const [isRunning, setIsRunning] = useState(false); const [stepIndex, setStepIndex] = useState(0); + const [phase, setPhase] = useState('navigating'); const [spotlightRect, setSpotlightRect] = useState(null); const [showSpotlight, setShowSpotlight] = useState(false); const [cursorPosition, setCursorPosition] = useState({ x: -50, y: -50 }); @@ -121,7 +126,22 @@ function WalkthroughEngine() { return () => window.removeEventListener('message', handleMessage); }, []); - // Track mouse hover for "Click to take control" overlay + // Keyboard controls: Esc to stop. (Skip-forward intentionally not exposed — + // it would race the async tour engine; tour runs at a fixed pace.) + useEffect(() => { + if (!isRunning) return; + + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && e.isTrusted) { + e.preventDefault(); + stop(); + } + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [isRunning, stop]); + + // Track mouse hover so we can show the "Click to take control" overlay. useEffect(() => { if (!isRunning) return; @@ -144,7 +164,6 @@ function WalkthroughEngine() { const isCancelled = () => cancelled || stoppedRef.current; const navigateViaCmdK = async (searchText: string) => { - // Move cursor to CmdK trigger button const cmdkTrigger = document.querySelector('[data-walkthrough-nav="cmdk-trigger"]') as HTMLElement | null; if (!cmdkTrigger) return false; @@ -153,15 +172,13 @@ function WalkthroughEngine() { x: triggerRect.left + triggerRect.width / 2, y: triggerRect.top + triggerRect.height / 2, }); - await sleep(600); + await sleep(CURSOR_MOVE_MS); if (isCancelled()) return false; - // Open CmdK window.dispatchEvent(new CustomEvent('spotlight-toggle')); - await sleep(400); + await sleep(250); if (isCancelled()) return false; - // Find the input and type into it const input = document.querySelector('input[placeholder="Search or ask AI..."]') as HTMLInputElement | null; if (!input) return false; @@ -170,17 +187,13 @@ function WalkthroughEngine() { x: inputRect.left + inputRect.width / 3, y: inputRect.top + inputRect.height / 2, }); - await sleep(500); + await sleep(300); if (isCancelled()) return false; await typeIntoInput(input, searchText, isCancelled); if (isCancelled()) return false; - await sleep(400); - if (isCancelled()) return false; - - // Click the first result - await sleep(100); + await sleep(250); if (isCancelled()) return false; const cmdkContainer = input.closest('.rounded-2xl'); @@ -192,11 +205,11 @@ function WalkthroughEngine() { x: resultRect.left + resultRect.width / 2, y: resultRect.top + resultRect.height / 2, }); - await sleep(400); + await sleep(300); if (isCancelled()) return false; resultButton.click(); - await sleep(500); + await sleep(350); if (isCancelled()) return false; } @@ -231,19 +244,14 @@ function WalkthroughEngine() { let targetLink = findSidebarLink(); - // If link isn't visible, find and expand its parent section + // If link isn't visible, expand its parent section first. if (!targetLink) { - // Find the link in DOM (even if hidden) to locate its parent section button for (const link of document.querySelectorAll('aside a')) { if (link.textContent.trim() === label) { - // Walk up to find the closest collapsed section let el = link.parentElement; while (el && el.tagName !== 'ASIDE') { const prevSibling = el.previousElementSibling; if (prevSibling) { - // The expand button may be the prevSibling itself, or nested - // inside it (the sidebar wraps the chevron toggle in a header - // div that also contains the section's main ). const expandButton = (prevSibling.tagName === 'BUTTON' && prevSibling.getAttribute('aria-expanded') === 'false') ? prevSibling as HTMLElement : prevSibling.querySelector('button[aria-expanded="false"]') as HTMLElement | null; @@ -257,21 +265,19 @@ function WalkthroughEngine() { break; } } - // Wait for CSS height transition (200ms) to complete - await sleepFixed(300); + // Wait for sidebar section CSS height transition (200ms) to complete. + await sleepFixed(220); if (isCancelled()) return false; targetLink = findSidebarLink(); } if (!targetLink) return false; - // Scroll the sidebar to make the link visible (not the outer page) const scrollContainer = targetLink.closest('[class*="overflow-y-auto"]') ?? targetLink.closest('aside'); if (scrollContainer) { const linkTop = targetLink.offsetTop; scrollContainer.scrollTo({ top: linkTop - scrollContainer.clientHeight / 2 }); } - // Wait a frame for scroll to settle before reading position await sleepFixed(50); if (isCancelled()) return false; @@ -280,11 +286,11 @@ function WalkthroughEngine() { x: linkRect.left + linkRect.width / 2, y: linkRect.top + linkRect.height / 2, }); - await sleep(600); + await sleep(CURSOR_MOVE_MS); if (isCancelled()) return false; targetLink.click(); - await sleep(500); + await sleep(350); if (isCancelled()) return false; return true; @@ -297,11 +303,11 @@ function WalkthroughEngine() { const step = WALKTHROUGH_STEPS[i]; setStepIndex(i); + setPhase('navigating'); setShowSpotlight(false); const needsNavigation = currentPathRef.current !== step.path; - // Phase 1: Navigate if needed if (needsNavigation) { let success = false; if (step.cmdkSearch) { @@ -312,26 +318,23 @@ function WalkthroughEngine() { if (isCancelled()) return; if (success) { currentPathRef.current = step.path; - await sleep(300); + await sleep(200); if (isCancelled()) return; } } - // Phase 2: Wait for target element const targetEl = await waitForElement(`[data-walkthrough="${step.id}"]`); if (isCancelled()) return; if (!targetEl) continue; - // Phase 3: Animate mouse to target element const targetRect = targetEl.getBoundingClientRect(); setCursorPosition({ x: targetRect.left + targetRect.width / 2, y: targetRect.top + targetRect.height / 2, }); - await sleep(600); + await sleep(CURSOR_MOVE_MS); if (isCancelled()) return; - // Phase 4: Show spotlight setSpotlightRect({ top: targetRect.top, left: targetRect.left, @@ -339,8 +342,9 @@ function WalkthroughEngine() { height: targetRect.height, }); setShowSpotlight(true); + setPhase('dwelling'); - // Continuously track element position + // Track moving/resizing targets while we dwell. const trackElement = () => { if (isCancelled()) return; const el = document.querySelector(`[data-walkthrough="${step.id}"]`); @@ -357,11 +361,16 @@ function WalkthroughEngine() { }; rafRef.current = requestAnimationFrame(trackElement); - // Phase 5: Wait at this step - await sleep(8000); + await sleep(STEP_DWELL_MS); cancelAnimationFrame(rafRef.current); if (isCancelled()) return; } + + // Finished one full pass — pause briefly, then restart. + setPhase('finishing'); + setShowSpotlight(false); + await sleep(INTER_LOOP_PAUSE_MS); + currentPathRef.current = '/__force_renav__'; } }; @@ -388,7 +397,9 @@ function WalkthroughEngine() { spotlightRect={spotlightRect} cursorPosition={cursorPosition} showSpotlight={showSpotlight} + phase={phase} isHovering={isHovering} + dwellMs={STEP_DWELL_MS * TIMING_MULTIPLIER} onStop={stop} /> ); diff --git a/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts b/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts index 8389c5018..01111fa6b 100644 --- a/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts +++ b/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts @@ -1,3 +1,5 @@ +export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing'; + export type WalkthroughStep = { id: string, path: string, @@ -21,14 +23,14 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [ path: '/', sidebarNavLabel: 'Overview', title: 'Global User Map', - description: 'See where your users are around the world.', + description: 'Pinch-zoom around the globe and see where sign-ups are landing.', spotlightPadding: 12, }, { id: 'overview-metrics', path: '/', title: 'Usage Metrics', - description: 'Track daily active users and sign-ups at a glance.', + description: 'Daily actives, sign-ups, and retention — the numbers you actually check.', spotlightPadding: 12, }, { @@ -36,34 +38,34 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [ path: '/users', cmdkSearch: 'Users', title: 'User Management', - description: 'Manage all your users — search, export, or create new ones.', + description: 'Search, export, impersonate, or spin up test users in one place.', }, { id: 'teams-table', path: '/teams', cmdkSearch: 'Teams', title: 'Teams', - description: 'Organize users into teams for multi-tenant apps.', + description: 'Multi-tenant apps live here. Teams, roles, invites — the whole stack.', }, { id: 'emails-sent', path: '/email-sent', cmdkSearch: 'Emails sent', title: 'Email Logs', - description: 'Monitor sent emails, delivery status, and domain reputation.', + description: 'Every sent email, delivery status, and domain health in one feed.', }, { id: 'payments-products', path: '/payments/products', cmdkSearch: 'Products', title: 'Products & Pricing', - description: 'Define products, pricing, and subscriptions.', + description: 'Products, prices, and subscriptions wired straight to Stripe.', }, { id: 'analytics-replays', path: '/session-replays', sidebarNavLabel: 'Replays', title: 'Session Replays', - description: 'Watch real user sessions to understand how people use your app.', + description: 'Watch real sessions — clicks, rage taps, and dead ends included.', }, ];