From f1dfb3525aa8a5240369086995fb852d4549f64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luke=20Schr=C3=B6ter?= Date: Mon, 20 Apr 2026 21:26:18 +0200 Subject: [PATCH 1/2] feat: add guided onboarding tour using driver.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OnboardingTour component with 13 steps across all three tabs - Tour auto-starts on first launch (onboardingCompleted flag in settings) - Manual restart available via 'Start Tour' button in Settings - Smooth tab navigation between Simple → Advanced → Settings - Custom CSS theme matching app dark/light mode via CSS variables - data-tour attributes added to key UI elements for precise targeting Made-with: Cursor --- src/App.tsx | 22 ++ src/components/OnboardingTour.css | 130 ++++++++++ src/components/OnboardingTour.tsx | 232 ++++++++++++++++++ src/components/panels/SettingsPanel.tsx | 24 +- src/components/panels/SimplePanel.tsx | 10 +- .../panels/advanced/AdvancedPanel.tsx | 20 +- src/settingsSchema.ts | 6 + 7 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 src/components/OnboardingTour.css create mode 100644 src/components/OnboardingTour.tsx diff --git a/src/App.tsx b/src/App.tsx index 70d377f..f180811 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ const AdvancedPanel = lazy( ); const SettingsPanel = lazy(() => import("./components/panels/SettingsPanel")); const TitleBar = lazy(() => import("./components/TitleBar")); +const OnboardingTour = lazy(() => import("./components/OnboardingTour")); export type Tab = "simple" | "advanced" | "settings"; const BACKEND_SETTINGS_SCHEMA_VERSION = 8; @@ -125,6 +126,7 @@ export default function App() { currentVersion: string; latestVersion: string; } | null>(null); + const [tourActive, setTourActive] = useState(false); const hotkeyTimer = useRef(null); const hotkeyRequestIdRef = useRef(0); @@ -502,6 +504,10 @@ export default function App() { setStatus(loadedStatus); setSettingsLoaded(true); + if (!hydratedSettings.onboardingCompleted) { + setTourActive(true); + } + await syncSettingsToBackend(hydratedSettings); if ( @@ -695,6 +701,16 @@ export default function App() { } }; + const handleTourComplete = () => { + setTourActive(false); + updateSettings({ onboardingCompleted: true }); + }; + + const handleStartTour = () => { + setTourActive(false); + requestAnimationFrame(() => setTourActive(true)); + }; + const handlePickPosition = async () => { try { const point = await invoke<{ x: number; y: number }>("pick_position"); @@ -712,6 +728,11 @@ export default function App() { return (
+ )} diff --git a/src/components/OnboardingTour.css b/src/components/OnboardingTour.css new file mode 100644 index 0000000..efd25f3 --- /dev/null +++ b/src/components/OnboardingTour.css @@ -0,0 +1,130 @@ +/* ── Overlay ─────────────────────────────────────────────────────────────── */ +.driver-overlay { + z-index: 9998 !important; +} + +/* ── Popover container ───────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-family: var(--font-family-base); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.45), + 0 2px 6px rgba(0, 0, 0, 0.3); + max-width: 320px; + min-width: 260px; + padding: 1.25rem 1.25rem 1rem; + z-index: 9999 !important; +} + +/* ── Arrow ───────────────────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover.driver-popover-arrow-side-top::before { + border-top-color: var(--bg-elevated) !important; +} +.driver-popover.onboarding-popover.driver-popover-arrow-side-bottom::before { + border-bottom-color: var(--bg-elevated) !important; +} +.driver-popover.onboarding-popover.driver-popover-arrow-side-left::before { + border-left-color: var(--bg-elevated) !important; +} +.driver-popover.onboarding-popover.driver-popover-arrow-side-right::before { + border-right-color: var(--bg-elevated) !important; +} + +/* ── Title ───────────────────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover .driver-popover-title { + color: var(--text-primary); + font-size: 0.9375rem; + font-weight: var(--font-weight-hv); + margin-bottom: 0.5rem; + padding-right: 1.25rem; /* leave room for close btn */ +} + +/* ── Description ─────────────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover .driver-popover-description { + color: var(--text-muted); + font-size: 0.8125rem; + line-height: 1.55; + margin-bottom: 0; +} + +/* ── Close button ────────────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover .driver-popover-close-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: transparent; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 2px 4px; + border-radius: var(--radius-sm); + transition: color 0.15s ease; + z-index: 1; +} +.driver-popover.onboarding-popover .driver-popover-close-btn:hover { + color: var(--text-primary); +} + +/* ── Footer ──────────────────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover .driver-popover-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + +/* ── Progress text ───────────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover .driver-popover-progress-text { + color: var(--text-dim); + font-size: 0.75rem; + font-weight: var(--font-weight-md); + margin-right: auto; +} + +/* ── Shared button base ──────────────────────────────────────────────────── */ +.driver-popover.onboarding-popover .driver-popover-prev-btn, +.driver-popover.onboarding-popover .driver-popover-next-btn { + background: var(--bg-input); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.3125rem 0.875rem; + font-size: 0.8rem; + font-weight: var(--font-weight-md); + font-family: var(--font-family-base); + cursor: pointer; + line-height: 1.4; + transition: + background 0.15s ease, + border-color 0.15s ease, + filter 0.15s ease; + white-space: nowrap; +} + +/* ── Next / Done button: accent-colored ─────────────────────────────────── */ +.driver-popover.onboarding-popover .driver-popover-next-btn { + background: var(--accent-green-strong); + border-color: transparent; + color: #fff; +} + +.driver-popover.onboarding-popover .driver-popover-prev-btn:hover { + background: var(--bg-input-off); + border-color: var(--border-focus); +} +.driver-popover.onboarding-popover .driver-popover-next-btn:hover { + filter: brightness(1.12); +} + +/* ── Highlighted element ring ────────────────────────────────────────────── */ +.driver-active .driver-active-element { + outline: 2px solid var(--accent-green) !important; + outline-offset: 3px !important; + border-radius: var(--radius-sm) !important; +} diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 0000000..35bd8a4 --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,232 @@ +import "./OnboardingTour.css"; +import { driver, type DriveStep } from "driver.js"; +import "driver.js/dist/driver.css"; +import { useEffect, useRef } from "react"; +import type { Tab } from "../App"; + +interface Props { + active: boolean; + onComplete: () => void; + setTab: (tab: Tab) => void; +} + +type TourTab = Tab; + +const STEP_TABS: TourTab[] = [ + "simple", // 0 Welcome + "simple", // 1 Simple panel overview + "simple", // 2 Click speed + "simple", // 3 Hotkey + "simple", // 4 Quick controls ← last simple step + "advanced", // 5 Advanced cadence ← first advanced step + "advanced", // 6 Duty cycle + "advanced", // 7 Speed variation + "advanced", // 8 Limits ← last advanced step + "settings", // 9 Settings overview ← first settings step + "settings", // 10 Theme & Accent + "settings", // 11 Presets + "settings", // 12 Restart tour button (final) +]; + +const LAST_SIMPLE_IDX = 4; +const FIRST_ADV_IDX = 5; +const LAST_ADV_IDX = 8; +const FIRST_SETTINGS_IDX = 9; + +export default function OnboardingTour({ active, onComplete, setTab }: Props) { + const driverRef = useRef | null>(null); + + const startTour = (startIndex = 0) => { + driverRef.current?.destroy(); + + const targetTab = STEP_TABS[startIndex] ?? "simple"; + setTab(targetTab); + + const steps: DriveStep[] = [ + // ─── Simple Tab ────────────────────────────────────────────────────── + { + popover: { + title: "Welcome to Blur AutoClicker!", + description: + "Let me walk you through the main features in about a minute. You can skip at any time by pressing Escape or clicking ✕.", + showButtons: ["next", "close"], + }, + }, + { + element: '[data-tour="simple-panel"]', + popover: { + title: "Simple Mode", + description: + "The Simple tab gives you instant access to all core controls — compact and efficient for everyday use.", + side: "bottom", + align: "center", + }, + }, + { + element: '[data-tour="simple-cadence"]', + popover: { + title: "Click Speed", + description: + "Set how many clicks per second (or per minute / hour / day). Scroll the number or type directly — it updates in real time.", + side: "bottom", + align: "center", + }, + }, + { + element: '[data-tour="simple-hotkey"]', + popover: { + title: "Hotkey", + description: + "Click here and press any key combo to assign a toggle shortcut — so you can start and stop clicking without touching the mouse.", + side: "bottom", + align: "center", + }, + }, + { + element: '[data-tour="simple-controls"]', + popover: { + title: "Quick Controls", + description: + "Left-click to cycle forward, right-click to cycle backward. Switch the active mouse button (Left / Middle / Right) and click mode (Toggle / Hold).", + side: "top", + align: "center", + onNextClick: () => { + setTab("advanced"); + setTimeout(() => driverRef.current?.moveTo(FIRST_ADV_IDX), 220); + }, + }, + }, + + // ─── Advanced Tab ───────────────────────────────────────────────────── + { + element: '[data-tour="adv-cadence"]', + popover: { + title: "Advanced Click Speed", + description: + "Full cadence control: choose rate mode (clicks / s) or duration mode, and see the exact millisecond interval between clicks.", + side: "right", + align: "start", + onPrevClick: () => { + setTab("simple"); + setTimeout(() => driverRef.current?.moveTo(LAST_SIMPLE_IDX), 220); + }, + }, + }, + { + element: '[data-tour="adv-dutycycle"]', + popover: { + title: "Duty Cycle", + description: + "Controls how long the mouse button is physically held down per click. 100 % = always held, 10 % = brief tap. Great for games that distinguish press vs. hold.", + side: "right", + align: "start", + }, + }, + { + element: '[data-tour="adv-speed"]', + popover: { + title: "Speed Variation", + description: + "Adds random jitter to the click interval to mimic human behavior and avoid detection patterns in competitive games.", + side: "right", + align: "start", + }, + }, + { + element: '[data-tour="adv-limits"]', + popover: { + title: "Click & Time Limits", + description: + "Set a maximum number of clicks or a time limit. The clicker stops automatically when reached — no need to babysit it.", + side: "left", + align: "start", + onNextClick: () => { + setTab("settings"); + setTimeout(() => driverRef.current?.moveTo(FIRST_SETTINGS_IDX), 220); + }, + }, + }, + + // ─── Settings Tab ────────────────────────────────────────────────────── + { + element: '[data-tour="settings-panel"]', + popover: { + title: "Settings", + description: + "Customize theme, language, hotkey behavior, window options, and more. Everything persists across restarts automatically.", + side: "bottom", + align: "center", + onPrevClick: () => { + setTab("advanced"); + setTimeout(() => driverRef.current?.moveTo(LAST_ADV_IDX), 220); + }, + }, + }, + { + element: '[data-tour="settings-theme"]', + popover: { + title: "Theme & Accent", + description: + "Switch between Dark and Light mode, then pick any accent color from the color picker to personalize the UI.", + side: "top", + align: "end", + }, + }, + { + element: '[data-tour="settings-presets"]', + popover: { + title: "Presets", + description: + "Save your current configuration as a named preset and switch between them instantly. Perfect for different games or tasks.", + side: "top", + align: "end", + }, + }, + { + element: '[data-tour="settings-tour-btn"]', + popover: { + title: "You're all set!", + description: + "You can restart this tour anytime by clicking the button below. Enjoy Blur AutoClicker!", + showButtons: ["previous", "close"], + side: "top", + align: "end", + }, + }, + ]; + + const driverInstance = driver({ + showProgress: true, + progressText: "{{current}} / {{total}}", + animate: true, + overlayColor: "rgba(0, 0, 0, 0.55)", + popoverClass: "onboarding-popover", + nextBtnText: "Next →", + prevBtnText: "← Back", + doneBtnText: "Done", + steps, + onDestroyStarted: () => { + driverInstance.destroy(); + onComplete(); + }, + }); + + driverRef.current = driverInstance; + driverInstance.drive(startIndex); + }; + + useEffect(() => { + if (!active) return; + + const timeout = setTimeout(() => startTour(0), 650); + return () => { + clearTimeout(timeout); + driverRef.current?.destroy(); + driverRef.current = null; + }; + // startTour captures setTab/onComplete via closure — intentionally not in deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); + + return null; +} diff --git a/src/components/panels/SettingsPanel.tsx b/src/components/panels/SettingsPanel.tsx index 84edc24..d60385c 100644 --- a/src/components/panels/SettingsPanel.tsx +++ b/src/components/panels/SettingsPanel.tsx @@ -41,6 +41,7 @@ interface Props { onDeletePreset: (presetId: PresetId) => boolean; onToggleAlwaysOnTop: () => Promise; onReset: () => Promise; + onStartTour: () => void; } function formatTime(totalSeconds: number, language: Language): string { @@ -229,6 +230,7 @@ export default function SettingsPanel({ onDeletePreset, onToggleAlwaysOnTop, onReset, + onStartTour, }: Props) { const [resetting, setResetting] = useState(false); const [resettingStats, setResettingStats] = useState(false); @@ -367,7 +369,7 @@ export default function SettingsPanel({ }; return ( -
+
{t("settings.supportMe")} @@ -672,7 +674,7 @@ export default function SettingsPanel({
-
+
{t("settings.theme")} @@ -722,7 +724,7 @@ export default function SettingsPanel({
-
+
{t("settings.presets")} @@ -808,6 +810,22 @@ export default function SettingsPanel({
+
+
+ Guided Tour + + Restart the interactive walkthrough of all features. + +
+ +
+
{t("settings.resetAll")} diff --git a/src/components/panels/SimplePanel.tsx b/src/components/panels/SimplePanel.tsx index d43ed1d..b3bc6ad 100644 --- a/src/components/panels/SimplePanel.tsx +++ b/src/components/panels/SimplePanel.tsx @@ -70,11 +70,13 @@ export default function SimplePanel({ settings, update }: SimplePanelProps) { }; return ( -
+
- +
+ +
-
+
-
+