visual updates to preview walkthrough

This commit is contained in:
Madison 2026-05-27 08:52:48 -05:00
parent fae8d2dfab
commit 7ca1587de8
3 changed files with 252 additions and 79 deletions

View File

@ -556,3 +556,6 @@ A: Project config overrides only support the hosted `sourceOfTruth` shape. Legac
## Q: How should managed email onboarding e2e tests wait for mock verification?
A: Do not rely on a fixed `wait(1500)` after setup. The mock onboarding path flips the domain to `verified` asynchronously through `runAsynchronously`, so tests should poll the managed-onboarding check endpoint until the expected status appears.
## Q: How does dashboard preview mode currently bootstrap the iframe demo?
A: In preview mode, the dashboard uses memory tokens and the protected layout client signs in or signs up a fresh `preview-*@preview.stack-auth.com` internal user. The `/projects` page renders `PreviewProjectRedirect`, POSTs `/internal/preview/create-project`, waits for `seedDummyProject`, refreshes the owned-projects cache, then navigates to `/projects/{project_id}`. The seeding endpoint creates a real isolated project owned by the preview user's auto-created team, warms/reuses ClickHouse, seeds project config/users/teams/emails/session activity/session replays/analytics mirrors synchronously, and starts payments seeding in the background.

View File

@ -6,6 +6,8 @@ import { createPortal } from 'react-dom';
import { MockCursor } from './mock-cursor';
import { type SpotlightRect, type WalkthroughStep } from './walkthrough-steps';
export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing';
export function WalkthroughOverlay({
step,
stepIndex,
@ -13,7 +15,9 @@ export function WalkthroughOverlay({
spotlightRect,
cursorPosition,
showSpotlight,
phase,
isHovering,
dwellMs,
onStop,
}: {
step: WalkthroughStep | null,
@ -22,19 +26,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 +49,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,
@ -63,9 +65,8 @@ export function WalkthroughOverlay({
return createPortal(
<>
{/* Walkthrough layer — spotlight, tooltip (z-40, below CmdK) */}
{/* Spotlight layer (below CmdK at z-40) */}
<div className="fixed inset-0 z-40 pointer-events-none">
{/* Spotlight cutout overlay */}
{showSpotlight && displayRect && step && (
<>
<div
@ -76,7 +77,7 @@ export function WalkthroughOverlay({
width: displayRect.width + padding * 2,
height: displayRect.height + padding * 2,
boxShadow: `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity})`,
transition: 'top 0.6s cubic-bezier(0.4, 0, 0.2, 1), left 0.6s cubic-bezier(0.4, 0, 0.2, 1), width 0.6s cubic-bezier(0.4, 0, 0.2, 1), height 0.6s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
transition: 'all 400ms cubic-bezier(0.22, 1, 0.36, 1)',
borderRadius: animatedIn ? undefined : '0px',
}}
/>
@ -86,6 +87,7 @@ export function WalkthroughOverlay({
step={step}
stepIndex={stepIndex}
totalSteps={totalSteps}
phase={phase}
spotlightRect={spotlightRect!}
/>
)}
@ -93,32 +95,47 @@ export function WalkthroughOverlay({
)}
</div>
{/* Mock mouse cursor — own stacking context above CmdK (z-[55]) */}
{/* Mock cursor — above CmdK */}
<div
className="fixed top-0 left-0 pointer-events-none z-[55]"
style={{
transform: `translate(${cursorPosition.x}px, ${cursorPosition.y}px)`,
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
transition: 'transform 500ms cubic-bezier(0.22, 1, 0.36, 1)',
}}
>
<MockCursor />
</div>
{/* "Click to take control" hover overlay — above everything including CmdK */}
{isHovering && (
<div
className="fixed inset-0 z-[60] bg-black/50 flex items-center justify-center cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStop();
}}
>
<p className="text-white text-2xl font-semibold">Click to take control</p>
</div>
{/* Floating tour progress card — bottom-center, bold and unmissable */}
{step && (
<TourProgressCard
stepIndex={stepIndex}
totalSteps={totalSteps}
phase={phase}
dwellMs={dwellMs}
/>
)}
{/* Invisible click catcher — catches clicks when not hovering (z-40, below CmdK) */}
{/* "Click to take control" hover overlay — above everything including CmdK */}
<div
className={cn(
"fixed inset-0 z-[60] flex items-center justify-center cursor-pointer",
"bg-black/50 backdrop-blur-[2px]",
"transition-opacity duration-200",
isHovering ? "opacity-100" : "opacity-0 pointer-events-none",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStop();
}}
>
<p className="text-white text-2xl font-semibold drop-shadow-lg">
Click to take control
</p>
</div>
{/* Invisible click catcher (active only when not hovering) — anywhere = stop */}
{!isHovering && (
<div
className="fixed inset-0 z-40"
@ -134,58 +151,200 @@ export function WalkthroughOverlay({
);
}
function TourProgressCard({
stepIndex,
totalSteps,
phase,
dwellMs,
}: {
stepIndex: number,
totalSteps: number,
phase: WalkthroughPhase,
dwellMs: number,
}) {
// Animate the bar's scaleX. During 'dwelling', grow from i/N to (i+1)/N over
// `dwellMs`. During 'navigating', hold at i/N. During 'finishing', show 100%.
const [progress, setProgress] = useState(stepIndex / totalSteps);
const [transitionMs, setTransitionMs] = useState(0);
useEffect(() => {
let raf1 = 0;
let raf2 = 0;
if (phase === 'finishing') {
setTransitionMs(300);
setProgress(1);
} else if (phase === 'dwelling') {
// Snap to start with no transition, then next frame kick off the long animation.
setTransitionMs(0);
setProgress(stepIndex / totalSteps);
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
setTransitionMs(dwellMs);
setProgress((stepIndex + 1) / totalSteps);
});
});
} else {
// 'navigating' — hold at start of step with a short transition so loop
// resets ease in rather than snap.
setTransitionMs(250);
setProgress(stepIndex / totalSteps);
}
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [phase, stepIndex, totalSteps, dwellMs]);
return (
<div
className={cn(
"fixed bottom-6 left-1/2 -translate-x-1/2 z-[57]",
"pointer-events-none select-none",
"w-[min(720px,calc(100vw-32px))]",
)}
>
<div
className={cn(
"rounded-2xl overflow-hidden",
"bg-white/95 dark:bg-zinc-900/90",
"backdrop-blur-2xl",
"ring-1 ring-black/10 dark:ring-white/10",
"shadow-2xl shadow-black/40",
)}
>
<div className="px-5 pt-3.5 pb-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2.5 min-w-0">
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded-full",
"text-[10px] font-bold uppercase tracking-wider",
"bg-gradient-to-r from-blue-500 to-indigo-500 text-white",
"shadow-[0_0_12px_rgba(59,130,246,0.6)]",
)}
>
Tour
</span>
<span className="text-[13px] font-medium text-zinc-700 dark:text-zinc-200 tabular-nums">
Step {stepIndex + 1} of {totalSteps}
</span>
{phase === 'navigating' && (
<span className="text-[12px] text-zinc-500 dark:text-zinc-400 italic">
· navigating
</span>
)}
</div>
<span className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 tabular-nums shrink-0">
{Math.round(progress * 100)}%
</span>
</div>
{/* The big bar */}
<div className="px-5 pb-4">
<div
className={cn(
"relative h-3 rounded-full overflow-hidden",
"bg-zinc-200/80 dark:bg-white/10",
"ring-1 ring-inset ring-black/5 dark:ring-white/5",
)}
>
<div
className={cn(
"absolute inset-y-0 left-0 origin-left rounded-full",
"bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-500",
"shadow-[0_0_18px_rgba(59,130,246,0.85),0_0_6px_rgba(99,102,241,1)]",
)}
style={{
width: '100%',
transform: `scaleX(${progress})`,
transition: transitionMs > 0
? `transform ${transitionMs}ms linear`
: 'none',
}}
>
{/* Bright leading edge highlight */}
<div
className="absolute right-0 top-0 bottom-0 w-10 bg-gradient-to-l from-white/80 to-transparent pointer-events-none"
/>
</div>
</div>
</div>
</div>
</div>
);
}
function SpotlightTooltip({
step,
stepIndex,
totalSteps,
phase,
spotlightRect,
}: {
step: WalkthroughStep,
stepIndex: number,
totalSteps: number,
phase: WalkthroughPhase,
spotlightRect: SpotlightRect,
}) {
const tooltipWidth = 280;
const tooltipHeight = 90;
const tooltipWidth = 300;
const tooltipHeight = 96;
const tooltipGap = 16;
const viewportMargin = 16;
// Reserve room for the bottom progress card (~88px tall including margin).
const viewportMarginTop = 16;
const viewportMarginBottom = 112;
const viewportMarginX = 16;
const padding = step.spotlightPadding ?? 8;
const spotlightTop = spotlightRect.top - padding;
const spotlightBottom = spotlightRect.top + spotlightRect.height + padding;
const spotlightCenterX = spotlightRect.left + spotlightRect.width / 2;
// Default: below the spotlight
// Default: below the spotlight.
let top = spotlightBottom + tooltipGap;
let left = spotlightCenterX - tooltipWidth / 2;
// If tooltip would go off-screen bottom, position above
if (top + tooltipHeight > window.innerHeight - viewportMargin) {
// If that would collide with the bottom progress card, flip to above the spotlight.
if (top + tooltipHeight > window.innerHeight - viewportMarginBottom) {
top = spotlightTop - tooltipGap - tooltipHeight;
}
// 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 - tooltipHeight - viewportMarginBottom),
);
left = Math.max(
viewportMarginX,
Math.min(left, window.innerWidth - tooltipWidth - viewportMarginX),
);
return (
<div
className={cn(
"fixed pointer-events-none p-4 rounded-xl",
"bg-white shadow-xl ring-1 ring-black/10",
"fixed pointer-events-none px-4 py-3 rounded-xl",
"bg-white/95 dark:bg-zinc-900/90",
"backdrop-blur-xl",
"shadow-2xl shadow-black/25",
"ring-1 ring-black/10 dark:ring-white/10",
)}
style={{
top,
left,
width: tooltipWidth,
transition: 'top 0.4s ease, left 0.4s ease',
transition: 'top 400ms cubic-bezier(0.22, 1, 0.36, 1), left 400ms cubic-bezier(0.22, 1, 0.36, 1)',
}}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold text-gray-900">{step.title}</span>
<span className="text-xs text-gray-400">{stepIndex + 1} / {totalSteps}</span>
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">{step.title}</span>
<span className="text-[11px] tabular-nums font-medium text-zinc-500 dark:text-zinc-400">
{stepIndex + 1} / {totalSteps}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">{step.description}</p>
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{step.description}</p>
{phase === 'navigating' && (
<p className="mt-1.5 text-[11px] text-zinc-400 dark:text-zinc-500 italic">navigating</p>
)}
</div>
);
}

View File

@ -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<Element | n
resolve(existing);
return;
}
const start = Date.now();
const start = performance.now();
const check = () => {
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<void> {
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<void> {
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<WalkthroughPhase>('navigating');
const [spotlightRect, setSpotlightRect] = useState<SpotlightRect | null>(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.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 <a>).
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}
/>
);