diff --git a/apps/desktop/e2e/appearance.spec.ts b/apps/desktop/e2e/appearance.spec.ts index 7ed7201..e57709b 100644 --- a/apps/desktop/e2e/appearance.spec.ts +++ b/apps/desktop/e2e/appearance.spec.ts @@ -56,17 +56,18 @@ test("it is light by default, the sidebar's moon switches to dark, and the choic expect(await theme(page)).toBe("light"); expect(await style(page, "body", "background-color")).toBe(LIGHT.bg); expect(await style(page, "body", "color")).toBe(LIGHT.ink); - expect(await style(page, "body", "font-family")).toContain("iA Writer Mono"); + expect(await style(page, "body", "font-family")).toContain("ui-sans-serif"); await page.screenshot({ path: join(__dirname, "../test-results/appearance-light.png") }); await page.getByRole("button", { name: "Switch to dark mode" }).click(); expect(await theme(page)).toBe("dark"); expect(await style(page, "body", "background-color")).toBe(DARK.bg); expect(await style(page, "body", "color")).toBe(DARK.ink); - // Primary actions use the structure signal, with the neutral canvas preserved around them. + // Export is a quiet menu action; the neutral canvas is preserved around it. + await page.locator('summary[aria-label="Page actions"]').click(); const download = page.getByRole("button", { name: "Download PDF" }); - await expect(download).toHaveCSS("background-color", DARK.structure); - await expect(download).toHaveCSS("color", DARK.structureInk); + await expect(download).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(download).toHaveCSS("color", DARK.ink); await expect(page.getByRole("button", { name: "Switch to light mode" })).toBeVisible(); await page.screenshot({ path: join(__dirname, "../test-results/appearance-dark.png") }); @@ -83,7 +84,7 @@ test("the page's text and its diagram follow the theme at once, and the PDF does const { app, page } = await launch(); try { // The page body is in the app's type, not BlockNote's own. - expect(await style(page, ".page-editor .bn-editor", "font-family")).toContain("iA Writer Mono"); + expect(await style(page, ".page-editor .bn-editor", "font-family")).toContain("ui-sans-serif"); expect(await labelFill(page)).toBe(LIGHT.bg); await page.getByRole("button", { name: "Switch to dark mode" }).click(); @@ -112,7 +113,7 @@ test("Settings: Theme follows the system when asked to, and Type changes the fac "aria-checked", "true", ); - await expect(type.getByRole("radio", { name: "Mono" })).toHaveAttribute("aria-checked", "true"); + await expect(type.getByRole("radio", { name: "Sans" })).toHaveAttribute("aria-checked", "true"); // System follows the machine, also while the app runs. await page.emulateMedia({ colorScheme: "dark" }); diff --git a/apps/desktop/e2e/backup-recovery.spec.ts b/apps/desktop/e2e/backup-recovery.spec.ts index 66818a1..e381e0b 100644 --- a/apps/desktop/e2e/backup-recovery.spec.ts +++ b/apps/desktop/e2e/backup-recovery.spec.ts @@ -2,7 +2,7 @@ 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"; +import { maximizeWindow, openPageActions } from "./helpers"; test("an automatic backup can be restored as a new page without replacing the current page", async () => { test.setTimeout(60_000); @@ -23,6 +23,7 @@ test("an automatic backup can be restored as a new page without replacing the cu await page.reload(); await expect(page.getByLabel("Page title")).toHaveValue("Current"); + await openPageActions(page); await page.getByRole("button", { name: "Backups" }).click(); const dialog = page.getByRole("dialog", { name: "Automatic backups" }); await expect(dialog).toContainText("never replaces this one"); diff --git a/apps/desktop/e2e/block-numbers.spec.ts b/apps/desktop/e2e/block-numbers.spec.ts index 8cbcdc1..ce2d92b 100644 --- a/apps/desktop/e2e/block-numbers.spec.ts +++ b/apps/desktop/e2e/block-numbers.spec.ts @@ -235,7 +235,7 @@ test("what the person says reaches the model as the reference the margin shows", await expect(labels(page)).toHaveCount(6); await page .getByRole("group", { name: "Mode" }) - .getByRole("button", { name: "Command" }) + .getByRole("button", { name: "Change page" }) .click(); const bar = page.getByLabel("Dictate or type your notes"); await bar.fill("Cross out item 2 of block 6"); diff --git a/apps/desktop/e2e/change-review.spec.ts b/apps/desktop/e2e/change-review.spec.ts new file mode 100644 index 0000000..47902de --- /dev/null +++ b/apps/desktop/e2e/change-review.spec.ts @@ -0,0 +1,78 @@ +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 { startMockLmStudio } from "@lucid/mock-lmstudio"; + +test("a selected passage can be previewed, discarded, kept and undone", async () => { + const mock = await startMockLmStudio({ script: [] }); + const app = await electron.launch({ + args: [join(__dirname, "../out/main/index.js")], + env: { + ...process.env, + LUCID_HOME: mkdtempSync(join(tmpdir(), "lucid-review-")), + LUCID_LLM_URL: mock.url, + }, + }); + try { + const page = await app.firstWindow(); + await page.emulateMedia({ colorScheme: "light" }); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(1280, 820)); + await page.evaluate(async () => { + const file = await window.lucid.pages.create("A considered change"); + await window.lucid.pages.save({ + ...file, + doc: { + blocks: [ + { + id: "keep", + type: "paragraph", + props: {}, + content: [{ type: "text", text: "Keep the surrounding thought.", styles: {} }], + }, + { + id: "change", + type: "paragraph", + props: {}, + content: [{ type: "text", text: "An unnecessary paragraph.", styles: {} }], + }, + ], + }, + }); + }); + await page.reload(); + const select = async () => { + await page.getByText("An unnecessary paragraph.", { exact: true }).dblclick(); + await page.getByRole("button", { name: "Change passage", exact: true }).click(); + await expect(page.locator(".passage-source")).toContainText("An unnecessary paragraph."); + await expect(page.locator(".passage-source")).not.toContainText( + "Keep the surrounding thought.", + ); + await page.getByLabel("Dictate or type your notes").fill("delete this block"); + await page.getByLabel("Dictate or type your notes").press("Enter"); + await expect(page.getByRole("button", { name: "Keep change", exact: true })).toBeVisible(); + }; + await select(); + await expect(page.locator(".review-document")).toContainText("Keep the surrounding thought."); + await expect(page.locator(".review-document")).not.toContainText("An unnecessary paragraph."); + await page.getByRole("button", { name: "Original", exact: true }).click(); + await expect(page.locator(".review-document")).toContainText("An unnecessary paragraph."); + await page.getByRole("button", { name: "Result", exact: true }).click(); + await page.screenshot({ path: "test-results/change-review-wide.png" }); + await page.getByRole("button", { name: "Discard", exact: true }).click(); + await expect(page.locator(".page-editor")).toContainText("An unnecessary paragraph."); + await select(); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(820, 700)); + await page.screenshot({ path: "test-results/change-review-narrow.png" }); + await page.getByRole("button", { name: "Keep change", exact: true }).click(); + await expect(page.locator(".review-document")).toHaveCount(0); + await expect(page.locator(".page-editor")).not.toContainText("An unnecessary paragraph."); + await expect(page.locator(".page-editor")).toContainText("Keep the surrounding thought."); + await page.locator(".bn-editor").click(); + await page.keyboard.press("Meta+z"); + await expect(page.locator(".page-editor")).toContainText("An unnecessary paragraph."); + } finally { + await app.close(); + await mock.close(); + } +}); diff --git a/apps/desktop/e2e/command.spec.ts b/apps/desktop/e2e/command.spec.ts index 925f60f..e49a4a4 100644 --- a/apps/desktop/e2e/command.spec.ts +++ b/apps/desktop/e2e/command.spec.ts @@ -61,7 +61,9 @@ const say = async (page: Page, text: string) => { await bar(page).press("Enter"); }; const modeButton = (page: Page, name: "Input" | "Command") => - page.getByRole("group", { name: "Mode" }).getByRole("button", { name }); + page + .getByRole("group", { name: "Mode" }) + .getByRole("button", { name: name === "Input" ? "Add notes" : "Change page" }); /** The list item with this text: where the page draws a crossed-out option. */ const struck = (page: Page, text: string) => page @@ -73,10 +75,10 @@ test("the mode pill, the ⌘/ shortcut and the spoken 'command mode' all switch const { app, page } = await launch([]); try { await expect(modeButton(page, "Input")).toHaveAttribute("aria-pressed", "true"); - await expect(bar(page)).toHaveAttribute("placeholder", /Speak or type your notes/); + await expect(bar(page)).toHaveAttribute("placeholder", /Type a thought/); await modeButton(page, "Command").click(); await expect(modeButton(page, "Command")).toHaveAttribute("aria-pressed", "true"); - await expect(bar(page)).toHaveAttribute("placeholder", /Say or type what to change/); + await expect(bar(page)).toHaveAttribute("placeholder", /Describe what to change/); await page.keyboard.press("Meta+/"); await expect(modeButton(page, "Input")).toHaveAttribute("aria-pressed", "true"); await say(page, "Command mode."); @@ -169,10 +171,19 @@ test("a command that throws something away waits for a yes: by button, or by voi await modeButton(page, "Command").click(); await say(page, "clean up the comparison"); await expect(page.getByText("Apply this change?")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".page-editor .bn-editor")).toHaveAttribute( + "contenteditable", + "false", + ); + await expect(page.getByLabel("Page title")).toHaveAttribute("readonly", ""); await expect(page.locator(".lucid-table")).toBeVisible(); await page.screenshot({ path: join(__dirname, "../test-results/app-proposal.png") }); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByText("Apply this change?")).toHaveCount(0); + await expect(page.locator(".page-editor .bn-editor")).toHaveAttribute( + "contenteditable", + "true", + ); await expect(page.locator(".lucid-table")).toBeVisible(); await say(page, "clean up the comparison"); diff --git a/apps/desktop/e2e/dictation.spec.ts b/apps/desktop/e2e/dictation.spec.ts index 59c2e9b..0b4c5d8 100644 --- a/apps/desktop/e2e/dictation.spec.ts +++ b/apps/desktop/e2e/dictation.spec.ts @@ -232,13 +232,13 @@ async function wisprPaste(app: ElectronApplication, page: Page, words: string): } const requestsSent = () => mock?.requests.length ?? 0; -test("a paste goes to the input box and is sent, wherever the focus is: on a button, on nothing, on the read-only title", async () => { +test("paste on chrome goes to capture; paste in the editable title stays in the title", async () => { test.setTimeout(120_000); const script = Array.from({ length: 3 }, () => ({ kind: "reply" as const, content: notes })); const { app, page } = await launch({ script }); try { // 1. After clicking a button (focus is on it, which cannot take text). - await page.getByRole("button", { name: /^History/ }).focus(); + await page.locator('summary[aria-label="Page actions"]').focus(); await wisprPaste(app, page, "Load balancer first thing. "); await expect.poll(requestsSent, { timeout: 30_000 }).toBe(1); const sentFirst = JSON.stringify(mock?.requests[0]?.body); @@ -256,11 +256,13 @@ test("a paste goes to the input box and is sent, wherever the focus is: on a but await expect.poll(requestsSent, { timeout: 30_000 }).toBe(2); expect(JSON.stringify(mock?.requests[1]?.body)).toContain("Health checks keep traffic away"); - // 3. With the cursor in the page title, which is read-only outside Edit mode. + // 3. The title is directly editable: preserve the user's intended paste target. + await expect(page.locator(".dictation")).toHaveAttribute("data-status", "done"); + await page.getByLabel("Page title").fill(""); await page.getByLabel("Page title").focus(); - await wisprPaste(app, page, "The database can become the bottleneck. "); - await expect.poll(requestsSent, { timeout: 30_000 }).toBe(3); - await expect(page.getByLabel("Page title")).toHaveValue("Load balancing"); + await wisprPaste(app, page, "Database bottlenecks"); + await expect(page.getByLabel("Page title")).toHaveValue("Database bottlenecks"); + expect(requestsSent()).toBe(2); } finally { await app.close(); } diff --git a/apps/desktop/e2e/editing.spec.ts b/apps/desktop/e2e/editing.spec.ts index ee9fe2e..3cf8cbf 100644 --- a/apps/desktop/e2e/editing.spec.ts +++ b/apps/desktop/e2e/editing.spec.ts @@ -87,15 +87,14 @@ test("one view that scales: the page, its text and the input box grow with the w }; }); - // The maximized window itself (what a person gets): everything fits, and a wide one shows the history by itself. + // The maximized window itself (what a person gets): everything fits, while History stays out of the way until asked for. const maximized = await measure(); expect(maximized.overflow, "maximized: no horizontal scrolling").toBe(false); expect(maximized.barBottom, "maximized: the input box is on screen").toBeLessThanOrEqual( maximized.innerHeight, ); expect(maximized.barRight).toBeLessThanOrEqual(maximized.innerWidth); - if (maximized.innerWidth >= 1500) - await expect(page.getByRole("complementary", { name: "History" })).toBeVisible(); + await expect(page.getByRole("complementary", { name: "History" })).toBeHidden(); await page.screenshot({ path: join(__dirname, "../test-results/app-maximized.png") }); const sizes = [ @@ -140,7 +139,7 @@ test("one view that scales: the page, its text and the input box grow with the w } }); -test("the page is read-only until Edit mode; ⌘E, Esc and 'edit mode' switch; the side panel and page list hide", async () => { +test("the page stays directly editable; legacy editing shortcuts and collapsible panels still work", async () => { test.setTimeout(90_000); const { app, page } = await openNewPage(); try { @@ -148,29 +147,34 @@ test("the page is read-only until Edit mode; ⌘E, Esc and 'edit mode' switch; t page.getByRole("group", { name: "Mode" }).getByRole("button", { name }); const editable = () => page.locator(".page-editor .bn-editor").getAttribute("contenteditable"); - // A new page starts in Edit mode. - await expect(pill("Edit")).toHaveAttribute("aria-pressed", "true"); + await expect(pill("Edit")).toHaveCount(0); await page.keyboard.type("typed by hand"); expect(await editable()).toBe("true"); - // Esc goes back to taking notes: the page can no longer be typed in, and there is one input box. + // Esc focuses capture without making the document read-only. await page.keyboard.press("Escape"); - await expect(pill("Input")).toHaveAttribute("aria-pressed", "true"); - expect(await editable()).toBe("false"); + await expect(pill("Add notes")).toHaveAttribute("aria-pressed", "true"); + expect(await editable()).toBe("true"); await expect(page.getByLabel("Dictate or type your notes")).toBeFocused(); - // ⌘E toggles Edit mode, and the box gives way to a hint. + // Direct editing needs no mode button, even after capture was focused. + await page.locator(".page-editor .bn-editor").click(); + await page.keyboard.press("End"); + await page.keyboard.type(" directly"); + await expect(page.locator(".page-editor")).toContainText("directly"); + + // Legacy shortcut focuses the page; the capture control remains available. await page.keyboard.press("Meta+e"); - await expect(pill("Edit")).toHaveAttribute("aria-pressed", "true"); - await expect(page.getByText("Editing by hand.")).toBeVisible(); + await expect(page.locator(".page-editor .bn-editor")).toBeFocused(); + await expect(page.getByLabel("Dictate or type your notes")).toBeVisible(); expect(await editable()).toBe("true"); await page.keyboard.press("Meta+e"); - await expect(pill("Input")).toHaveAttribute("aria-pressed", "true"); + await expect(pill("Add notes")).toHaveAttribute("aria-pressed", "true"); // Saying "edit mode" does the same, without asking the model. await page.getByLabel("Dictate or type your notes").fill("edit mode"); await page.getByLabel("Dictate or type your notes").press("Enter"); - await expect(pill("Edit")).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(".page-editor .bn-editor")).toBeFocused(); await page.keyboard.press("Escape"); // The panel on the right shows what was said, and hides. @@ -188,3 +192,30 @@ test("the page is read-only until Edit mode; ⌘E, Esc and 'edit mode' switch; t await app.close(); } }); + +test("Insert exposes both builders and closes with Escape or a choice", async () => { + const { app, page } = await openNewPage(); + try { + const insert = page.locator(".insert-menu > summary"); + await insert.focus(); + await page.keyboard.press("Enter"); + await expect(page.locator(".insert-popover")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator(".insert-popover")).toBeHidden(); + await expect(insert).toBeFocused(); + for (const kind of ["Diagram", "Table"]) { + await insert.click(); + await page + .locator(".insert-popover") + .getByRole("button", { name: kind, exact: true }) + .click(); + const workbench = page.getByRole("region", { name: `${kind} builder` }); + await expect(workbench).toBeVisible(); + await expect(page.locator(".insert-popover")).toBeHidden(); + await workbench.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(page.locator(".page-editor")).toBeVisible(); + } + } finally { + await app.close(); + } +}); diff --git a/apps/desktop/e2e/export-markdown.spec.ts b/apps/desktop/e2e/export-markdown.spec.ts index a7066a6..83aa43f 100644 --- a/apps/desktop/e2e/export-markdown.spec.ts +++ b/apps/desktop/e2e/export-markdown.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { COMMAND_DOC } from "@lucid/llm"; import { _electron as electron, expect, test } from "@playwright/test"; -import { autoSaveDialog, maximizeWindow } from "./helpers"; +import { autoSaveDialog, maximizeWindow, openPageActions } from "./helpers"; test("the page can be saved as Markdown, from the button and by voice", async () => { test.setTimeout(60_000); @@ -23,6 +23,7 @@ test("the page can be saved as Markdown, from the button and by voice", async () await expect(page.locator(".page-editor")).toContainText("MongoDB"); const path = await autoSaveDialog(app); + await openPageActions(page); await page.getByRole("button", { name: "Markdown" }).click(); await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible(); const md = readFileSync(path, "utf8"); diff --git a/apps/desktop/e2e/faults.spec.ts b/apps/desktop/e2e/faults.spec.ts index f5f12b7..b3e7e0b 100644 --- a/apps/desktop/e2e/faults.spec.ts +++ b/apps/desktop/e2e/faults.spec.ts @@ -140,6 +140,7 @@ test("without Tectonic the PDF says how to install it, and Markdown still works" await page.reload(); await expect(page.locator(".page-editor")).toContainText("MongoDB"); + await page.locator('summary[aria-label="Page actions"]').click(); await page.getByRole("button", { name: "Download PDF" }).click(); const note = page.locator(".export-note"); await expect(note).toContainText("The PDF couldn’t be made", { timeout: 60_000 }); diff --git a/apps/desktop/e2e/helpers.ts b/apps/desktop/e2e/helpers.ts index 00bdcee..973cd3a 100644 --- a/apps/desktop/e2e/helpers.ts +++ b/apps/desktop/e2e/helpers.ts @@ -6,6 +6,13 @@ import { expect, type ElectronApplication, type Page } from "@playwright/test"; const PDFTOTEXT = "/opt/homebrew/bin/pdftotext"; +/** Opens the progressive-disclosure menu without closing it when it is already open. */ +export async function openPageActions(page: Page): Promise { + const menu = page.locator("details.workspace-menu"); + if ((await menu.getAttribute("open")) === null) + await menu.locator('summary[aria-label="Page actions"]').click(); +} + /** Answers the Save dialog with a file in a temp folder, so Download PDF can be driven without a person. */ export async function autoSaveDialog(app: ElectronApplication): Promise { const path = join(mkdtempSync(join(tmpdir(), "lucid-dl-")), "page.pdf"); @@ -21,6 +28,7 @@ export async function downloadPdf( path: string, timeoutMs = 90_000, ): Promise { + await openPageActions(page); await page.getByRole("button", { name: "Download PDF" }).click(); try { await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: timeoutMs }); @@ -77,6 +85,7 @@ export async function maximizeWindow( /** Opens the History panel unless it is open already: a wide (maximized) window opens it by itself. */ export async function showHistory(page: Page): Promise { + await openPageActions(page); const button = page.getByRole("button", { name: /^History/ }); if ((await button.getAttribute("aria-pressed")) !== "true") await button.click(); await expect(page.getByRole("complementary", { name: "History" })).toBeVisible(); diff --git a/apps/desktop/e2e/history-revert.spec.ts b/apps/desktop/e2e/history-revert.spec.ts index 18b26df..243f2a6 100644 --- a/apps/desktop/e2e/history-revert.spec.ts +++ b/apps/desktop/e2e/history-revert.spec.ts @@ -63,7 +63,7 @@ const say = async (page: Page, text: string) => { await bar(page).press("Enter"); }; const command = (page: Page) => - page.getByRole("group", { name: "Mode" }).getByRole("button", { name: "Command" }); + page.getByRole("group", { name: "Mode" }).getByRole("button", { name: "Change page" }); const item = (page: Page, text: string) => page .locator('.page-editor [data-content-type="bulletListItem"]') @@ -122,7 +122,7 @@ test("one earlier change can be taken back while the others stay, as one undo st expect(await struck(page, "PostgreSQL")).toBe(true); // The way back is in the page file, so it is still there after a restart. - await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible({ + await expect(page.locator(".save-note")).toHaveAttribute("data-state", "saved", { timeout: 15_000, }); const file = readdirSync(home).find((n) => n !== ".lucid" && n.endsWith(".lucid")) ?? ""; @@ -186,17 +186,13 @@ test("a change cannot be taken back after the block was edited by hand", async ( const only = changes(page).first(); await expect(only.getByRole("button", { name: "Revert" })).toBeEnabled(); - // By hand, in Edit mode: type into that very list item. - await page.keyboard.press("Meta+e"); - await expect( - page.getByRole("group", { name: "Mode" }).getByRole("button", { name: "Edit" }), - ).toHaveAttribute("aria-pressed", "true"); - await item(page, "MongoDB").click(); - await page.keyboard.press("End"); + // Edit the visible word directly, without a mode switch or clicking the block's empty padding. + await item(page, "MongoDB").getByText("MongoDB", { exact: true }).dblclick(); + await page.keyboard.press("ArrowRight"); await page.keyboard.type(" Atlas"); // The typing has landed (the "Saved" shown before it started must not be taken for the one after it). await expect(item(page, "MongoDB Atlas")).toBeVisible(); - await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible({ + await expect(page.locator(".save-note")).toHaveAttribute("data-state", "saved", { timeout: 15_000, }); await expect(only.getByRole("button", { name: "Revert" })).toBeDisabled(); @@ -214,10 +210,10 @@ test("Show scrolls to what a change touched and lets it flash", async () => { await expect(page.getByText("Crossed out MongoDB.")).toBeVisible({ timeout: 20_000 }); await showHistory(page); await changes(page).first().getByRole("button", { name: "Show" }).click(); - await expect(page.locator('.page-editor [data-id="o2"].flash')).toBeVisible(); - await expect(page.locator('.page-editor [data-id="o2"].flash')).toHaveCount(0, { - timeout: 5_000, - }); + const target = page.locator('.page-editor [data-id="o2"]').first(); + await expect(target).toBeVisible(); + await expect.poll(() => target.evaluate((el) => el.getAnimations().length)).toBeGreaterThan(0); + await expect.poll(() => target.evaluate((el) => el.getAnimations().length)).toBe(0); } finally { await app.close(); } diff --git a/apps/desktop/e2e/mcp.spec.ts b/apps/desktop/e2e/mcp.spec.ts index 29e448c..22253b4 100644 --- a/apps/desktop/e2e/mcp.spec.ts +++ b/apps/desktop/e2e/mcp.spec.ts @@ -113,7 +113,7 @@ test("an agent reads and edits the open page through MCP: it shows up, is one un await expect(mongo).toHaveCSS("text-decoration-line", "line-through"); // The open page is saved by the editor, with the agent's entry in its history. - await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible({ + await expect(page.locator(".save-note")).toHaveAttribute("data-state", "saved", { timeout: 15_000, }); const saved = JSON.parse( diff --git a/apps/desktop/e2e/mode-shortcuts.spec.ts b/apps/desktop/e2e/mode-shortcuts.spec.ts index 551b5e3..ef3cc0c 100644 --- a/apps/desktop/e2e/mode-shortcuts.spec.ts +++ b/apps/desktop/e2e/mode-shortcuts.spec.ts @@ -28,7 +28,9 @@ async function launch(): Promise<{ app: ElectronApplication; page: Page }> { } const mode = (page: Page, name: "Input" | "Command" | "Edit"): Locator => - page.getByRole("group", { name: "Mode" }).getByRole("button", { name, exact: true }); + page + .getByRole("group", { name: "Mode" }) + .getByRole("button", { name: name === "Command" ? "Change page" : "Add notes", exact: true }); const builder = (page: Page, kind: "Diagram" | "Table"): Locator => page.getByRole("region", { name: `${kind} builder` }); diff --git a/apps/desktop/e2e/note-transfer.spec.ts b/apps/desktop/e2e/note-transfer.spec.ts index a93771e..f65d108 100644 --- a/apps/desktop/e2e/note-transfer.spec.ts +++ b/apps/desktop/e2e/note-transfer.spec.ts @@ -10,7 +10,7 @@ import { type ElectronApplication, type Page, } from "@playwright/test"; -import { maximizeWindow, showHistory } from "./helpers"; +import { maximizeWindow, openPageActions, showHistory } from "./helpers"; /** * A note leaves as one file with everything in it (what was said and the history too, unless the person leaves them out) @@ -132,6 +132,7 @@ test("Export note saves the whole note, with what was said and the history, or o // The whole note. await saveTo(app, join(out, "whole.lucid")); + await openPageActions(page); await page.getByRole("button", { name: "Export note" }).click(); await expect(exportDialog(page)).toBeVisible(); await expect(exportDialog(page)).toContainText("2 things you said"); @@ -148,6 +149,7 @@ test("Export note saves the whole note, with what was said and the history, or o // Only the notes: nothing of what was said, nothing of the history. await saveTo(app, join(out, "clean.lucid")); + await openPageActions(page); await page.getByRole("button", { name: "Export note" }).click(); await exportDialog(page).getByLabel("Include what I said and the change history").uncheck(); await expect(exportDialog(page)).toContainText("stay with you"); @@ -173,12 +175,14 @@ test("Export note: cancelling saves nothing, and the Lucid folder itself is refu await app.evaluate(({ dialog }) => { dialog.showSaveDialog = () => Promise.resolve({ canceled: true, filePath: "" }); }); + await openPageActions(page); await page.getByRole("button", { name: "Export note" }).click(); await exportDialog(page).getByRole("button", { name: "Export…" }).click(); await expect(exportDialog(page)).toHaveCount(0); await expect(page.getByText(/^Saved .*\.lucid/)).toHaveCount(0); await saveTo(app, join(home, "inside.lucid")); + await openPageActions(page); await page.getByRole("button", { name: "Export note" }).click(); await exportDialog(page).getByRole("button", { name: "Export…" }).click(); await expect(page.getByText(/outside your Lucid folder/)).toBeVisible(); @@ -197,6 +201,7 @@ test("Import note brings a note into another library with its history, refuses b try { await seed(page); await saveTo(app, file); + await openPageActions(page); await page.getByRole("button", { name: "Export note" }).click(); await exportDialog(page).getByRole("button", { name: "Export…" }).click(); await expect(page.getByText("Saved scaling.lucid.")).toBeVisible(); diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts index 4cd9265..8927fb5 100644 --- a/apps/desktop/e2e/onboarding.spec.ts +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -25,25 +25,20 @@ test("a first run leads into a real first-success task", async () => { await window.reload(); await expect( - window.getByRole("heading", { name: "Speak or type the messy version." }), + window.getByRole("heading", { name: "Start with the thought, not the setup." }), ).toBeVisible(); - await window.getByRole("button", { name: "Set up Lucid" }).click(); + await window.getByRole("button", { name: "Connect AI" }).click(); - await expect(window.getByRole("heading", { name: "Connect an AI provider" })).toBeVisible(); + await expect(window.getByRole("heading", { name: "Connect AI" })).toBeVisible(); await expect(window.getByText("LM Studio or Ollama", { exact: false })).toBeVisible(); await window.getByRole("button", { name: "Continue without AI" }).click(); - await expect( - window.getByRole("heading", { name: "Everything happens around one page" }), - ).toBeVisible(); - await window.getByRole("button", { name: "Open my workspace" }).click(); - 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.getByRole("heading", { name: "Create a page" })).toBeVisible(); + await guide.getByRole("button", { name: "New page" }).click(); + await expect(guide.getByRole("heading", { name: "Write on the page" })).toBeVisible(); + await expect(guide.getByText("Click the page", { exact: false })).toBeVisible(); + await guide.getByRole("button", { name: "Skip tour", exact: true }).last().click(); await expect(guide).toBeHidden(); await expect .poll(() => window.evaluate(() => localStorage.getItem("lucid.onboarding"))) @@ -52,14 +47,20 @@ test("a first run leads into a real first-success task", async () => { .poll(() => window.evaluate(() => { const stored = localStorage.getItem("lucid.firstSuccess"); - return Boolean( - stored?.includes('"provider":"skipped"') && - stored.includes('"paused":true') && - stored.includes('"page-created"'), - ); + if (!stored) return null; + const progress = JSON.parse(stored) as { + provider: string; + paused: boolean; + milestones: string[]; + }; + return { + provider: progress.provider, + paused: progress.paused, + pageCreated: progress.milestones.includes("page-created"), + }; }), ) - .toBe(true); + .toEqual({ provider: "skipped", paused: true, pageCreated: true }); } finally { await app.close(); } diff --git a/apps/desktop/e2e/page-look.spec.ts b/apps/desktop/e2e/page-look.spec.ts index 5467952..920a183 100644 --- a/apps/desktop/e2e/page-look.spec.ts +++ b/apps/desktop/e2e/page-look.spec.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import type { Block } from "@lucid/core"; import { registry } from "@lucid/kit"; import { _electron as electron, expect, test, type Page } from "@playwright/test"; -import { maximizeWindow } from "./helpers"; +import { maximizeWindow, openPageActions } from "./helpers"; /** * The page (the document with its diagrams and tables) has a light and dark of its own, apart from the app's: a dark app can @@ -48,6 +48,7 @@ test("the page has its own light and dark, apart from the app's, and it is kept" .getByRole("button", { name: /Look/ }) .click(); await expect(page.locator(".page-editor svg g.node").first()).toBeVisible({ timeout: 20_000 }); + await openPageActions(page); const toggle = page.getByRole("button", { name: /^Look of the page:/ }); const nodeFill = () => @@ -84,6 +85,7 @@ test("the page has its own light and dark, apart from the app's, and it is kept" .getByRole("navigation", { name: "Pages" }) .getByRole("button", { name: /Look/ }) .click(); + await openPageActions(page); await expect(page.locator(".editor-scroll[data-page-look='dark']")).toHaveCount(1); // A light page in a dark app (paper), chosen in Settings: the app follows the system, here made dark. diff --git a/apps/desktop/e2e/paper-workspace.spec.ts b/apps/desktop/e2e/paper-workspace.spec.ts new file mode 100644 index 0000000..9fc063c --- /dev/null +++ b/apps/desktop/e2e/paper-workspace.spec.ts @@ -0,0 +1,50 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { _electron as electron, expect, test } from "@playwright/test"; + +test("writing stays focused, pages are recoverable, and tools fit a narrow window", async () => { + const app = await electron.launch({ + args: [join(__dirname, "../out/main/index.js")], + env: { ...process.env, LUCID_HOME: mkdtempSync(join(tmpdir(), "lucid-paper-")) }, + }); + try { + const page = await app.firstWindow(); + await page.emulateMedia({ colorScheme: "light" }); + await page.getByRole("button", { name: "+ New page", exact: true }).click(); + await page.getByLabel("Page title").fill("A useful page"); + await page.getByLabel("Page title").press("Enter"); + await page.keyboard.type("A thought worth keeping."); + await expect(page.getByText("A thought worth keeping.", { exact: true })).toBeVisible(); + await expect(page.locator(".dock")).toHaveCount(0); + await expect(page.getByRole("dialog", { name: "Pages" })).not.toBeVisible(); + await page.getByRole("button", { name: "Show pages", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Pages" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Pages" })).not.toBeVisible(); + await page.getByRole("button", { name: "Change page", exact: true }).click(); + await expect(page.locator(".dock")).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.locator(".dock")).toHaveCount(0); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(820, 700)); + const boxes = await page.locator(".topbar > :not(.spacer)").evaluateAll((elements) => + elements.map((element) => { + const r = element.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }; + }), + ); + for (let i = 1; i < boxes.length; i++) { + const a = boxes[i - 1]; + const b = boxes[i]; + if (!a || !b) throw new Error("Missing header bounds"); + expect(a.right <= b.left + 1 || a.bottom <= b.top + 1).toBeTruthy(); + } + await page.screenshot({ path: "test-results/paper-workspace-narrow.png" }); + await page.reload(); + await expect(page.getByLabel("Page title")).toHaveValue("A useful page"); + await expect(page.getByText("A thought worth keeping.", { exact: true })).toBeVisible(); + await expect(page.locator(".dock")).toHaveCount(0); + } finally { + await app.close(); + } +}); diff --git a/apps/desktop/e2e/pdf-first-run.spec.ts b/apps/desktop/e2e/pdf-first-run.spec.ts index 9451040..cc7c226 100644 --- a/apps/desktop/e2e/pdf-first-run.spec.ts +++ b/apps/desktop/e2e/pdf-first-run.spec.ts @@ -8,7 +8,7 @@ import { type ElectronApplication, type Page, } from "@playwright/test"; -import { autoSaveDialog, maximizeWindow } from "./helpers"; +import { autoSaveDialog, maximizeWindow, openPageActions } from "./helpers"; /** * The first PDF on a computer makes Tectonic download its typesetting files (hundreds, one at a time: about six minutes). The @@ -64,6 +64,7 @@ test("the first start prepares the PDF engine in the background and says so, wit // Ready: a PDF is made at once, without a download. await autoSaveDialog(app); + await page.locator('summary[aria-label="Page actions"]').click(); await page.getByRole("button", { name: "Download PDF" }).click(); await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: 30_000 }); } finally { @@ -77,6 +78,7 @@ test("a PDF asked for during the preparation waits for it, and shows what is hap try { await newPage(page, "Caching notes"); await autoSaveDialog(app); + await openPageActions(page); const button = page.getByRole("button", { name: /PDF/ }).last(); await button.click(); await expect(button).toHaveText(/Making PDF/); diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 29e1c1f..ee11251 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -15,9 +15,8 @@ test("a fresh install opens a window with an empty library and a New page button await expect(window).toHaveTitle("Lucid"); await expect(window.getByText("No pages yet.")).toBeVisible(); await expect(window.getByRole("button", { name: "+ New page" })).toBeVisible(); - // A person who has just installed Lucid is told what to do first. - await expect(window.getByText("Start LM Studio")).toBeVisible(); - await expect(window.getByText("Help → Lucid Guide")).toBeVisible(); + // The empty state stays focused on the only action that matters. + await expect(window.getByRole("heading", { name: "Start with a page." })).toBeVisible(); } finally { await app.close(); } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e4440a7..a1ff6ff 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -35,8 +35,6 @@ export function App() { return ; } -const SIDEBAR_KEY = "lucid.sidebar"; - function Shell() { const pages = usePages(); useEffect(() => { @@ -106,23 +104,15 @@ function Shell() { window.removeEventListener("keydown", onKey); }; }, []); - const [sidebarOpen, setSidebarOpen] = useState(() => { - try { - return localStorage.getItem(SIDEBAR_KEY) !== "closed"; - } catch { - return true; - } - }); + const [sidebarOpen, setSidebarOpen] = useState(false); + const library = useRef(null); const toggleSidebar = () => { - setSidebarOpen((open) => { - try { - localStorage.setItem(SIDEBAR_KEY, open ? "closed" : "open"); - } catch { - // Remembering is a convenience only. - } - return !open; - }); + setSidebarOpen((open) => !open); }; + useEffect(() => { + if (sidebarOpen) library.current?.showModal(); + else library.current?.close(); + }, [sidebarOpen]); const finishOnboarding = (startTour: boolean) => { completeOnboarding(); setOnboardingOpen(false); @@ -152,22 +142,47 @@ function Shell() { }, [activation.milestones, guideOpen, pages.current]); return (
- {sidebarOpen && ( + { + setSidebarOpen(false); + }} + onClick={(event) => { + if (event.target === event.currentTarget) setSidebarOpen(false); + }} + > void pages.open(id)} - onCreate={() => void createFirstPage()} + onOpen={(id) => { + void pages.open(id); + setSidebarOpen(false); + }} + onCreate={() => { + void createFirstPage(); + setSidebarOpen(false); + }} onCreateFrom={() => { + setSidebarOpen(false); setNewPageOpen(true); }} - onImport={() => void importNotes()} + onImport={() => { + setSidebarOpen(false); + void importNotes(); + }} onTrash={(id) => void pages.trash(id)} onHide={toggleSidebar} - onSettings={openSettings} + onSettings={() => { + setSidebarOpen(false); + openSettings(); + }} /> - )} +
{pages.error && (
@@ -194,29 +209,14 @@ function Shell() { /> ) : pages.ready ? (
-

Think out loud. Lucid writes it up.

-

- Say or type what you’re learning. Lucid turns it into notes, diagrams and tables, and - makes the PDF. -

+ +

Start with a page.

+

Write freely. Structure can come later.

-
    -
  1. - Start LM Studio and load a model. The pill at the top says when it is ready. -
  2. -
  3. - Make a page and say or type what you’re learning. -
  4. -
  5. - Download PDF when it looks right. The first one takes a few minutes: Lucid - gets its typesetting files once. -
  6. -
-

- New here? Help → Lucid Guide explains every part of the window. -

) : null}
diff --git a/apps/desktop/src/dictation/DictationBar.tsx b/apps/desktop/src/dictation/DictationBar.tsx index 1e4f9e4..68c2b8d 100644 --- a/apps/desktop/src/dictation/DictationBar.tsx +++ b/apps/desktop/src/dictation/DictationBar.tsx @@ -31,8 +31,8 @@ interface Props { } const PLACEHOLDER: Record = { - input: "Speak or type your notes…", - command: "Say or type what to change…", + input: "Type a thought, or paste your dictation…", + command: "Describe what to change on this page…", }; const BUILDER_PLACEHOLDER = { @@ -53,8 +53,10 @@ const BUILDER_PLACEHOLDER = { const BUILD_NAME = { diagram: "Diagram", table: "Table" } as const; const PILL_TITLE: Record = { - input: keys("Input (⌘1): your words become notes. ⌘/ switches between Input and Command"), - command: keys("Command (⌘2): your words change the page. Hold the right ⌥ key for one command"), + input: keys("Add notes (⌘1): your words become notes. ⌘/ switches actions"), + command: keys( + "Change page (⌘2): your words change the page. Hold the right ⌥ key for one command", + ), edit: keys("Edit (⌘3, or ⌘E): change the page by hand. Esc goes back"), }; @@ -355,13 +357,13 @@ export function DictationBar({
- {(["input", "command", "edit"] as const).map((m) => ( + {(["input", "command"] as const).map((m) => ( ))} - {onBuild && - (["diagram", "table"] as const).map((kind) => ( - - ))} + }} + > + Insert + +
+ {(["diagram", "table"] as const).map((kind) => ( + + ))} +
+ + )}
- {mode === "edit" && !builder ? ( -

{keys("Editing by hand. Esc (or ⌘E) goes back.")}

- ) : ( - <> -