diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts new file mode 100644 index 0000000..644a640 --- /dev/null +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -0,0 +1,62 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { _electron as electron, expect, test } from "@playwright/test"; +import { maximizeWindow } from "./helpers"; + +test("a first run explains setup and points out the workspace", async () => { + const app = await electron.launch({ + args: [join(__dirname, "../out/main/index.js")], + env: { + ...process.env, + LUCID_HOME: mkdtempSync(join(tmpdir(), "lucid-home-")), + LUCID_PDF_WARMUP: "0", + LUCID_SKIP_ONBOARDING: "0", + }, + }); + + try { + const window = await app.firstWindow(); + await maximizeWindow(app, window); + await window.evaluate(() => { + localStorage.removeItem("lucid.onboarding"); + }); + await window.reload(); + + await expect( + window.getByRole("heading", { name: "Speak or type the messy version." }), + ).toBeVisible(); + await window.getByRole("button", { name: "Set up Lucid" }).click(); + + await expect(window.getByRole("heading", { name: "Connect an AI provider" })).toBeVisible(); + await expect(window.getByText("LM Studio or Ollama", { exact: false })).toBeVisible(); + await window.getByRole("button", { name: "Continue" }).click(); + + await expect( + window.getByRole("heading", { name: "Everything happens around one page" }), + ).toBeVisible(); + await window.getByRole("button", { name: "Open my workspace" }).click(); + + const tour = window.getByRole("dialog", { name: "Lucid workspace tour" }); + await expect(tour.getByRole("heading", { name: "Your pages live here" })).toBeVisible(); + await tour.getByRole("button", { name: "Next" }).click(); + await expect(tour.getByRole("heading", { name: "Change how Lucid listens" })).toBeVisible(); + await expect + .poll(async () => { + const card = await window.locator(".tour-card").boundingBox(); + const modes = await window.locator('[data-tour="modes"]').boundingBox(); + return card && modes ? card.y + card.height <= modes.y : false; + }) + .toBe(true); + await tour.getByRole("button", { name: "Next" }).click(); + await expect(tour.getByRole("heading", { name: "Take the result with you" })).toBeVisible(); + await tour.getByRole("button", { name: "Done" }).click(); + + await expect(tour).toBeHidden(); + await expect + .poll(() => window.evaluate(() => localStorage.getItem("lucid.onboarding"))) + .toBe("1"); + } finally { + await app.close(); + } +}); diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index c2945d0..ca5f207 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -182,6 +182,7 @@ export function registerIpc(): void { })), }, llmUrlLocked: process.env["LUCID_LLM_URL"] !== undefined, + onboardingDisabled: process.env["LUCID_SKIP_ONBOARDING"] === "1", }); ipcMain.handle(IPC.settingsGet, async () => publicSettings(await loadSettings(libraryDir()))); ipcMain.handle(IPC.settingsSave, async (_e, input: unknown): Promise => { diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts index dbea99c..8cce33c 100644 --- a/apps/desktop/electron/ipc/types.ts +++ b/apps/desktop/electron/ipc/types.ts @@ -122,8 +122,10 @@ export interface PublicSettings { ai: AiSettings; /** The address comes from the LUCID_LLM_URL environment variable (tests, development) and is not changed here. */ llmUrlLocked: boolean; + /** Test harnesses can suppress first-run UI without changing a person's saved preference. */ + onboardingDisabled: boolean; } -export type SettingsInput = Omit; +export type SettingsInput = Omit; export type SettingsSaveResult = { ok: true; settings: PublicSettings } | { ok: false; problems: string[] }; diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index cd413d2..aa84291 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "@playwright/test"; // The PDF engine prepares itself in the background on a real start (it downloads files); a test must not wait for that. process.env["LUCID_PDF_WARMUP"] = "0"; +// Most specs exercise the workspace directly. The onboarding flow has focused component coverage. +process.env["LUCID_SKIP_ONBOARDING"] = "1"; export default defineConfig({ testDir: "./e2e", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f591d6d..e824c27 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,6 +8,9 @@ import { ImportNotice } from "./pages/ImportNotice"; import { Sidebar } from "./pages/Sidebar"; import { Workspace } from "./pages/Workspace"; import { usePages } from "./pages/usePages"; +import { FirstRun } from "./onboarding/FirstRun"; +import { ProductTour } from "./onboarding/ProductTour"; +import { completeOnboarding, onboardingComplete } from "./onboarding/state"; export function App() { if (typeof window.lucid === "undefined") { @@ -32,6 +35,8 @@ function Shell() { }, []); const [settings, setSettings] = useState(); const [settingsOpen, setSettingsOpen] = useState(false); + const [onboardingOpen, setOnboardingOpen] = useState(false); + const [tourOpen, setTourOpen] = useState(false); const [newPageOpen, setNewPageOpen] = useState(false); const [imported, setImported] = useState(); @@ -68,7 +73,10 @@ function Shell() { }); }; useEffect(() => { - void loadSettings(); + void loadSettings().then((loaded) => { + setSettings(loaded); + if (!loaded.onboardingDisabled && !onboardingComplete()) setOnboardingOpen(true); + }); // ⌘, opens Settings, as in most Mac apps. const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === ",") { @@ -98,6 +106,17 @@ function Shell() { return !open; }); }; + const finishOnboarding = (startTour: boolean) => { + completeOnboarding(); + setOnboardingOpen(false); + if (!startTour) return; + void (async () => { + if (!pages.current) await pages.create(); + requestAnimationFrame(() => { + setTourOpen(true); + }); + })(); + }; return (
{sidebarOpen && ( @@ -184,6 +203,20 @@ function Shell() { onClose={() => { setSettingsOpen(false); }} + onOpenOnboarding={() => { + setSettingsOpen(false); + setOnboardingOpen(true); + }} + /> + )} + {onboardingOpen && settings && ( + + )} + {tourOpen && ( + { + setTourOpen(false); + }} /> )}
diff --git a/apps/desktop/src/dictation/DictationBar.tsx b/apps/desktop/src/dictation/DictationBar.tsx index 9bfc14a..1e4f9e4 100644 --- a/apps/desktop/src/dictation/DictationBar.tsx +++ b/apps/desktop/src/dictation/DictationBar.tsx @@ -354,7 +354,7 @@ export function DictationBar({ )}
-
+
{(["input", "command", "edit"] as const).map((m) => ( + + +
+ {step === 0 && ( +
+ + )} + + {step === 1 && ( +
+

Choose where Lucid thinks

+

+ Connect an AI provider +

+

+ For LM Studio or Ollama, keep the local server running while you use Lucid. For a + cloud provider, add its API key below; keys use the system’s secure credential + store. +

+ +

+ You can continue without AI. Notes, manual editing, import and export still work. +

+
+ )} + + {step === 2 && ( +
+

Three places to remember

+

+ Everything happens around one page +

+ +
+
+
Input
+
Capture rough thoughts as structured notes.
+
+
+
Command
+
Ask for a precise change to the page.
+
+
+
Diagram and table
+
Build visual structure step by step.
+
+
+

+ Next, Lucid will point out pages, modes and export in the real workspace. +

+
+ )} + + {problem && ( +

+ {problem} +

+ )} +
+ {step > 0 && ( + + )} + + {step < STEP_NAMES.length - 1 ? ( + + ) : ( + + )} +
+
+ +
+ ); +} diff --git a/apps/desktop/src/onboarding/ProductTour.tsx b/apps/desktop/src/onboarding/ProductTour.tsx new file mode 100644 index 0000000..7e7b722 --- /dev/null +++ b/apps/desktop/src/onboarding/ProductTour.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; + +interface Props { + onFinish: () => void; +} + +const STEPS = [ + { + selector: '[data-tour="pages"]', + title: "Your pages live here", + text: "Create, import and switch between pages from this list.", + }, + { + selector: '[data-tour="modes"]', + title: "Change how Lucid listens", + text: "Input captures ideas. Command changes the page. Diagram and Table build things with you.", + }, + { + selector: '[data-tour="export"]', + title: "Take the result with you", + text: "Export the editable Lucid note, Markdown or a finished PDF.", + }, +] as const; + +const CARD_HEIGHT = 210; +const CARD_GAP = 14; +const VIEWPORT_MARGIN = 20; + +export function ProductTour({ onFinish }: Props) { + const [step, setStep] = useState(0); + const [rect, setRect] = useState(); + const current = STEPS[step] ?? STEPS[0]; + + useEffect(() => { + let frame = 0; + let attempts = 0; + const locate = () => { + const target = document.querySelector(current.selector); + if (!target && attempts++ < 30) { + frame = requestAnimationFrame(locate); + return; + } + if (!target) return; + target.scrollIntoView({ block: "nearest" }); + setRect(target.getBoundingClientRect()); + }; + locate(); + const update = () => { + locate(); + }; + window.addEventListener("resize", update); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("resize", update); + }; + }, [current]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onFinish(); + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + }; + }, [onFinish]); + + const next = () => { + setRect(undefined); + if (step === STEPS.length - 1) onFinish(); + else setStep((value) => value + 1); + }; + const left = rect + ? Math.max(VIEWPORT_MARGIN, Math.min(rect.left, window.innerWidth - 340)) + : VIEWPORT_MARGIN; + const top = (() => { + if (!rect) return VIEWPORT_MARGIN; + if (rect.bottom + CARD_GAP + CARD_HEIGHT <= window.innerHeight - VIEWPORT_MARGIN) { + return rect.bottom + CARD_GAP; + } + if (rect.top - CARD_GAP - CARD_HEIGHT >= VIEWPORT_MARGIN) { + return rect.top - CARD_GAP - CARD_HEIGHT; + } + return Math.max( + VIEWPORT_MARGIN, + Math.min(rect.bottom + CARD_GAP, window.innerHeight - CARD_HEIGHT - VIEWPORT_MARGIN), + ); + })(); + + return ( +
+ {rect && ( +
+ )} +
+ + {step + 1} of {STEPS.length} + +

{current.title}

+

{current.text}

+
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/onboarding/state.test.ts b/apps/desktop/src/onboarding/state.test.ts new file mode 100644 index 0000000..02291fc --- /dev/null +++ b/apps/desktop/src/onboarding/state.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { + completeOnboarding, + onboardingComplete, + ONBOARDING_STORAGE_KEY, + ONBOARDING_VERSION, +} from "./state"; + +describe("onboarding state", () => { + it("shows a new or changed guide until its current version is completed", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(onboardingComplete(storage)).toBe(false); + values.set(ONBOARDING_STORAGE_KEY, "older"); + expect(onboardingComplete(storage)).toBe(false); + completeOnboarding(storage); + expect(values.get(ONBOARDING_STORAGE_KEY)).toBe(ONBOARDING_VERSION); + expect(onboardingComplete(storage)).toBe(true); + }); + + it("does not let unavailable browser storage break startup or dismissal", () => { + const storage = { + getItem: vi.fn(() => { + throw new Error("blocked"); + }), + setItem: vi.fn(() => { + throw new Error("blocked"); + }), + }; + + expect(onboardingComplete(storage)).toBe(false); + expect(() => { + completeOnboarding(storage); + }).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/onboarding/state.ts b/apps/desktop/src/onboarding/state.ts new file mode 100644 index 0000000..ac415fc --- /dev/null +++ b/apps/desktop/src/onboarding/state.ts @@ -0,0 +1,30 @@ +export const ONBOARDING_VERSION = "1"; +export const ONBOARDING_STORAGE_KEY = "lucid.onboarding"; + +type OnboardingStorage = Pick; + +const browserStorage = (): OnboardingStorage | undefined => { + try { + return window.localStorage; + } catch { + return undefined; + } +}; + +/** A new onboarding version may be shown once even if an older guide was completed. */ +export function onboardingComplete(storage = browserStorage()): boolean { + try { + return storage?.getItem(ONBOARDING_STORAGE_KEY) === ONBOARDING_VERSION; + } catch { + return false; + } +} + +/** Completion is a convenience preference; an unavailable store must never block the app. */ +export function completeOnboarding(storage = browserStorage()): void { + try { + storage?.setItem(ONBOARDING_STORAGE_KEY, ONBOARDING_VERSION); + } catch { + // This window still closes the guide; it may be offered again next launch. + } +} diff --git a/apps/desktop/src/pages/ExportButton.tsx b/apps/desktop/src/pages/ExportButton.tsx index d5c2692..eaca9f0 100644 --- a/apps/desktop/src/pages/ExportButton.tsx +++ b/apps/desktop/src/pages/ExportButton.tsx @@ -154,7 +154,7 @@ export function ExportButton({ }, [note]); return ( -
+
{engineNote && ( {engineNote} diff --git a/apps/desktop/src/pages/Sidebar.tsx b/apps/desktop/src/pages/Sidebar.tsx index 20e6de5..bc0ff27 100644 --- a/apps/desktop/src/pages/Sidebar.tsx +++ b/apps/desktop/src/pages/Sidebar.tsx @@ -58,7 +58,7 @@ export function Sidebar({ }, [confirming]); return ( -