mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
visual updates to preview walkthrough
This commit is contained in:
parent
fae8d2dfab
commit
7ca1587de8
@ -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.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user