[Preview App] Updates visuals of walkthrough on preview app (#1511)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests / wait-for-fast-fail (push) Has been cancelled
Runs E2E API Tests with custom port prefix / wait-for-fast-fail (push) Has been cancelled
Runs E2E Fallback Tests / wait-for-fast-fail (push) Has been cancelled
Fast-fail tests / select-tests (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Fast-fail tests / fast-fail-tests (push) Has been cancelled

Updates the UI of the walkthrough

<img width="1285" height="731" alt="image"
src="https://github.com/user-attachments/assets/1467e134-a86e-4b80-bfe0-7272cc1f419f"
/>


<img width="946" height="425" alt="image"
src="https://github.com/user-attachments/assets/4e958141-e59a-4694-9abf-f5b47e9fd3b9"
/>



<!-- This is an auto-generated description by cubic. -->
---
## 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.

<sup>Written for commit efdbe6fc6b.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1511?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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_stack_entry_start -->

[![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)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Madison 2026-07-17 01:51:25 -05:00 committed by GitHub
parent afa2d74ba0
commit 4b371e5c36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 353 additions and 116 deletions

View File

@ -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;
}
}

View File

@ -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 (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
>
<path
d="M5 3L19 12L12 13L9 20L5 3Z"
fill="white"
stroke="black"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
<div className="relative">
{phase === 'dwelling' && (
<span
className={cn(
"absolute -top-1 -left-1 h-8 w-8 rounded-full",
"border-2 border-blue-500",
"walkthrough-pulse-ring",
)}
aria-hidden
/>
)}
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={{ filter: 'drop-shadow(2px 2px 0 rgba(0,0,0,0.85))' }}
>
<path
d="M5 3L19 12L12 13L9 20L5 3Z"
fill="white"
stroke="#171717"
strokeWidth="1.75"
strokeLinejoin="round"
/>
</svg>
</div>
);
}

View File

@ -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) */}
<div className="fixed inset-0 z-40 pointer-events-none">
{/* Spotlight cutout overlay */}
{showSpotlight && displayRect && step && (
<>
<div
className="fixed pointer-events-none rounded-xl"
className="fixed pointer-events-none"
style={{
top: displayRect.top - padding,
left: displayRect.left - padding,
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)',
borderRadius: animatedIn ? undefined : '0px',
borderRadius: animatedIn ? '14px' : '0px',
border: animatedIn ? `2px solid ${SPOTLIGHT_BORDER}` : '0px solid transparent',
boxShadow: animatedIn
? `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity}), 0 0 14px 2px rgba(59, 130, 246, 0.35), 0 0 28px 6px rgba(59, 130, 246, 0.12)`
: `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity})`,
transition: 'all 400ms cubic-bezier(0.22, 1, 0.36, 1)',
}}
/>
@ -86,39 +92,55 @@ export function WalkthroughOverlay({
step={step}
stepIndex={stepIndex}
totalSteps={totalSteps}
phase={phase}
spotlightRect={spotlightRect!}
padding={padding}
/>
)}
</>
)}
</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 />
<MockCursor phase={phase} />
</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>
{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 */}
<div
className={cn(
"fixed inset-0 z-[60] flex items-center justify-center cursor-pointer",
"bg-black/50 backdrop-blur-[2px]",
"transition-opacity duration-150",
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) */}
{!isHovering && (
<div
className="fixed inset-0 z-40"
@ -134,58 +156,220 @@ export function WalkthroughOverlay({
);
}
function TourProgressCard({
stepIndex,
totalSteps,
phase,
dwellMs,
}: {
stepIndex: number,
totalSteps: number,
phase: WalkthroughPhase,
dwellMs: number,
}) {
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') {
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 (
<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-blue-500 text-white",
)}
>
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>
<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="absolute inset-y-0 left-0 origin-left rounded-full bg-blue-500"
style={{
width: '100%',
transform: `scaleX(${progress})`,
transition: transitionMs > 0
? `transform ${transitionMs}ms linear`
: 'none',
}}
/>
</div>
</div>
</div>
</div>
);
}
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 (
<div
className={cn(
"fixed pointer-events-none p-4 rounded-xl",
"bg-white shadow-xl ring-1 ring-black/10",
)}
className="fixed pointer-events-none"
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>
{/* Speech bubble pointer */}
<div
className={cn(
"absolute w-3 h-3",
"bg-white/95 dark:bg-zinc-900/90",
"border-black/10 dark:border-white/10",
)}
style={{
left: pointerLeft - 6,
...(placement === 'below'
? {
top: -6,
borderTopWidth: '1px',
borderLeftWidth: '1px',
transform: 'rotate(45deg)',
}
: {
bottom: -6,
borderBottomWidth: '1px',
borderRightWidth: '1px',
transform: 'rotate(45deg)',
}),
}}
/>
<div
className={cn(
"relative rounded-xl px-4 py-3",
"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",
)}
>
<div className="flex items-center justify-between mb-1">
<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-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>
<p className="text-sm text-gray-600 leading-relaxed">{step.description}</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.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 <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}
/>
);

View File

@ -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.',
},
];