From 5186b4e2719152f7a769439b32f3c94598424c0b Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:44:06 +0530 Subject: [PATCH] feat: guide users to first success --- apps/desktop/e2e/onboarding.spec.ts | 38 +++--- apps/desktop/src/App.tsx | 75 ++++++++--- apps/desktop/src/jobs/queue.test.ts | 9 +- apps/desktop/src/jobs/queue.ts | 3 + apps/desktop/src/jobs/useJobs.ts | 4 + .../src/onboarding/FirstSuccessGuide.test.tsx | 83 ++++++++++++ .../src/onboarding/FirstSuccessGuide.tsx | 115 +++++++++++++++++ apps/desktop/src/onboarding/ProductTour.tsx | 120 ------------------ .../desktop/src/onboarding/activation.test.ts | 25 ++++ apps/desktop/src/onboarding/activation.ts | 33 ++++- apps/desktop/src/pages/ExportButton.tsx | 13 +- apps/desktop/src/pages/Workspace.tsx | 36 +++++- apps/desktop/src/pages/usePages.ts | 2 + apps/desktop/src/styles.css | 87 ++++++++----- 14 files changed, 450 insertions(+), 193 deletions(-) create mode 100644 apps/desktop/src/onboarding/FirstSuccessGuide.test.tsx create mode 100644 apps/desktop/src/onboarding/FirstSuccessGuide.tsx delete mode 100644 apps/desktop/src/onboarding/ProductTour.tsx diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts index df8d1d8..4cd9265 100644 --- a/apps/desktop/e2e/onboarding.spec.ts +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -4,7 +4,7 @@ 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 () => { +test("a first run leads into a real first-success task", async () => { const app = await electron.launch({ args: [join(__dirname, "../out/main/index.js")], env: { @@ -20,6 +20,7 @@ test("a first run explains setup and points out the workspace", async () => { await maximizeWindow(app, window); await window.evaluate(() => { localStorage.removeItem("lucid.onboarding"); + localStorage.removeItem("lucid.firstSuccess"); }); await window.reload(); @@ -37,25 +38,28 @@ test("a first run explains setup and points out the workspace", async () => { ).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(); + const guide = window.getByRole("complementary", { name: "First success guide" }); + await expect(guide.getByRole("heading", { name: "Create your first page" })).toBeVisible(); + await guide.getByRole("button", { name: "Create my page" }).click(); + await expect(guide.getByRole("heading", { name: "Add your first thought" })).toBeVisible(); + await expect(guide.getByText("Choose Edit mode", { exact: false })).toBeVisible(); + await guide.getByRole("button", { name: "Skip for now", exact: true }).last().click(); + await expect(guide).toBeHidden(); await expect .poll(() => window.evaluate(() => localStorage.getItem("lucid.onboarding"))) .toBe("1"); + await expect + .poll(() => + window.evaluate(() => { + const stored = localStorage.getItem("lucid.firstSuccess"); + return Boolean( + stored?.includes('"provider":"skipped"') && + stored.includes('"paused":true') && + stored.includes('"page-created"'), + ); + }), + ) + .toBe(true); } finally { await app.close(); } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e824c27..e4440a7 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { startAgentBridge } from "./agent/bridge"; import { NewPageDialog } from "./pages/NewPageDialog"; import { SettingsDialog } from "./settings/SettingsDialog"; @@ -9,8 +9,17 @@ 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 { FirstSuccessGuide } from "./onboarding/FirstSuccessGuide"; import { completeOnboarding, onboardingComplete } from "./onboarding/state"; +import { + finishActivation, + loadActivationProgress, + pauseActivation, + recordMilestone, + rememberActivationPage, + resumeActivation, + type ActivationMilestone, +} from "./onboarding/activation"; export function App() { if (typeof window.lucid === "undefined") { @@ -36,7 +45,8 @@ function Shell() { const [settings, setSettings] = useState(); const [settingsOpen, setSettingsOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); - const [tourOpen, setTourOpen] = useState(false); + const [guideOpen, setGuideOpen] = useState(false); + const [activation, setActivation] = useState(loadActivationProgress); const [newPageOpen, setNewPageOpen] = useState(false); const [imported, setImported] = useState(); @@ -75,7 +85,14 @@ function Shell() { useEffect(() => { void loadSettings().then((loaded) => { setSettings(loaded); - if (!loaded.onboardingDisabled && !onboardingComplete()) setOnboardingOpen(true); + if (loaded.onboardingDisabled) return; + if (!onboardingComplete()) { + setOnboardingOpen(true); + return; + } + const progress = loadActivationProgress(); + setActivation(progress); + if (!progress.finished && !progress.paused) setGuideOpen(true); }); // ⌘, opens Settings, as in most Mac apps. const onKey = (e: KeyboardEvent) => { @@ -109,14 +126,30 @@ function Shell() { const finishOnboarding = (startTour: boolean) => { completeOnboarding(); setOnboardingOpen(false); - if (!startTour) return; - void (async () => { - if (!pages.current) await pages.create(); - requestAnimationFrame(() => { - setTourOpen(true); - }); - })(); + const latest = loadActivationProgress(); + setActivation(startTour ? resumeActivation(latest) : pauseActivation(latest)); + setGuideOpen(startTour); }; + const noteMilestone = useCallback((milestone: ActivationMilestone) => { + setActivation((current) => recordMilestone(current, milestone)); + }, []); + const createFirstPage = useCallback( + async (options?: { title?: string; format?: string }) => { + const file = await pages.create(options); + if (!file) return; + setActivation((current) => + recordMilestone(rememberActivationPage(current, file.id), "page-created"), + ); + }, + [pages.create], + ); + useEffect(() => { + const currentPage = pages.current; + if (!guideOpen || !currentPage || activation.milestones.includes("page-created")) return; + setActivation((current) => + recordMilestone(rememberActivationPage(current, currentPage.id), "page-created"), + ); + }, [activation.milestones, guideOpen, pages.current]); return (
{sidebarOpen && ( @@ -125,7 +158,7 @@ function Shell() { problems={pages.problems} currentId={pages.current?.id} onOpen={(id) => void pages.open(id)} - onCreate={() => void pages.create()} + onCreate={() => void createFirstPage()} onCreateFrom={() => { setNewPageOpen(true); }} @@ -157,6 +190,7 @@ function Shell() { onRestoreBackup={pages.restoreBackup} sidebarOpen={sidebarOpen} onToggleSidebar={toggleSidebar} + onActivationMilestone={noteMilestone} /> ) : pages.ready ? (
@@ -165,7 +199,7 @@ function Shell() { Say or type what you’re learning. Lucid turns it into notes, diagrams and tables, and makes the PDF.

-
    @@ -193,7 +227,7 @@ function Shell() { }} onCreate={(page) => { setNewPageOpen(false); - void pages.create(page); + void createFirstPage(page); }} /> )} @@ -212,10 +246,17 @@ function Shell() { {onboardingOpen && settings && ( )} - {tourOpen && ( - void createFirstPage()} + onPause={() => { + setActivation((current) => pauseActivation(current)); + setGuideOpen(false); + }} onFinish={() => { - setTourOpen(false); + setActivation((current) => finishActivation(current)); + setGuideOpen(false); }} /> )} diff --git a/apps/desktop/src/jobs/queue.test.ts b/apps/desktop/src/jobs/queue.test.ts index 260f73a..262147f 100644 --- a/apps/desktop/src/jobs/queue.test.ts +++ b/apps/desktop/src/jobs/queue.test.ts @@ -16,7 +16,7 @@ import { } from "@lucid/llm"; import { startMockLmStudio, type Behaviour, type MockLmStudio } from "@lucid/mock-lmstudio"; import { afterEach, describe, expect, it } from "vitest"; -import { JobQueue, type EditorPort, type JobStatus, type Utterance } from "./queue"; +import { JobQueue, type EditorPort, type JobStatus, type Mode, type Utterance } from "./queue"; let mock: MockLmStudio | undefined; afterEach(async () => { @@ -68,6 +68,7 @@ async function setup( const statuses: JobStatus[] = []; const transcripts: TranscriptEntry[] = []; const changes: ChangeEntry[] = []; + const applied: Mode[] = []; const nativeClient = new LmStudioClient({ baseUrl: mock.url, ...(options.fetch ? { fetch: options.fetch } : {}), @@ -97,6 +98,9 @@ async function setup( onChange: (c) => { changes.push(c); }, + onApplied: (mode) => { + applied.push(mode); + }, pollMs: 20, ids: createSequentialIds("n"), }); @@ -119,6 +123,7 @@ async function setup( statuses, transcripts, changes, + applied, queue, say, settled, @@ -171,6 +176,7 @@ describe("JobQueue", () => { source: "local-model", affected: ["n1", "n2", "n3"], }); + expect(t.applied).toEqual(["input"]); }); it("gives the model the page and the last things said", async () => { @@ -386,6 +392,7 @@ describe("JobQueue in Command mode", () => { text: "cross out MongoDB", }); expect(t.changes[0]).toMatchObject({ summary: "Crossed out MongoDB", affected: ["o2"] }); + expect(t.applied).toEqual(["command"]); }); it("tells the model what a block number is, when the person numbers blocks, and says nothing otherwise", async () => { diff --git a/apps/desktop/src/jobs/queue.ts b/apps/desktop/src/jobs/queue.ts index bc9ccda..69cd04f 100644 --- a/apps/desktop/src/jobs/queue.ts +++ b/apps/desktop/src/jobs/queue.ts @@ -68,6 +68,8 @@ export interface QueueOptions { onStatus: (status: JobStatus) => void; onTranscript: (entry: TranscriptEntry) => void; onChange: (entry: ChangeEntry) => void; + /** A real input or command change was successfully applied to the page. */ + onApplied?: (mode: Mode) => void; /** Timing of every model call (for the developer console and, later, the job log). */ onCall?: (call: CallInfo) => void; /** How often to look for LM Studio again while it is down. */ @@ -554,6 +556,7 @@ export class JobQueue { affected: [...(affected ?? [])], reverted: false, }); + this.options.onApplied?.(mode); this.options.onStatus({ kind: "done", summary, notice, at, undoable: true }); } diff --git a/apps/desktop/src/jobs/useJobs.ts b/apps/desktop/src/jobs/useJobs.ts index e3579d2..8031dae 100644 --- a/apps/desktop/src/jobs/useJobs.ts +++ b/apps/desktop/src/jobs/useJobs.ts @@ -15,6 +15,7 @@ interface Options { format: () => string | undefined; onTranscript: (entry: TranscriptEntry) => void; onChange: (entry: ChangeEntry) => void; + onApplied?: ((mode: Mode) => void) | undefined; onExportPdf?: (() => void) | undefined; onExportMarkdown?: (() => void) | undefined; /** "diagram mode", "edit this diagram", "table mode": open the guided builder. */ @@ -64,6 +65,9 @@ export function useJobs(options: Options) { onChange: (e) => { opts.current.onChange(e); }, + onApplied: (mode) => { + opts.current.onApplied?.(mode); + }, }); setReady(true); }); diff --git a/apps/desktop/src/onboarding/FirstSuccessGuide.test.tsx b/apps/desktop/src/onboarding/FirstSuccessGuide.test.tsx new file mode 100644 index 0000000..20824f2 --- /dev/null +++ b/apps/desktop/src/onboarding/FirstSuccessGuide.test.tsx @@ -0,0 +1,83 @@ +/** @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ActivationProgress } from "./activation"; +import { FirstSuccessGuide } from "./FirstSuccessGuide"; + +const progress = (overrides: Partial = {}): ActivationProgress => ({ + version: "1", + provider: "skipped", + setupStep: 0, + milestones: [], + finished: false, + paused: false, + ...overrides, +}); + +let root: Root | undefined; +let container: HTMLDivElement | undefined; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + root = undefined; + container = undefined; +}); + +function renderGuide(value: ActivationProgress, handlers = { create: vi.fn(), finish: vi.fn() }) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + return handlers; +} + +describe("FirstSuccessGuide", () => { + it("starts with a real page action", () => { + const handlers = renderGuide(progress()); + if (!container) throw new Error("Guide was not rendered"); + const button = [...container.querySelectorAll("button")].find( + (item) => item.textContent === "Create my page", + ); + act(() => button?.click()); + expect(handlers.create).toHaveBeenCalledOnce(); + }); + + it("uses the offline route and finishes only after an export", () => { + renderGuide(progress({ milestones: ["page-created"] })); + expect(container?.querySelector("h2")?.textContent).toBe("Add your first thought"); + expect(container?.textContent).toContain("Choose Edit mode"); + + act(() => { + root?.render( + , + ); + }); + expect(container?.querySelector("h2")?.textContent).toBe("Your first page is ready"); + expect(container?.textContent).toContain("Finish"); + }); + + it("guides a ready provider through command and visual milestones", () => { + renderGuide(progress({ provider: "ready", milestones: ["page-created", "content-added"] })); + expect(container?.querySelector("h2")?.textContent).toBe("Make one change with AI"); + expect(container?.textContent).toContain("Build a diagram or table"); + }); +}); diff --git a/apps/desktop/src/onboarding/FirstSuccessGuide.tsx b/apps/desktop/src/onboarding/FirstSuccessGuide.tsx new file mode 100644 index 0000000..024e36c --- /dev/null +++ b/apps/desktop/src/onboarding/FirstSuccessGuide.tsx @@ -0,0 +1,115 @@ +import { + hasAiFirstSuccess, + hasBasicFirstSuccess, + type ActivationMilestone, + type ActivationProgress, +} from "./activation"; + +interface Props { + progress: ActivationProgress; + onCreatePage: () => void; + onPause: () => void; + onFinish: () => void; +} + +const done = (progress: ActivationProgress, milestone: ActivationMilestone): boolean => + progress.milestones.includes(milestone); + +function nextStep(progress: ActivationProgress): { title: string; text: string } { + if (!done(progress, "page-created")) { + return { + title: "Create your first page", + text: "Start with a real Lucid page. Your progress will continue there.", + }; + } + if (!done(progress, "content-added")) { + return progress.provider === "ready" + ? { + title: "Add your first thought", + text: "Stay in Input mode and type or speak a few sentences. Lucid will shape them into notes.", + } + : { + title: "Add your first thought", + text: "Choose Edit mode and type directly on the page. You can connect an AI provider later in Settings.", + }; + } + if (progress.provider === "ready" && !done(progress, "command-applied")) { + return { + title: "Make one change with AI", + text: "Switch to Command mode and ask Lucid to revise, organize, or clarify something already on the page.", + }; + } + if (progress.provider === "ready" && !done(progress, "visual-created")) { + return { + title: "Build one visual", + text: "Open Diagram or Table mode, create something useful, then choose Done to add it to the page.", + }; + } + if (!done(progress, "export-saved")) { + return { + title: "Take your work with you", + text: "Export the page as Markdown or an editable .lucid note. PDF is available too when you need it.", + }; + } + return { + title: hasAiFirstSuccess(progress) + ? "You’ve completed the full Lucid flow" + : "Your first page is ready", + text: hasAiFirstSuccess(progress) + ? "You created, shaped, visualized, and exported a real page." + : "You created, edited, and exported a real page. Connect an AI provider whenever you want the full flow.", + }; +} + +export function FirstSuccessGuide({ progress, onCreatePage, onPause, onFinish }: Props) { + const step = nextStep(progress); + const basicComplete = hasBasicFirstSuccess(progress); + const checks: { milestone: ActivationMilestone; label: string; optional?: boolean }[] = [ + { milestone: "page-created", label: "Create a page" }, + { milestone: "content-added", label: "Add content" }, + ...(progress.provider === "ready" + ? [ + { milestone: "command-applied" as const, label: "Use a command" }, + { milestone: "visual-created" as const, label: "Build a diagram or table" }, + ] + : []), + { milestone: "export-saved", label: "Save an export" }, + ]; + + return ( + + ); +} diff --git a/apps/desktop/src/onboarding/ProductTour.tsx b/apps/desktop/src/onboarding/ProductTour.tsx deleted file mode 100644 index 7e7b722..0000000 --- a/apps/desktop/src/onboarding/ProductTour.tsx +++ /dev/null @@ -1,120 +0,0 @@ -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/activation.test.ts b/apps/desktop/src/onboarding/activation.test.ts index 6cf26ca..b2fa2d8 100644 --- a/apps/desktop/src/onboarding/activation.test.ts +++ b/apps/desktop/src/onboarding/activation.test.ts @@ -4,10 +4,14 @@ import { ACTIVATION_VERSION, decideProvider, finishActivation, + hasAiFirstSuccess, + hasBasicFirstSuccess, loadActivationProgress, + pauseActivation, recordMilestone, rememberActivationPage, rememberSetupStep, + resumeActivation, } from "./activation"; const memory = (initial?: string) => { @@ -30,6 +34,7 @@ describe("first-success activation progress", () => { setupStep: 0, milestones: [], finished: false, + paused: false, }); }); @@ -57,6 +62,7 @@ describe("first-success activation progress", () => { setupStep: 0, milestones: ["page-created"], finished: false, + paused: false, }); }); @@ -67,6 +73,8 @@ describe("first-success activation progress", () => { progress = rememberActivationPage(progress, "page-1", storage); progress = recordMilestone(progress, "page-created", storage); progress = recordMilestone(progress, "page-created", storage); + progress = pauseActivation(progress, storage); + progress = resumeActivation(progress, storage); finishActivation(progress, storage); expect(loadActivationProgress(storage)).toEqual({ @@ -76,9 +84,26 @@ describe("first-success activation progress", () => { milestones: ["page-created"], pageId: "page-1", finished: true, + paused: false, }); }); + it("distinguishes a basic offline success from the full AI flow", () => { + const base = loadActivationProgress(memory().storage); + const basic: ReturnType = { + ...base, + milestones: ["page-created", "content-added", "export-saved"], + }; + expect(hasBasicFirstSuccess(basic)).toBe(true); + expect(hasAiFirstSuccess(basic)).toBe(false); + expect( + hasAiFirstSuccess({ + ...basic, + milestones: [...basic.milestones, "command-applied", "visual-created"], + }), + ).toBe(true); + }); + it("never blocks the app when storage throws", () => { const storage = { getItem: vi.fn(() => { diff --git a/apps/desktop/src/onboarding/activation.ts b/apps/desktop/src/onboarding/activation.ts index b815f4e..b91123b 100644 --- a/apps/desktop/src/onboarding/activation.ts +++ b/apps/desktop/src/onboarding/activation.ts @@ -12,6 +12,7 @@ export interface ActivationProgress { milestones: ActivationMilestone[]; pageId?: string | undefined; finished: boolean; + paused: boolean; } type ActivationStorage = Pick; @@ -31,6 +32,7 @@ const emptyProgress = (): ActivationProgress => ({ setupStep: 0, milestones: [], finished: false, + paused: false, }); const browserStorage = (): ActivationStorage | undefined => { @@ -73,6 +75,7 @@ const parseProgress = (value: string | null): ActivationProgress => { milestones, ...(pageId ? { pageId } : {}), finished: parsed["finished"] === true, + paused: parsed["paused"] === true, }; } catch { return emptyProgress(); @@ -139,5 +142,33 @@ export function finishActivation( progress: ActivationProgress, storage = browserStorage(), ): ActivationProgress { - return saveActivationProgress({ ...progress, finished: true }, storage); + return saveActivationProgress({ ...progress, finished: true, paused: false }, storage); +} + +export function pauseActivation( + progress: ActivationProgress, + storage = browserStorage(), +): ActivationProgress { + return saveActivationProgress({ ...progress, paused: true }, storage); +} + +export function resumeActivation( + progress: ActivationProgress, + storage = browserStorage(), +): ActivationProgress { + return saveActivationProgress({ ...progress, paused: false }, storage); +} + +export function hasBasicFirstSuccess(progress: ActivationProgress): boolean { + return ["page-created", "content-added", "export-saved"].every((milestone) => + progress.milestones.includes(milestone as ActivationMilestone), + ); +} + +export function hasAiFirstSuccess(progress: ActivationProgress): boolean { + return ( + hasBasicFirstSuccess(progress) && + progress.milestones.includes("command-applied") && + progress.milestones.includes("visual-created") + ); } diff --git a/apps/desktop/src/pages/ExportButton.tsx b/apps/desktop/src/pages/ExportButton.tsx index eaca9f0..6b8515b 100644 --- a/apps/desktop/src/pages/ExportButton.tsx +++ b/apps/desktop/src/pages/ExportButton.tsx @@ -21,6 +21,7 @@ interface Props { /** Lets the page start an export (the voice commands "export PDF" and "export Markdown", the File menu). */ register?: ((start: { pdf: () => void; markdown: () => void; note: () => void }) => void) | undefined; + onSaved?: ((kind: "pdf" | "markdown" | "note", path: string) => void) | undefined; } /** What went wrong, without the "Error invoking remote method ..." that Electron puts in front. */ @@ -41,6 +42,7 @@ export function ExportButton({ said, changes, register, + onSaved, }: Props) { const [busy, setBusy] = useState(false); const [noteOpen, setNoteOpen] = useState(false); @@ -91,6 +93,7 @@ export function ExportButton({ if (path) { const warning = made.warnings.length > 0 ? ` ${made.warnings.join(" ")}` : ""; setNote({ kind: "saved", text: `Saved ${baseName(path)}.${warning}`, path }); + onSaved?.("pdf", path); } } finally { setBusy(false); @@ -104,14 +107,20 @@ export function ExportButton({ docToMarkdown(current, { title: name }), pdfFileName(name).replace(/\.pdf$/, ".md"), ); - if (path) setNote({ kind: "saved", text: `Saved ${baseName(path)}` }); + if (path) { + setNote({ kind: "saved", text: `Saved ${baseName(path)}` }); + onSaved?.("markdown", path); + } }; const runNote = async (history: boolean) => { setNoteOpen(false); setNote(undefined); try { const path = await window.lucid.note.export(getNote(), { history }); - if (path) setNote({ kind: "saved", text: `Saved ${baseName(path)}.` }); + if (path) { + setNote({ kind: "saved", text: `Saved ${baseName(path)}.` }); + onSaved?.("note", path); + } } catch (error) { setNote({ kind: "error", text: `The note couldn’t be exported. ${plain(error)}` }); } diff --git a/apps/desktop/src/pages/Workspace.tsx b/apps/desktop/src/pages/Workspace.tsx index f969264..543d693 100644 --- a/apps/desktop/src/pages/Workspace.tsx +++ b/apps/desktop/src/pages/Workspace.tsx @@ -29,6 +29,7 @@ import { useAppearance } from "../theme/appearance"; import { HistoryPanel } from "./HistoryPanel"; import { useAutosave } from "./useAutosave"; import { BackupDialog } from "./BackupDialog"; +import type { ActivationMilestone } from "../onboarding/activation"; const SIDE_KEY = "lucid.history"; @@ -66,6 +67,7 @@ interface Props { sidebarOpen: boolean; onToggleSidebar: () => void; onRestoreBackup: (pageId: string, backupId: string) => Promise; + onActivationMilestone?: ((milestone: ActivationMilestone) => void) | undefined; } /** @@ -73,7 +75,14 @@ interface Props { * lists what was said. The page is edited by speaking or typing in the box; Edit mode lets you type in the page * itself for the few things the model gets wrong. */ -export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar, onRestoreBackup }: Props) { +export function Workspace({ + file, + onSaved, + sidebarOpen, + onToggleSidebar, + onRestoreBackup, + onActivationMilestone, +}: Props) { const [title, setTitle] = useState(file.title); const [doc, setDoc] = useState(file.doc); const [transcripts, setTranscripts] = useState(file.transcripts); @@ -120,10 +129,14 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar, onResto }; }, []); /** Lists a change in the History, with its way back (what the editor says takes the last change back). */ - const addChange = useCallback((entry: ChangeEntry) => { - const revert = editorApi.current?.takeRevert(); - setChangeLog((list) => withinRevertWindow([...list, revert ? { ...entry, revert } : entry])); - }, []); + const addChange = useCallback( + (entry: ChangeEntry) => { + const revert = editorApi.current?.takeRevert(); + setChangeLog((list) => withinRevertWindow([...list, revert ? { ...entry, revert } : entry])); + if (entry.source === "builder") onActivationMilestone?.("visual-created"); + }, + [onActivationMilestone], + ); const builder = useBuilder({ pageId: file.id, editor: () => editorApi.current, @@ -155,6 +168,9 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar, onResto setTranscripts((list) => [...list, entry]); }, onChange: addChange, + onApplied: (mode) => { + onActivationMilestone?.(mode === "command" ? "command-applied" : "content-added"); + }, onExportPdf: () => { exportRef.current?.pdf(); }, @@ -445,6 +461,9 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar, onResto said={transcripts.length} changes={changeLog.length} register={registerExport} + onSaved={() => { + onActivationMilestone?.("export-saved"); + }} />
    @@ -476,7 +495,12 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar, onResto { + setDoc(next); + if (next !== file.doc && next.blocks.length > 0) { + onActivationMilestone?.("content-added"); + } + }} onReady={(api) => { editorApi.current = api; }} diff --git a/apps/desktop/src/pages/usePages.ts b/apps/desktop/src/pages/usePages.ts index 853e3be..c9f858b 100644 --- a/apps/desktop/src/pages/usePages.ts +++ b/apps/desktop/src/pages/usePages.ts @@ -90,8 +90,10 @@ export function usePages() { setCurrent(file); remember(file.id); setError(undefined); + return file; } catch (e) { setError(e instanceof Error ? e.message : String(e)); + return undefined; } }, []); diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index a45fb67..089c070 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -700,9 +700,19 @@ select:focus-visible, gap: 10px; margin-left: auto; } +.workspace[data-side="open"] .topbar { + flex-wrap: nowrap; + gap: 6px; +} +.workspace[data-side="open"] .topbar-actions { + gap: 6px; +} +.workspace[data-side="open"] .save-note { + display: none; +} /* Narrow: the buttons keep their pictures and names (for screen readers and as tooltips) and lose the visible words, so the bar stays on one line. */ -@container topbar (max-width: 880px) { +@container topbar (max-width: 1050px) { .topbar .btn-text { position: absolute; width: 1px; @@ -2116,50 +2126,72 @@ select:focus-visible, .onboarding-actions > span { flex: 1; } -.product-tour { - position: fixed; - inset: 0; - z-index: 100; - pointer-events: none; -} -.tour-highlight { - position: fixed; - border: 2px solid var(--inverse-bg); - border-radius: 10px; - box-shadow: 0 0 0 9999px var(--backdrop); - transition: - inset 0.18s ease, - width 0.18s ease, - height 0.18s ease; -} -.tour-card { +.first-success-guide { position: fixed; - width: min(320px, calc(100vw - 40px)); + z-index: 32; + top: 58px; + right: 18px; + width: min(340px, calc(100vw - 36px)); padding: 16px 17px; border: 1px solid var(--line); - border-radius: 9px; + border-radius: 10px; background: var(--bg); box-shadow: var(--shadow-pop); - pointer-events: auto; } -.tour-count { +.app:has([role="dialog"][aria-modal="true"]) .first-success-guide, +.app:has(.dictation-card) .first-success-guide { + display: none; +} +.first-success-heading, +.first-success-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.first-success-heading .eyebrow { color: var(--ink-3); + font-weight: 700; font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; } -.tour-card h2 { +.first-success-guide h2 { margin: 5px 0 5px; font-size: 15px; } -.tour-card p { +.first-success-guide > p { margin: 0 0 14px; color: var(--ink-2); font-size: 12.5px; line-height: 1.5; } -.tour-card > div { +.first-success-checklist { + display: grid; + gap: 7px; + margin: 0 0 15px; + padding: 12px 0; + border-block: 1px solid var(--line); + list-style: none; + color: var(--ink-2); + font-size: 12px; +} +.first-success-checklist li { display: flex; align-items: center; - justify-content: space-between; + gap: 8px; +} +.first-success-checklist li[data-done="true"] { + color: var(--ink-3); + text-decoration: line-through; +} +.first-success-checklist li > span { + width: 14px; + color: var(--accent); + text-align: center; +} +.first-success-actions { + align-items: center; } .modal-backdrop { position: fixed; @@ -2456,9 +2488,6 @@ select:focus-visible, .lm-pill[data-state="loading"] > i { animation: none; } - .tour-highlight { - transition: none; - } } .setting-row > span { font-weight: 700;