Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Splitter } from './components/layout/Splitter';
import { ResizeBorder } from './components/layout/ResizeBorder';
import { WindowControls } from './components/layout/WindowControls';
import { OnboardingBanner } from './components/layout/OnboardingBanner';
import { WelcomeWizard } from './components/layout/WelcomeWizard';
import { AccessibilityBanner } from './components/layout/AccessibilityBanner';
import { CapturePausedBanner } from './components/layout/CapturePausedBanner';
import { HistoryList } from './components/history/HistoryList';
Expand Down Expand Up @@ -50,6 +51,7 @@ function App() {
const { editorOpen: snippetEditorOpen } = useSnippetStore();
const showSidePanel = activeTab !== 'settings' && (previewOpen || snippetEditorOpen);
const { hasSeenOptIn, loadSettings: loadFeedbackSettings } = useFeedbackStore();
const welcomeSeen = useSettingsStore((s) => s.settings.welcomeSeen);
const [isInitialized, setIsInitialized] = useState(false);
const [showOptIn, setShowOptIn] = useState(false);
// Restore the divider position the user dragged to last time.
Expand Down Expand Up @@ -120,14 +122,15 @@ function App() {
return () => window.removeEventListener('focus', onFocus);
}, []);

// Show opt-in dialog on first run
// Show opt-in dialog on first run — but only after the welcome wizard is done,
// so the two full-screen surfaces never stack on top of each other.
useEffect(() => {
if (isInitialized && !hasSeenOptIn) {
if (isInitialized && welcomeSeen && !hasSeenOptIn) {
// Small delay to let the UI settle on first launch
const timer = setTimeout(() => setShowOptIn(true), 500);
return () => clearTimeout(timer);
}
}, [isInitialized, hasSeenOptIn]);
}, [isInitialized, welcomeSeen, hasSeenOptIn]);

// Listen for tray icon "Show" event from Rust
useEffect(() => {
Expand Down Expand Up @@ -308,6 +311,11 @@ function App() {
</div>

<ErrorReportingOptIn isOpen={showOptIn} onClose={() => setShowOptIn(false)} />

{/* First-run welcome. Sits above everything and gates the opt-in dialog. */}
<AnimatePresence>
{isInitialized && !welcomeSeen && <WelcomeWizard />}
</AnimatePresence>
</ErrorBoundary>
);
}
Expand Down
130 changes: 130 additions & 0 deletions src/components/layout/WelcomeWizard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';

// jsdom never "finishes" framer-motion's exit animation, so AnimatePresence
// mode="wait" would keep the next step unmounted. Render motion elements plainly
// and let AnimatePresence pass its children straight through.
vi.mock('framer-motion', async () => {
const React = await import('react');
const passthrough =
(Tag: string) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ children, initial, animate, exit, transition, whileTap, whileHover, layout, ...rest }: any) =>
React.createElement(Tag, rest, children);
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
AnimatePresence: ({ children }: any) => React.createElement(React.Fragment, null, children),
motion: new Proxy({}, { get: (_t, tag: string) => passthrough(tag) }),
};
});
import i18next from 'i18next';
import { initReactI18next, I18nextProvider } from 'react-i18next';
import en from '@/i18n/locales/en.json';
import { useSettingsStore } from '@/stores/settingsStore';
import {
WelcomeWizard,
welcomeFinishPatch,
clampStep,
WELCOME_STEPS,
} from './WelcomeWizard';

// A self-contained i18n instance so tests don't depend on app bootstrap.
const testI18n = i18next.createInstance();
testI18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
resources: { en: { translation: en } },
interpolation: { escapeValue: false },
});

function renderWizard() {
return render(
<I18nextProvider i18n={testI18n}>
<WelcomeWizard />
</I18nextProvider>
);
}

afterEach(cleanup);

describe('welcomeFinishPatch', () => {
it('always retires the welcome and inline onboarding', () => {
expect(welcomeFinishPatch(true)).toMatchObject({
welcomeSeen: true,
onboardingSeen: true,
});
expect(welcomeFinishPatch(false)).toMatchObject({
welcomeSeen: true,
onboardingSeen: true,
});
});

it('carries the autostart choice through', () => {
expect(welcomeFinishPatch(true).launchOnLogin).toBe(true);
expect(welcomeFinishPatch(false).launchOnLogin).toBe(false);
});
});

describe('clampStep', () => {
it('clamps below zero to the first step', () => {
expect(clampStep(-3)).toBe(0);
});
it('clamps past the end to the last step', () => {
expect(clampStep(99)).toBe(WELCOME_STEPS - 1);
});
it('passes valid indices through', () => {
expect(clampStep(1)).toBe(1);
});
});

describe('WelcomeWizard', () => {
beforeEach(() => {
// A resolved stub so finishing never touches the Tauri store in tests.
useSettingsStore.setState({ updateSettings: vi.fn().mockResolvedValue(undefined) });
});

it('opens on the first step', () => {
renderWizard();
expect(screen.getByText(en['welcome.step1.title'])).toBeInTheDocument();
});

it('advances through every step to the final CTA', () => {
renderWizard();
fireEvent.click(screen.getByText(en['welcome.next']));
expect(screen.getByText(en['welcome.step2.title'])).toBeInTheDocument();
fireEvent.click(screen.getByText(en['welcome.next']));
expect(screen.getByText(en['welcome.step3.title'])).toBeInTheDocument();
// Last step swaps "Next" for the finish CTA.
expect(screen.getByText(en['welcome.getStarted'])).toBeInTheDocument();
expect(screen.queryByText(en['welcome.next'])).not.toBeInTheDocument();
});

it('persists the finish patch with autostart on by default', () => {
const patch = vi.fn().mockResolvedValue(undefined);
useSettingsStore.setState({ updateSettings: patch });
renderWizard();
fireEvent.click(screen.getByText(en['welcome.next']));
fireEvent.click(screen.getByText(en['welcome.next']));
fireEvent.click(screen.getByText(en['welcome.getStarted']));
expect(patch).toHaveBeenCalledWith(welcomeFinishPatch(true));
});

it('lets the user turn autostart off before finishing', () => {
const patch = vi.fn().mockResolvedValue(undefined);
useSettingsStore.setState({ updateSettings: patch });
renderWizard();
fireEvent.click(screen.getByText(en['welcome.next']));
fireEvent.click(screen.getByText(en['welcome.next']));
fireEvent.click(screen.getByText(en['welcome.step3.autostart']));
fireEvent.click(screen.getByText(en['welcome.getStarted']));
expect(patch).toHaveBeenCalledWith(welcomeFinishPatch(false));
});

it('skips straight to finishing from any step', () => {
const patch = vi.fn().mockResolvedValue(undefined);
useSettingsStore.setState({ updateSettings: patch });
renderWizard();
fireEvent.click(screen.getByText(en['welcome.skip']));
expect(patch).toHaveBeenCalledWith(welcomeFinishPatch(true));
});
});
196 changes: 196 additions & 0 deletions src/components/layout/WelcomeWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Clipboard,
Command,
Rocket,
ChevronRight,
ChevronLeft,
CornerDownLeft,
ArrowUpDown,
Check,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useSettingsStore, type AppSettings } from '@/stores/settingsStore';
import { formatShortcut } from '@/lib/formatShortcut';
import { cn } from '@/lib/utils';

export const WELCOME_STEPS = 3;

/** The settings written when the wizard finishes (completed or skipped). Pure so
* the persisted outcome can be unit-tested without rendering. */
export function welcomeFinishPatch(autostart: boolean): Partial<AppSettings> {
return {
welcomeSeen: true,
// The wizard already teaches the hints, so the inline banner is redundant.
onboardingSeen: true,
launchOnLogin: autostart,
};
}

/** Clamp a step index into the valid [0, WELCOME_STEPS - 1] range. */
export function clampStep(step: number): number {
return Math.max(0, Math.min(WELCOME_STEPS - 1, step));
}

export function WelcomeWizard() {
const { t } = useTranslation();
const settings = useSettingsStore((s) => s.settings);
const updateSettings = useSettingsStore((s) => s.updateSettings);
const [step, setStep] = useState(0);
const [autostart, setAutostart] = useState(true);

const shortcut = formatShortcut(settings.globalShortcut);
const isLast = step === WELCOME_STEPS - 1;

const finish = () => {
void updateSettings(welcomeFinishPatch(autostart));
};

const next = () => {
if (isLast) finish();
else setStep((s) => clampStep(s + 1));
};
const back = () => setStep((s) => clampStep(s - 1));

return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] flex flex-col bg-surface rounded-lg overflow-hidden"
>
{/* Skip */}
<div className="flex justify-end p-3 shrink-0">
<button
onClick={finish}
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer px-2 py-1 rounded"
>
{t('welcome.skip')}
</button>
</div>

{/* Body */}
<div className="flex-1 flex flex-col items-center justify-center px-8 text-center">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="flex flex-col items-center gap-4 max-w-xs"
>
{step === 0 && (
<>
<IconBubble icon={Clipboard} />
<h1 className="text-lg font-semibold">{t('welcome.step1.title')}</h1>
<p className="text-sm text-muted-foreground">{t('welcome.step1.body')}</p>
</>
)}
{step === 1 && (
<>
<IconBubble icon={Command} />
<h1 className="text-lg font-semibold">{t('welcome.step2.title')}</h1>
<p className="text-sm text-muted-foreground">{t('welcome.step2.body')}</p>
{shortcut && (
<div className="flex flex-col items-center gap-1 mt-1">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t('welcome.summonLabel')}
</span>
<kbd className="inline-flex items-center px-3 py-1.5 bg-background border border-border rounded-lg text-sm font-mono font-medium">
{shortcut}
</kbd>
</div>
)}
<div className="flex flex-col gap-1.5 mt-2">
<MiniHint icon={CornerDownLeft} text={t('welcome.step2.enterHint')} />
<MiniHint icon={ArrowUpDown} text={t('welcome.step2.arrowHint')} />
</div>
</>
)}
{step === 2 && (
<>
<IconBubble icon={Rocket} />
<h1 className="text-lg font-semibold">{t('welcome.step3.title')}</h1>
<p className="text-sm text-muted-foreground">{t('welcome.step3.body')}</p>
<button
onClick={() => setAutostart((v) => !v)}
aria-pressed={autostart}
className={cn(
'flex items-center gap-2 mt-1 px-3 py-2 rounded-lg border transition-colors cursor-pointer text-sm',
autostart
? 'border-accent/60 bg-accent/10 text-foreground'
: 'border-border bg-background text-muted-foreground'
)}
>
<span
className={cn(
'flex items-center justify-center w-4 h-4 rounded border shrink-0',
autostart ? 'bg-accent border-accent text-white' : 'border-border'
)}
>
{autostart && <Check className="w-3 h-3" />}
</span>
{t('welcome.step3.autostart')}
</button>
</>
)}
</motion.div>
</AnimatePresence>
</div>

{/* Footer */}
<div className="flex items-center justify-between p-4 shrink-0">
<button
onClick={back}
className={cn(
'flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer px-2 py-1 rounded',
step === 0 && 'invisible'
)}
>
<ChevronLeft className="w-4 h-4 rtl:rotate-180" />
{t('welcome.back')}
</button>

{/* Progress dots */}
<div className="flex items-center gap-1.5">
{Array.from({ length: WELCOME_STEPS }).map((_, i) => (
<span
key={i}
className={cn(
'rounded-full transition-all',
i === step ? 'w-4 h-1.5 bg-accent' : 'w-1.5 h-1.5 bg-border'
)}
/>
))}
</div>

<button
onClick={next}
className="flex items-center gap-1 text-sm font-medium bg-accent text-white px-4 py-1.5 rounded-lg hover:bg-accent/90 transition-colors cursor-pointer"
>
{isLast ? t('welcome.getStarted') : t('welcome.next')}
{!isLast && <ChevronRight className="w-4 h-4 rtl:rotate-180" />}
</button>
</div>
</motion.div>
);
}

function IconBubble({ icon: Icon }: { icon: React.ElementType }) {
return (
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-accent/10 text-accent">
<Icon className="w-7 h-7" />
</div>
);
}

function MiniHint({ icon: Icon, text }: { icon: React.ElementType; text: string }) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Icon className="w-3.5 h-3.5 text-accent shrink-0" />
<span>{text}</span>
</div>
);
}
14 changes: 14 additions & 0 deletions src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,20 @@
"onboarding.hint.enterToPaste": "Enter للصق في التطبيق السابق",
"onboarding.hint.arrowKeys": "مفاتيح الأسهم للتنقل",
"onboarding.hint.optionD": "Option+D لمقارنة عنصرين",
"welcome.skip": "تخطٍّ",
"welcome.next": "التالي",
"welcome.back": "رجوع",
"welcome.getStarted": "ابدأ",
"welcome.summonLabel": "اختصارك",
"welcome.step1.title": "مرحبًا بك في QlipLab",
"welcome.step1.body": "انسخ أي شيء — نصوصًا وأكوادًا وروابط وصورًا. كله يظهر هنا جاهزًا للصق.",
"welcome.step2.title": "افتحه من أي مكان",
"welcome.step2.body": "استدعِ QlipLab باختصارك مهما كان التطبيق الذي تستخدمه.",
"welcome.step2.enterHint": "اضغط Enter للصق في التطبيق الذي أتيت منه",
"welcome.step2.arrowHint": "استخدم مفاتيح الأسهم لاختيار عنصر",
"welcome.step3.title": "كل شيء جاهز",
"welcome.step3.body": "ينتظر QlipLab بهدوء في الخلفية — افتحه في أي وقت باختصارك.",
"welcome.step3.autostart": "تشغيل QlipLab عند تسجيل الدخول",
"feedback.title": "الإبلاغ عن مشكلة",
"feedback.issueType": "نوع المشكلة",
"feedback.type.bug": "خلل",
Expand Down
Loading
Loading