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
62 changes: 62 additions & 0 deletions apps/desktop/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
1 change: 1 addition & 0 deletions apps/desktop/electron/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsSaveResult> => {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/electron/ipc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicSettings, "llmUrlLocked">;
export type SettingsInput = Omit<PublicSettings, "llmUrlLocked" | "onboardingDisabled">;
export type SettingsSaveResult =
{ ok: true; settings: PublicSettings } | { ok: false; problems: string[] };

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 34 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -32,6 +35,8 @@ function Shell() {
}, []);
const [settings, setSettings] = useState<PublicSettings | undefined>();
const [settingsOpen, setSettingsOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
const [tourOpen, setTourOpen] = useState(false);
const [newPageOpen, setNewPageOpen] = useState(false);
const [imported, setImported] = useState<NoteImportResult | undefined>();

Expand Down Expand Up @@ -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 === ",") {
Expand Down Expand Up @@ -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 (
<div className="app" data-sidebar={sidebarOpen ? "open" : "closed"}>
{sidebarOpen && (
Expand Down Expand Up @@ -184,6 +203,20 @@ function Shell() {
onClose={() => {
setSettingsOpen(false);
}}
onOpenOnboarding={() => {
setSettingsOpen(false);
setOnboardingOpen(true);
}}
/>
)}
{onboardingOpen && settings && (
<FirstRun settings={settings} onSettingsChange={setSettings} onFinish={finishOnboarding} />
)}
{tourOpen && (
<ProductTour
onFinish={() => {
setTourOpen(false);
}}
/>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/dictation/DictationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ export function DictationBar({
)}
</div>
<div className="dictation-box">
<div className="mode" role="group" aria-label="Mode">
<div className="mode" role="group" aria-label="Mode" data-tour="modes">
{(["input", "command", "edit"] as const).map((m) => (
<button
key={m}
Expand Down
96 changes: 96 additions & 0 deletions apps/desktop/src/onboarding/FirstRun.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/** @vitest-environment jsdom */

import { DEFAULT_PDF_STYLE } from "@lucid/kit";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PublicSettings } from "../../electron/ipc/types";
import { FirstRun } from "./FirstRun";

const settings: PublicSettings = {
author: "",
footerRight: "",
pdf: DEFAULT_PDF_STYLE,
dictation: { autoSendMs: 450 },
vocabulary: [],
llm: {
baseUrl: "http://127.0.0.1:1234",
model: "qwen/qwen3.8-27b",
contextLength: 32768,
},
ai: {
defaultProfileId: "lmstudio",
profiles: [
{
id: "lmstudio",
name: "LM Studio",
kind: "lmstudio",
baseUrl: "http://127.0.0.1:1234",
model: "qwen/qwen3.8-27b",
},
],
},
llmUrlLocked: false,
onboardingDisabled: false,
};

let root: Root | undefined;
let container: HTMLDivElement | undefined;

afterEach(() => {
if (root)
act(() => {
root?.unmount();
});
container?.remove();
root = undefined;
container = undefined;
});

describe("FirstRun", () => {
it("starts with a welcome, reaches provider setup, and can be skipped", async () => {
Object.defineProperty(window, "lucid", {
configurable: true,
value: {
credentials: {
status: vi.fn().mockResolvedValue({
present: false,
storage: "none",
securePersistenceAvailable: true,
}),
},
},
});
const finish = vi.fn();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);

await act(async () => {
root?.render(<FirstRun settings={settings} onSettingsChange={vi.fn()} onFinish={finish} />);
await Promise.resolve();
});
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(container.querySelector("h1")?.textContent).toBe("Speak or type the messy version.");

const next = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Set up Lucid",
);
await act(async () => {
next?.click();
await Promise.resolve();
});
expect(container.querySelector("h1")?.textContent).toBe("Connect an AI provider");
expect(
[...container.querySelectorAll("legend")].some((item) => item.textContent === "AI providers"),
).toBe(true);

const skip = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Skip setup",
);
act(() => {
skip?.click();
});
expect(finish).toHaveBeenCalledWith(false);
});
});
Loading
Loading