Skip to content
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1766f54
fix: trap keyboard focus within onboarding dialog
karthikeya20012007 May 21, 2026
eb489af
chore: resolve merge conflicts with main
magic-peach May 23, 2026
acb043d
fix(types): remove duplicate DEFAULT_RECIPE and SPEED_STEPS from type…
rahul-rajak-nsut May 23, 2026
8f2d93e
fix(ffmpeg): guard buildAudioFilter against zero or negative speed (#…
anshul23102 May 23, 2026
f7e9aed
fix(ThumbnailStrip): replace abortRef with run ID to eliminate concur…
anshul23102 May 23, 2026
9d12c0c
fix(useVideoEditor): guard validateRecipe and localStorage against Na…
anshul23102 May 23, 2026
0aabcc3
fix: prevent custom dimension inputs from clipping 4-digit values (#884)
Vedhant26 May 23, 2026
3a5bff6
fix: allow external contributors to trigger automated PR review workf…
anshul23102 May 24, 2026
8439533
docs: improve and standardize pull request template (#1035)
shivani11jadhav May 24, 2026
d5324d7
feat: improve mobile layout & responsiveness (#912) (#1034)
dropps07 May 24, 2026
76280ac
feat: add text overlay (#1033)
mohammed-danyal May 24, 2026
aff28bd
fix: prevent onboarding tour crash during step transitions (#1032)
shivanshanand May 24, 2026
90c0e3b
fix: improve border and surface contrast in light mode (#1031)
Arsh-sudo May 24, 2026
7f0fe92
fix: improve color palette and visual consistency (closes #616) (#989)
Abhi190702 May 24, 2026
874bbb5
fix: resolve merge conflicts with upstream main
karthikeya20012007 May 24, 2026
2388541
fix: resolve merge conflicts with upstream main
karthikeya20012007 May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 133 additions & 78 deletions src/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { FocusTrap } from "focus-trap-react";

const TOUR_KEY = "reframe_onboarding_complete";

Expand Down Expand Up @@ -44,7 +45,7 @@ const TOUR_STEPS: TourStep[] = [
},
];

const PADDING = 12; // spotlight padding around target element
const PADDING = 12;
const TOOLTIP_OFFSET = 16;

interface Rect {
Expand Down Expand Up @@ -76,16 +77,19 @@ function getTooltipStyle(
top: sr.top - th - TOOLTIP_OFFSET,
left: sr.left + sr.width / 2 - tw / 2,
};

case "left":
return {
top: sr.top + sr.height / 2 - th / 2,
left: sr.left - tw - TOOLTIP_OFFSET,
};

case "right":
return {
top: sr.top + sr.height / 2 - th / 2,
left: sr.left + sr.width + TOOLTIP_OFFSET,
};

case "bottom":
default:
return {
Expand Down Expand Up @@ -126,13 +130,14 @@ function Spotlight({ rect }: SpotlightProps) {
/>
</mask>
</defs>

<rect
width="100%"
height="100%"
fill="rgba(0,0,0,0.65)"
mask="url(#spotlight-mask)"
/>
{/* Highlight ring */}

<rect
x={r.left}
y={r.top}
Expand Down Expand Up @@ -183,7 +188,6 @@ function Tooltip({
style={{ ...style }}
tabIndex={-1}
>
{/* Progress bar */}
<div className="h-1 rounded-t-xl overflow-hidden bg-[var(--border)]">
<div
className="h-full bg-indigo-500 transition-all duration-300"
Expand All @@ -192,12 +196,12 @@ function Tooltip({
</div>

<div className="p-5">
{/* Step counter */}
<p className="text-xs font-semibold tracking-widest uppercase text-indigo-500 mb-1">
Step {stepIndex + 1} of {totalSteps}
</p>

<h2 className="text-base font-semibold mb-1">{step.title}</h2>

<p className="text-sm text-[var(--muted)] leading-relaxed mb-4">
{step.description}
</p>
Expand All @@ -209,6 +213,7 @@ function Tooltip({
>
Skip tour
</button>

<button
onClick={onNext}
ref={(el) => {
Expand All @@ -231,67 +236,86 @@ export default function OnboardingTour() {
const [stepIndex, setStepIndex] = useState(0);
const [visible, setVisible] = useState(false);
const [targetRect, setTargetRect] = useState<Rect | null>(null);

const tooltipRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);

const currentStep = TOUR_STEPS[stepIndex];

const dismiss = useCallback(() => {
localStorage.setItem(TOUR_KEY, "1");
setVisible(false);
}, []);

const measureTarget = useCallback((id: string): Promise<Rect | null> => {
return new Promise((resolve) => {
const attempt = (tries: number) => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
const r = el.getBoundingClientRect();
resolve({
top: r.top,
left: r.left,
width: r.width,
height: r.height,
const measureTarget = useCallback(
(id: string): Promise<Rect | null> => {
return new Promise((resolve) => {
const attempt = (tries: number) => {
const el = document.getElementById(id);

if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 400); // wait for scroll to finish
return;
}
if (tries <= 0) {
resolve(null);
return;
}
setTimeout(() => attempt(tries - 1), 300);
};
attempt(5);
});
}, []);

// Initialise on mount
setTimeout(() => {
const r = el.getBoundingClientRect();

resolve({
top: r.top,
left: r.left,
width: r.width,
height: r.height,
});
}, 400);

return;
}

if (tries <= 0) {
resolve(null);
return;
}

setTimeout(() => attempt(tries - 1), 300);
};

attempt(5);
});
},
[],
);

useEffect(() => {
if (localStorage.getItem(TOUR_KEY)) return;

const t = setTimeout(async () => {
const rect = await measureTarget(TOUR_STEPS[0]?.targetId ?? "");
const rect = await measureTarget(
TOUR_STEPS[0]?.targetId ?? "",
);

if (rect) {
setTargetRect(rect);
setVisible(true);
}
}, 600);

return () => clearTimeout(t);
}, [measureTarget]);

// Measure target whenever step changes (skip on first render — init effect handles that)
useEffect(() => {
if (!visible) return;

if (isFirstRender.current) {
isFirstRender.current = false;
return;
}

if (!currentStep) {
dismiss();
return;
}

let retryCount = 0;
const maxRetries = 10; // Retry up to ~5s with 500ms delays
let retryTimer: number | null = null;
Expand All @@ -301,6 +325,7 @@ export default function OnboardingTour() {
measureTarget(currentStep.targetId)
.then((rect) => {
if (cancelled) return;

if (rect) {
setTargetRect(rect);
setTimeout(() => tooltipRef.current?.focus(), 50);
Expand All @@ -310,8 +335,11 @@ export default function OnboardingTour() {
retryTimer = window.setTimeout(tryMeasure, 500);
} else {
// If we've retried enough, fallback to advancing or dismissing
if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1);
else dismiss();
if (stepIndex < TOUR_STEPS.length - 1) {
setStepIndex((i) => i + 1);
} else {
dismiss();
}
}
})
.catch((error) => {
Expand All @@ -326,67 +354,94 @@ export default function OnboardingTour() {
cancelled = true;
if (retryTimer !== null) clearTimeout(retryTimer);
};
}, [stepIndex, visible, measureTarget, dismiss, currentStep]);
}, [
stepIndex,
visible,
measureTarget,
dismiss,
currentStep,
]);

// Re-measure on resize or scroll so spotlight stays anchored to target.
// requestAnimationFrame prevents layout thrashing on rapid scroll/resize events.
useEffect(() => {
if (!visible) return;
let rafId: number;
const remeasure = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
measureTarget(TOUR_STEPS[stepIndex]?.targetId ?? "").then(setTargetRect);
});

const onResize = () => {
measureTarget(
TOUR_STEPS[stepIndex]?.targetId ?? "",
).then(setTargetRect);
};
window.addEventListener("resize", remeasure);
window.addEventListener("scroll", remeasure, true);

window.addEventListener("resize", onResize);

return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", remeasure);
window.removeEventListener("scroll", remeasure, true);
window.removeEventListener("resize", onResize);
};
}, [visible, stepIndex, measureTarget]);

// Keyboard support
useEffect(() => {
if (!visible) return;

const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
if (e.key === "Escape") {
dismiss();
}

if (e.key === "ArrowRight" || e.key === "Enter") {
if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1);
else dismiss();
if (stepIndex < TOUR_STEPS.length - 1) {
setStepIndex((i) => i + 1);
} else {
dismiss();
}
}
};

window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);

return () => {
window.removeEventListener("keydown", onKey);
};
}, [visible, stepIndex, dismiss]);

if (!visible || !targetRect || !currentStep) return null;
if (!visible || !targetRect || !currentStep) {
return null;
}

return createPortal(
<>
{/* Clickable backdrop to skip */}
<div
className="fixed inset-0"
style={{ zIndex: 9997 }}
aria-hidden="true"
onClick={dismiss}
/>
<Spotlight rect={targetRect} />
<Tooltip
step={currentStep}
stepIndex={stepIndex}
totalSteps={TOUR_STEPS.length}
rect={targetRect}
onNext={() => {
if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1);
else dismiss();
}}
onSkip={dismiss}
tooltipRef={tooltipRef}
/>
</>,
<FocusTrap
active={visible}
focusTrapOptions={{
escapeDeactivates: true,
clickOutsideDeactivates: false,
fallbackFocus: () => tooltipRef.current!,
}}
>
<div>
<div
className="fixed inset-0"
style={{ zIndex: 9997 }}
aria-hidden="true"
onClick={dismiss}
/>

<Spotlight rect={targetRect} />

<Tooltip
step={currentStep}
stepIndex={stepIndex}
totalSteps={TOUR_STEPS.length}
rect={targetRect}
onNext={() => {
if (stepIndex < TOUR_STEPS.length - 1) {
setStepIndex((i) => i + 1);
} else {
dismiss();
}
}}
onSkip={dismiss}
tooltipRef={tooltipRef}
/>
</div>
</FocusTrap>,
document.body,
);
}
}
Loading