diff --git a/web/tests/integration/notes-source-motion-browser.mjs b/web/tests/integration/notes-source-motion-browser.mjs
new file mode 100644
index 0000000..4ae3342
--- /dev/null
+++ b/web/tests/integration/notes-source-motion-browser.mjs
@@ -0,0 +1,574 @@
+// Genuine encrypted Notes source motion/placement regression driver; synthetic disposable contexts only.
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve, join } from "node:path";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+const here = dirname(fileURLToPath(import.meta.url));
+const web = resolve(here, "../..");
+const require = createRequire(join(web, "package.json"));
+const { chromium, expect } = require("@playwright/test");
+const focusOnly = process.argv.includes("--focus-only");
+const base = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179";
+const output =
+ process.env.NOTES_UI_OUTPUT ??
+ resolve(web, "test-results/notes-source-motion-browser");
+await mkdir(output, { recursive: true });
+const sources = [
+ "src/ui/RichEditor.tsx",
+ "src/ui/SourceCardComposer.tsx",
+ "src/ui/NoteEditor.tsx",
+ "src/styles/study-source.css",
+ "src/ui/primitives.tsx",
+ "src/styles/app.css",
+ "tests/integration/study-source-browser.tsx",
+];
+async function hashes() {
+ return Promise.all(
+ sources.map(async (path) => ({
+ path,
+ sha256: createHash("sha256")
+ .update(await readFile(join(web, path)))
+ .digest("hex"),
+ })),
+ );
+}
+const before = await hashes(),
+ runs = [];
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+let activePage;
+async function stable(locator) {
+ await locator.evaluate(async (element) => {
+ await Promise.all(
+ element
+ .getAnimations({ subtree: true })
+ .map((animation) => animation.finished.catch(() => {})),
+ );
+ });
+ await expect(locator).toHaveCSS("opacity", "1");
+}
+async function sample(page, selector) {
+ return page
+ .locator(selector)
+ .first()
+ .evaluateAll((elements) => {
+ const element = elements[0];
+ if (!element) return { present: false };
+ const css = getComputedStyle(element),
+ rect = element.getBoundingClientRect();
+ return {
+ present: true,
+ classes: element.className,
+ inert: element.inert,
+ ariaHidden: element.getAttribute("aria-hidden"),
+ opacity: css.opacity,
+ transform: css.transform,
+ animation: css.animationName,
+ duration: css.animationDuration,
+ easing: css.animationTimingFunction,
+ pointerEvents: css.pointerEvents,
+ borderColor: css.borderBottomColor,
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
+ activeInside: element.contains(document.activeElement),
+ focusable: [
+ ...element.querySelectorAll("button,textarea,select,input"),
+ ].filter((control) => !control.disabled).length,
+ };
+ });
+}
+async function mark(page, value) {
+ await page.evaluate((value) => (window.__sourceAudit.phase = value), value);
+}
+async function selection(page) {
+ const editor = page.getByLabel("Note content", { exact: true });
+ await editor.click();
+ await editor.press("Control+Home");
+ for (let i = 0; i < 7; i++) await editor.press("ArrowRight");
+ for (let i = 0; i < 14; i++) await editor.press("Shift+ArrowRight");
+ await expect(
+ page.getByRole("toolbar", { name: "Selection tools", exact: true }),
+ ).toBeVisible();
+}
+async function open(page) {
+ await selection(page);
+ await page
+ .getByRole("toolbar", { name: "Selection tools", exact: true })
+ .getByRole("button", { name: "Make a card", exact: true })
+ .click();
+ const dialog = page.getByRole("dialog", {
+ name: "Make a card",
+ exact: true,
+ includeHidden: true,
+ });
+ await expect(dialog).toBeVisible();
+ return dialog;
+}
+function row(events, name, phase) {
+ return events.filter(
+ (event) =>
+ event.kind === "animationend" &&
+ event.name === name &&
+ (!phase || event.phase === phase),
+ );
+}
+try {
+ for (const mode of [
+ { id: "normal", os: "no-preference", motion: "system" },
+ { id: "os-reduced", os: "reduce", motion: "system" },
+ { id: "explicit-on-os-reduced", os: "reduce", motion: "on" },
+ { id: "explicit-reduced", os: "no-preference", motion: "reduced" },
+ ].slice(0, focusOnly ? 1 : 4)) {
+ const reduced =
+ mode.motion === "reduced" ||
+ (mode.os === "reduce" && mode.motion !== "on");
+ const context = await browser.newContext({
+ viewport: { width: 1160, height: 850 },
+ reducedMotion: mode.os,
+ });
+ await context.addInitScript(
+ (motion) => localStorage.setItem("notes.motion", motion),
+ mode.motion,
+ );
+ const page = await context.newPage();
+ activePage = page;
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(`${base}/tests/integration/study-source-browser.html`);
+ await page.waitForFunction(() => window.__studySourceFixture);
+ const setup = page.getByRole("dialog", {
+ name: "Set up Orbyn Notes",
+ exact: true,
+ });
+ await expect(setup).toBeVisible();
+ {
+ await setup.getByRole("button", { name: "Skip", exact: true }).click();
+ await setup.getByRole("button", { name: "Skip", exact: true }).click();
+ await expect(setup).toHaveCount(0);
+ }
+ await expect
+ .poll(() =>
+ page.evaluate(async () =>
+ (await import("/src/desktop/closeGuard.ts")).checkClose(),
+ ),
+ )
+ .toBe("allowed");
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await page
+ .getByRole("button", { name: "New deck", exact: true })
+ .first()
+ .click();
+ let dialog = page.getByRole("dialog", { name: "New deck", exact: true });
+ await dialog.getByLabel("Name").fill("Motion audit deck");
+ await dialog
+ .getByRole("button", { name: "Create deck", exact: true })
+ .click();
+ await expect(dialog).toHaveCount(0);
+ await page.getByRole("button", { name: /^All notes/ }).click();
+ await page
+ .getByRole("button", { name: "New note", exact: true })
+ .first()
+ .click();
+ await page.getByLabel("Note title").fill("Synthetic source motion audit");
+ const editor = page.getByLabel("Note content", { exact: true });
+ await editor.click();
+ await page.keyboard.type("Before NADH and FADH2 after.");
+ await expect(
+ page.getByText("Saved in this browser", { exact: true }),
+ ).toBeVisible();
+ await page.evaluate(() => {
+ const audit = (window.__sourceAudit = {
+ phase: "ready",
+ events: [],
+ frames: [],
+ dom: [],
+ }),
+ ids = new WeakMap();
+ let next = 0;
+ const selector =
+ ".source-card-popover,.source-card-notice,.study-source-annotation,.study-selection-toolbar";
+ const id = (element) => {
+ if (!ids.has(element)) ids.set(element, ++next);
+ return ids.get(element);
+ };
+ const snapshot = (element) => {
+ const css = getComputedStyle(element);
+ return {
+ id: id(element),
+ at: performance.now(),
+ phase: audit.phase,
+ classes: element.className,
+ connected: element.isConnected,
+ opacity: css.opacity,
+ transform: css.transform,
+ borderColor: css.borderBottomColor,
+ animation: css.animationName,
+ duration: css.animationDuration,
+ easing: css.animationTimingFunction,
+ pointerEvents: css.pointerEvents,
+ inert: element.inert,
+ ariaHidden: element.getAttribute("aria-hidden"),
+ };
+ };
+ for (const kind of [
+ "animationstart",
+ "animationend",
+ "animationcancel",
+ ]) {
+ document.addEventListener(
+ kind,
+ (event) => {
+ const element = event.target;
+ if (!(element instanceof Element) || !element.matches(selector))
+ return;
+ audit.events.push({
+ ...snapshot(element),
+ kind,
+ name: event.animationName,
+ elapsed: event.elapsedTime,
+ keyframes: element.getAnimations().flatMap((animation) =>
+ animation.effect
+ .getKeyframes()
+ .map(({ opacity, transform, borderBottomColor, offset }) => ({
+ opacity,
+ transform,
+ borderBottomColor,
+ offset,
+ })),
+ ),
+ });
+ if (kind === "animationstart") {
+ const deadline = performance.now() + 450;
+ let frames = 0;
+ const frame = () => {
+ if (
+ element.isConnected &&
+ performance.now() < deadline &&
+ frames++ < 40
+ ) {
+ audit.frames.push(snapshot(element));
+ requestAnimationFrame(frame);
+ }
+ };
+ requestAnimationFrame(frame);
+ }
+ },
+ true,
+ );
+ }
+ new MutationObserver((records) => {
+ for (const record of records) {
+ if (record.type === "attributes" && record.target.matches(selector))
+ audit.dom.push({ ...snapshot(record.target), kind: "attribute" });
+ for (const [nodes, kind] of [
+ [record.addedNodes, "added"],
+ [record.removedNodes, "removed"],
+ ])
+ for (const node of nodes) {
+ if (!(node instanceof Element)) continue;
+ for (const element of [node, ...node.querySelectorAll(selector)])
+ if (element.matches(selector))
+ audit.dom.push({ ...snapshot(element), kind });
+ }
+ }
+ }).observe(document.getElementById("root"), {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ["class", "inert", "aria-hidden"],
+ });
+ });
+ const observations = {
+ mode,
+ policy: await page.evaluate(() => ({
+ effective: document.documentElement.dataset.motion,
+ os: matchMedia("(prefers-reduced-motion: reduce)").matches,
+ })),
+ };
+ await editor.evaluate((node) => {
+ window.__sourceEditorBefore = node;
+ window.__sourceHTMLBefore = node.innerHTML;
+ });
+ await mark(page, "clean-escape");
+ dialog = await open(page);
+ observations.enter = await sample(page, ".source-card-popover");
+ await stable(dialog);
+ await expect(
+ dialog.getByRole("textbox", { name: "Front", exact: true }),
+ "Opening Front receives focus after visible placement",
+ ).toBeFocused();
+ await page.screenshot({ path: join(output, `${mode.id}-popover.png`) });
+ if (focusOnly) {
+ runs.push({ mode, openingFrontFocused: true, pageErrors: errors });
+ await context.close();
+ break;
+ }
+ await dialog
+ .getByRole("textbox", { name: "Front", exact: true })
+ .press("Escape");
+ observations.escapeClosing = await sample(page, ".source-card-popover");
+ expect(observations.escapeClosing).toMatchObject({
+ inert: true,
+ ariaHidden: "true",
+ pointerEvents: "none",
+ });
+ await expect(dialog).toHaveCount(0);
+ observations.escapeFocusImmediate = await page.evaluate(() => ({
+ label: document.activeElement?.getAttribute("aria-label"),
+ selectedPassage: window.getSelection()?.toString() === "NADH and FADH2",
+ nativeSelection: window.getSelection()?.toString(),
+ activeTag: document.activeElement?.tagName,
+ }));
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ observations.escapeFocusSettled = await page.evaluate(() => ({
+ label: document.activeElement?.getAttribute("aria-label"),
+ selectedPassage: window.getSelection()?.toString() === "NADH and FADH2",
+ nativeSelection: window.getSelection()?.toString(),
+ activeTag: document.activeElement?.tagName,
+ }));
+ await writeFile(
+ join(output, `${mode.id}-escape-state.json`),
+ JSON.stringify(observations.escapeFocusSettled, null, 2),
+ );
+ await expect(editor).toBeFocused();
+ expect(observations.escapeFocusSettled.selectedPassage).toBe(true);
+ observations.editorIdentity = await editor.evaluate((node) => ({
+ sameDOM: node === window.__sourceEditorBefore,
+ sameHTML: node.innerHTML === window.__sourceHTMLBefore,
+ pmSelection: node.pmViewDesc?.view?.state?.selection
+ ? {
+ from: node.pmViewDesc.view.state.selection.from,
+ to: node.pmViewDesc.view.state.selection.to,
+ }
+ : null,
+ }));
+ expect(observations.editorIdentity.sameDOM).toBe(true);
+ expect(observations.editorIdentity.sameHTML).toBe(true);
+ await mark(page, "clean-outside");
+ dialog = await open(page);
+ await stable(dialog);
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ observations.outsideAfterClick = await sample(page, ".source-card-popover");
+ expect(observations.outsideAfterClick).toMatchObject({
+ present: true,
+ inert: true,
+ ariaHidden: "true",
+ });
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await expect(dialog).toHaveCount(0);
+ await expect(editor).toBeVisible();
+ await expect(
+ page.getByRole("status").filter({
+ hasText: "Finish or cancel this card draft before leaving.",
+ }),
+ ).toHaveCount(0);
+ await expect(
+ page
+ .getByRole("status")
+ .filter({ hasText: "Your latest changes are only in this window." }),
+ ).toHaveCount(0);
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await expect(
+ page.getByRole("heading", { name: "Motion audit deck", exact: true }),
+ )
+ .toBeVisible()
+ .catch(async () => {
+ await expect(
+ page.getByRole("button", { name: /Motion audit deck/ }),
+ ).toBeVisible();
+ });
+ await page.getByRole("button", { name: /^All notes/ }).click();
+ await page
+ .getByRole("button", { name: /Synthetic source motion audit/ })
+ .first()
+ .click();
+ await expect(editor).toBeVisible();
+ observations.freshNavigation = true;
+ await mark(page, "dirty-escape");
+ dialog = await open(page);
+ await stable(dialog);
+ await dialog
+ .getByRole("textbox", { name: "Front", exact: true })
+ .fill("User-authored audit question");
+ await dialog
+ .getByRole("textbox", { name: "Front", exact: true })
+ .press("Escape");
+ const discard = page.getByRole("dialog", {
+ name: "Discard card draft?",
+ exact: true,
+ });
+ await expect(discard).toBeVisible();
+ await discard
+ .getByRole("button", { name: "Keep editing", exact: true })
+ .click();
+ await expect(
+ dialog.getByRole("textbox", { name: "Front", exact: true }),
+ ).toHaveValue("User-authored audit question");
+ observations.dirtyKept = true;
+ await mark(page, "commit");
+ await dialog.getByRole("button", { name: "Add card", exact: true }).click();
+ observations.commitClosing = await sample(page, ".source-card-popover");
+ await expect(dialog).toHaveCount(0);
+ await expect(page.locator(".study-source-annotation")).toHaveCount(1);
+ const meta = await page.evaluate(() =>
+ window.__studySourceFixture.metadata(),
+ );
+ observations.actualCounts = {
+ entries: meta.entries.length,
+ cards: meta.cards.length,
+ notes: meta.notes.length,
+ };
+ expect(observations.actualCounts.entries).toBe(1);
+ expect(observations.actualCounts.cards).toBe(1);
+ await expect(page.locator(".source-card-notice")).toContainText(
+ "Card added to Motion audit deck",
+ );
+ observations.annotation = await sample(page, ".study-source-annotation");
+ await stable(page.locator(".source-card-notice"));
+ await page.screenshot({ path: join(output, `${mode.id}-added.png`) });
+ await mark(page, "toast-dismiss");
+ await page
+ .getByRole("button", { name: "Dismiss card notification", exact: true })
+ .click();
+ observations.toastClosing = await sample(page, ".source-card-notice");
+ await expect(page.locator(".source-card-notice")).toHaveCount(0);
+ await mark(page, "narrow");
+ await page.setViewportSize({ width: 390, height: 844 });
+ dialog = await open(page);
+ await stable(dialog);
+ observations.narrow = await sample(page, ".source-card-popover");
+ await dialog
+ .getByRole("button", { name: "Add card", exact: true })
+ .scrollIntoViewIfNeeded();
+ observations.narrowActions = {
+ cancel: await dialog
+ .getByRole("button", { name: "Cancel", exact: true })
+ .isVisible(),
+ add: await dialog
+ .getByRole("button", { name: "Add card", exact: true })
+ .isVisible(),
+ overflow: await page.evaluate(
+ () => document.documentElement.scrollWidth > innerWidth,
+ ),
+ };
+ await page.screenshot({ path: join(output, `${mode.id}-narrow.png`) });
+ await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(dialog).toHaveCount(0);
+ const trace = await page.evaluate(() => window.__sourceAudit);
+ observations.expected = {
+ reduced,
+ popEnterMs: reduced ? 150 : 200,
+ popExitMs: reduced ? 150 : 140,
+ underlineMs: reduced ? 0 : 150,
+ };
+ observations.measured = {
+ enterEnds: trace.events.filter(
+ (event) =>
+ event.phase === "clean-escape" &&
+ event.kind === "animationend" &&
+ !event.classes.includes("source-closing"),
+ ),
+ escapeExitEnds: trace.events.filter(
+ (event) =>
+ event.phase === "clean-escape" &&
+ event.kind === "animationend" &&
+ event.classes.includes("source-closing"),
+ ),
+ outsideExitEnds: trace.events.filter(
+ (event) =>
+ event.phase === "clean-outside" &&
+ event.kind === "animationend" &&
+ event.classes.includes("source-closing"),
+ ),
+ underlineEnds: row(trace.events, "source-mark-in"),
+ toastExitEnds: trace.events.filter(
+ (event) =>
+ event.phase === "toast-dismiss" && event.kind === "animationend",
+ ),
+ };
+ const enter = observations.measured.enterEnds.find(
+ (event) => event.name === (reduced ? "source-fade" : "source-pop-in"),
+ );
+ expect(enter).toBeTruthy();
+ expect(enter.elapsed).toBeCloseTo(reduced ? 0.15 : 0.2, 3);
+ const exit = observations.measured.escapeExitEnds.find(
+ (event) =>
+ event.name === (reduced ? "source-fade-out" : "source-pop-out"),
+ );
+ expect(exit).toBeTruthy();
+ expect(exit.elapsed).toBeCloseTo(reduced ? 0.15 : 0.14, 3);
+ const outsideExit = observations.measured.outsideExitEnds.find(
+ (event) =>
+ event.name === (reduced ? "source-fade-out" : "source-pop-out"),
+ );
+ expect(outsideExit).toBeTruthy();
+ expect(outsideExit.elapsed).toBeCloseTo(reduced ? 0.15 : 0.14, 3);
+ expect(observations.annotation.animation).toBe(
+ reduced ? "none" : "source-mark-in",
+ );
+ if (!reduced)
+ expect(observations.measured.underlineEnds[0].elapsed).toBeCloseTo(
+ 0.15,
+ 3,
+ );
+ expect(observations.narrowActions.overflow).toBe(false);
+ expect(errors).toEqual([]);
+ runs.push({ ...observations, trace, pageErrors: errors });
+ await writeFile(
+ join(output, `${mode.id}.json`),
+ JSON.stringify(runs.at(-1), null, 2),
+ );
+ await context.close();
+ }
+ const after = await hashes();
+ const drift = before.filter(
+ (item, index) => item.sha256 !== after[index].sha256,
+ );
+ await writeFile(
+ join(output, "result.json"),
+ JSON.stringify(
+ {
+ qualification:
+ "Disposable synthetic identity; actual production Workspace/editor/source writer/WASM/IDB; no backend login/native input claim",
+ before,
+ after,
+ drift,
+ runs: runs.map(({ trace, ...run }) => run),
+ },
+ null,
+ 2,
+ ),
+ );
+ if (drift.length)
+ throw Error("Source drift: evidence is not a single candidate");
+ console.log(
+ `Source motion audit captured ${runs.length} policy contexts in ${output}`,
+ );
+} catch (error) {
+ if (activePage && !activePage.isClosed()) {
+ await activePage.screenshot({ path: join(output, "failure.png") });
+ await writeFile(
+ join(output, "failure-state.json"),
+ JSON.stringify(
+ await activePage.evaluate(() => ({
+ trace: window.__sourceAudit,
+ body: document.body.innerText,
+ })),
+ null,
+ 2,
+ ),
+ );
+ }
+ await writeFile(join(output, "failure.txt"), String(error));
+ await writeFile(
+ join(output, "failure-hashes.json"),
+ JSON.stringify({ before, after: await hashes() }, null, 2),
+ );
+ throw error;
+} finally {
+ await browser.close();
+}
diff --git a/web/tests/integration/source-opening-browser.mjs b/web/tests/integration/source-opening-browser.mjs
new file mode 100644
index 0000000..a978493
--- /dev/null
+++ b/web/tests/integration/source-opening-browser.mjs
@@ -0,0 +1,79 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+const base = new URL(process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179");
+if (!["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))
+ throw new Error("Source-opening proof requires a loopback preview.");
+const source = new URL("/tests/integration/source-proof.html", base).href;
+const output =
+ process.env.NOTES_UI_OUTPUT ?? "test-results/source-opening-browser";
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+try {
+ await mkdir(output, { recursive: true });
+ const context = await browser.newContext();
+ const requests = [],
+ errors = [];
+ context.on("request", (request) => {
+ if (request.url() === source) requests.push(request.url());
+ });
+ await context.addInitScript(() => localStorage.setItem("notes.editor.v1", JSON.stringify({textSize:"large",lineWidth:"wide",markdown:true,spellcheck:true})));
+ const page = await context.newPage();
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(new URL("/tests/integration/clips-browser.html", base).href);
+ await page.getByRole("button", { name: "Clips", exact: true }).click();
+ await page
+ .getByRole("button", { name: "Save a link", exact: true })
+ .first()
+ .click();
+ const dialog = page.getByRole("dialog", { name: "Save a link", exact: true });
+ await dialog.getByLabel("Clip URL").fill(source);
+ await dialog.getByLabel("Title (optional)").fill("Controlled saved source");
+ await dialog.getByLabel("Selected text (optional)").fill("Controlled pasted text for reader preferences.");
+ await dialog.getByRole("button", { name: "Save link", exact: true }).click();
+ const notice = page.getByRole("status").filter({ hasText: "Text clip saved" });
+ await expect(notice).toBeVisible();
+ await notice.getByRole("button", { name: "Open clip", exact: true }).click();
+ expect(requests).toHaveLength(0);
+ await expect(page.locator(".clip-text")).toHaveCSS("font-size", "16px");
+ await expect(page.locator(".clip-reader-content")).toHaveCSS("max-width", "880px");
+ const original = page.getByRole("link", {
+ name: "Open original",
+ exact: true,
+ });
+ await expect(original).toHaveAttribute("rel", "noopener noreferrer");
+ const pending = page.waitForEvent("popup");
+ await original.click();
+ const popup = await pending;
+ await expect(
+ popup.getByRole("heading", { name: "Controlled clip source", exact: true }),
+ ).toBeVisible();
+ expect(popup.url()).toBe(source);
+ expect(await popup.evaluate(() => window.opener === null)).toBe(true);
+ expect(requests).toHaveLength(1);
+ await expect(
+ page.getByRole("button", { name: "Mark read", exact: true }),
+ ).toBeVisible();
+ expect(errors).toEqual([]);
+ await popup.close();
+ await writeFile(
+ join(output, "result.json"),
+ JSON.stringify(
+ {
+ qualified:
+ "Actual browser source opening from a real-WASM/IndexedDB Clip under a synthetic account session. No native OS handoff or account-server qualification.",
+ noFetchOnSave: true,
+ oneExplicitLoopbackHandoff: true,
+ openerNull: true,
+ stillUnread: true,
+ pageErrors: errors,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log(
+ "Controlled browser source opening passed; no prefetch, opener null, clip remains unread.",
+ );
+} finally {
+ await browser.close();
+}
diff --git a/web/tests/integration/source-proof.html b/web/tests/integration/source-proof.html
new file mode 100644
index 0000000..1ba60fc
--- /dev/null
+++ b/web/tests/integration/source-proof.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Controlled clip source
+
+
+ Controlled clip source
+ This local test page is opened only by the explicit source action.
+
+
diff --git a/web/tests/integration/study-browser.mjs b/web/tests/integration/study-browser.mjs
new file mode 100644
index 0000000..4177091
--- /dev/null
+++ b/web/tests/integration/study-browser.mjs
@@ -0,0 +1,45 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+// Run with the local Vite server. Auth is a synthetic session; encryption/storage
+// are the actual production adapters. This does not qualify account-server entry.
+const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome", headless: true });
+const output = process.env.NOTES_UI_OUTPUT ?? "test-results/study-browser";
+try {
+ const page = await browser.newPage({ viewport: { width: 1160, height: 760 } });
+ const errors = [];
+ page.on("pageerror", error => errors.push(error.message));
+ await page.goto(`${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/collections-browser.html`);
+ await expect(page.getByText("No decks yet", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "New deck", exact: true }).first().click();
+ await page.getByRole("dialog", { name: "New deck" }).getByLabel("Name").fill("Encrypted browser study");
+ await page.getByRole("button", { name: "Create deck", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Encrypted browser study" })).toBeVisible();
+ await page.getByRole("button", { name: "Add card", exact: true }).first().click();
+ const dialog = page.getByRole("dialog", { name: "Add card" });
+ await dialog.getByLabel("Front", { exact: true }).fill("Synthetic question");
+ await dialog.getByLabel("Back", { exact: true }).fill("Synthetic answer");
+ await dialog.getByRole("button", { name: "Add card", exact: true }).click();
+ await expect(dialog).toHaveCount(0);
+ const before = await page.evaluate(() => window.__collectionsFixture.read());
+ if (before.study.cards.length !== 1) throw new Error("Card not committed");
+ await page.reload();
+ await expect(page.getByRole("button", { name: "Encrypted browser study", exact: true })).toBeVisible();
+ await page.getByRole("button", { name: /^Study 1 card$/ }).click();
+ await page.getByRole("button", { name: "Show answer", exact: true }).click();
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect(page.getByRole("heading", { name: "Done for today" })).toBeVisible();
+ const after = await page.evaluate(() => window.__collectionsFixture.read());
+ if (after.study.events.length !== 1 || after.study.cards[0].schedule.state !== "review") throw new Error("Grade not persisted");
+ await page.reload();
+ await expect(page.getByRole("button", { name: "Encrypted browser study", exact: true })).toBeVisible();
+ const persisted = await page.evaluate(() => window.__collectionsFixture.read());
+ if (persisted.study.events.length !== 1) throw new Error("Review lost on reopen");
+ await page.evaluate(() => window.__collectionsFixture.erase());
+ await expect(page.getByText("No decks yet", { exact: true })).toBeVisible();
+ if (errors.length) throw new Error(errors.join("\n"));
+ await mkdir(output, { recursive: true });
+ await writeFile(join(output, "result.json"), JSON.stringify({ harness: "Synthetic account session; real WASM + IndexedDB; no account-server qualification", passed: ["create deck", "create card", "reopen persisted card", "grade changes schedule", "reopen review event", "erase invalidates view"], errors }, null, 2));
+ console.log("Encrypted Study browser journey passed: create, reopen, grade, reopen, erase.");
+} finally { await browser.close(); }
diff --git a/web/tests/integration/study-motion-audit.mjs b/web/tests/integration/study-motion-audit.mjs
new file mode 100644
index 0000000..44eec8e
--- /dev/null
+++ b/web/tests/integration/study-motion-audit.mjs
@@ -0,0 +1,206 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile, readFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+
+const output = process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-audit";
+const preview = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179";
+await mkdir(output, { recursive: true });
+const sourcePaths = ["src/ui/StudyView.tsx", "src/styles/study.css"];
+const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
+const sourceBefore = await Promise.all(
+ sourcePaths.map(async (path) => ({
+ path,
+ sha256: hash(await readFile(path)),
+ })),
+);
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+const results = [];
+try {
+ for (const reduced of [false, true]) {
+ const context = await browser.newContext({
+ reducedMotion: reduced ? "reduce" : "no-preference",
+ viewport: { width: 1160, height: 850 },
+ });
+ try {
+ const page = await context.newPage(),
+ errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(
+ `${preview}/tests/integration/study-settings-browser.html`,
+ );
+ await expect(
+ page.getByRole("heading", { name: "All notes", exact: true }),
+ ).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(async () =>
+ (await import("/src/desktop/closeGuard.ts")).checkClose(),
+ ),
+ )
+ .toBe("allowed");
+ await page.evaluate(() => {
+ window.__studyMotionAudit = [];
+ const observe = (element, phase) => {
+ if (!(element instanceof Element)) return;
+ const css = getComputedStyle(element);
+ window.__studyMotionAudit.push({
+ phase,
+ at: performance.now(),
+ kind: element.classList.contains("study-toast") ? "toast" : "card",
+ label: element.getAttribute("aria-label"),
+ duration: css.animationDuration,
+ animation: css.animationName,
+ frames: element.getAnimations().flatMap((animation) =>
+ animation.effect
+ .getKeyframes()
+ .map(({ opacity, transform, offset }) => ({
+ opacity,
+ transform,
+ offset,
+ })),
+ ),
+ });
+ };
+ new MutationObserver((records) => {
+ for (const record of records) {
+ for (const [nodes, phase] of [
+ [record.addedNodes, "added"],
+ [record.removedNodes, "removed"],
+ ]) {
+ for (const node of nodes) {
+ if (!(node instanceof Element)) continue;
+ if (node.matches(".study-flash,.study-toast"))
+ observe(node, phase);
+ for (const element of node.querySelectorAll(
+ ".study-flash,.study-toast",
+ ))
+ observe(element, phase);
+ }
+ }
+ }
+ }).observe(document.getElementById("root"), {
+ childList: true,
+ subtree: true,
+ });
+ });
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await page.getByRole("button", { name: /^Study 4 cards$/ }).click();
+ await expect(
+ page.getByRole("article", { name: "Card question" }),
+ ).toBeVisible();
+ await page
+ .getByRole("button", { name: "Show answer", exact: true })
+ .click();
+ await expect(
+ page.getByRole("article", { name: "Card answer" }),
+ ).toBeVisible();
+ const good = page.getByRole("button", { name: /^Good/ });
+ await good.focus();
+ await page.keyboard.press("Tab");
+ await page.keyboard.press("Shift+Tab");
+ const gradeStyle = await good.evaluate((element) => {
+ const css = getComputedStyle(element);
+ return {
+ transitionDuration: css.transitionDuration,
+ transitionProperty: css.transitionProperty,
+ transitionTimingFunction: css.transitionTimingFunction,
+ outlineWidth: css.outlineWidth,
+ outlineStyle: css.outlineStyle,
+ };
+ });
+ expect(gradeStyle.outlineStyle).toBe("solid");
+ expect(gradeStyle.outlineWidth).toBe("2px");
+ const box = await good.boundingBox();
+ expect(box).toBeTruthy();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await good.evaluate((element) =>
+ Promise.all(
+ element
+ .getAnimations()
+ .map((animation) => animation.finished.catch(() => undefined)),
+ ),
+ );
+ const pressedTransform = await good.evaluate(
+ (element) => getComputedStyle(element).transform,
+ );
+ expect(pressedTransform).toBe(
+ reduced ? "none" : "matrix(0.97, 0, 0, 0.97, 0, 0)",
+ );
+ await page.mouse.up();
+ await expect(
+ page.getByRole("article", { name: "Card question" }),
+ ).toBeVisible();
+ await expect(page.locator(".study-toast")).toBeVisible();
+ // Wait on the actual UI disappearance, rather than using a guessed sleep.
+ await expect(page.locator(".study-toast")).toHaveCount(0, {
+ timeout: 7000,
+ });
+ const timeline = await page.evaluate(() => window.__studyMotionAudit);
+ const entered = timeline.filter(
+ (item) => item.phase === "added" && item.kind === "card",
+ );
+ expect(entered.length).toBeGreaterThanOrEqual(3);
+ expect(entered.some((item) => item.label === "Card answer")).toBe(true);
+ if (!reduced)
+ expect(
+ entered.every(
+ (item) =>
+ item.animation === "study-answer-in" && item.duration === "0.15s",
+ ),
+ ).toBe(true);
+ else
+ expect(
+ entered.every((item) =>
+ item.frames.every(
+ (frame) => !frame.transform || frame.transform === "none",
+ ),
+ ),
+ ).toBe(true);
+ const toastEnter = timeline.find(
+ (item) => item.phase === "added" && item.kind === "toast",
+ ),
+ toastExit = timeline.find(
+ (item) => item.phase === "removed" && item.kind === "toast",
+ );
+ expect(toastEnter).toBeTruthy();
+ expect(toastExit.at - toastEnter.at).toBeGreaterThanOrEqual(4900);
+ expect(errors).toEqual([]);
+ results.push({
+ reduced,
+ gradeStyle,
+ pressedTransform,
+ timeline,
+ toastLifetimeMs: toastExit.at - toastEnter.at,
+ errors,
+ });
+ } finally {
+ await context.close();
+ }
+ }
+ const sourceAfter = await Promise.all(
+ sourcePaths.map(async (path) => ({
+ path,
+ sha256: hash(await readFile(path)),
+ })),
+ );
+ expect(sourceAfter).toEqual(sourceBefore);
+ await writeFile(
+ `${output}/result.json`,
+ JSON.stringify(
+ {
+ qualified:
+ "Disposable synthetic account; genuine App/WASM/IndexedDB. Browser presentation observations only; no native input, backend authentication or whole-app frame-rate claim.",
+ source: sourceBefore,
+ results,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log(
+ "Study motion baseline passed: keyed card/answer entry, grade feedback styles, actual toast expiry and reduced-motion paths observed.",
+ );
+} finally {
+ await browser.close();
+}
diff --git a/web/tests/integration/study-motion-browser.mjs b/web/tests/integration/study-motion-browser.mjs
new file mode 100644
index 0000000..870a299
--- /dev/null
+++ b/web/tests/integration/study-motion-browser.mjs
@@ -0,0 +1,430 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+const output =
+ process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-browser";
+const base = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179";
+await mkdir(output, { recursive: true });
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+const results = [],
+ errors = [];
+let activePage;
+async function pageFor(context, path = "study-motion-component.html") {
+ const page = await context.newPage();
+ activePage = page;
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(`${base}/tests/integration/${path}`);
+ await page.waitForFunction(
+ () => window.__studyMotionFixture || window.__studySettingsFixture,
+ );
+ await page.evaluate(() => {
+ window.__motionEvents = [];
+ for (const kind of ["animationstart", "animationend"]) {
+ document.addEventListener(
+ kind,
+ (event) => {
+ if (!event.animationName.startsWith("study-")) return;
+ const element = event.target,
+ css = getComputedStyle(element);
+ window.__motionEvents.push({
+ kind,
+ name: event.animationName,
+ elapsed: event.elapsedTime,
+ at: performance.now(),
+ phase: element.dataset.studyMotion,
+ duration: css.animationDuration,
+ easing: css.animationTimingFunction,
+ frames: element
+ .getAnimations()
+ .flatMap((a) =>
+ a.effect
+ .getKeyframes()
+ .map(({ opacity, transform }) => ({ opacity, transform })),
+ ),
+ });
+ },
+ true,
+ );
+ }
+ });
+ return page;
+}
+async function settle(page, selector = ".study-flash") {
+ await page.locator(selector).evaluate(async (element) => {
+ await Promise.all(
+ element.getAnimations().map((a) => a.finished.catch(() => {})),
+ );
+ });
+}
+async function start(page) {
+ await page
+ .getByRole("button", { name: "Study 4 cards", exact: true })
+ .click();
+ await expect(
+ page.getByRole("article", { name: "Card question" }),
+ ).toBeVisible();
+ await settle(page);
+}
+async function reveal(page) {
+ await page.getByRole("button", { name: "Show answer", exact: true }).click();
+ await expect(
+ page.getByRole("article", { name: "Card answer" }),
+ ).toBeVisible();
+ await settle(page);
+}
+function timing(events, name, seconds) {
+ const ends = events.filter(
+ (event) => event.name === name && event.kind === "animationend",
+ );
+ expect(ends.length, `${name} actual completion`).toBeGreaterThan(0);
+ for (const event of ends) expect(event.elapsed).toBeCloseTo(seconds, 3);
+}
+try {
+ {
+ const context = await browser.newContext();
+ const page = await pageFor(context);
+ await start(page);
+ await page.evaluate(() => {
+ const early = (event) => {
+ if (
+ event.animationName !== "study-answer-in" ||
+ event.target.getAttribute("aria-label") !== "Card answer"
+ )
+ return;
+ document.removeEventListener("animationstart", early, true);
+ const good = [
+ ...document.querySelectorAll(".study-grades button"),
+ ].find((button) => button.textContent.startsWith("Good"));
+ window.__earlyGradeAttempt = {
+ disabled: good.disabled,
+ phase: event.target.dataset.studyMotion,
+ preview: good.querySelector("time").getAttribute("datetime"),
+ };
+ good.click();
+ event.target.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "1", bubbles: true }),
+ );
+ };
+ document.addEventListener("animationstart", early, true);
+ });
+ await reveal(page);
+ const attempted = await page.evaluate(() => window.__earlyGradeAttempt);
+ expect(attempted).toMatchObject({ disabled: true, phase: "answer-enter" });
+ await expect(page.getByRole("button", { name: /^Good/ })).toBeEnabled();
+ expect(
+ (await page.evaluate(() => window.__studyMotionFixture.metadata())).study
+ .events,
+ ).toHaveLength(0);
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ expect(
+ (await page.evaluate(() => window.__studyMotionFixture.metadata())).study
+ .events,
+ ).toHaveLength(0);
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect
+ .poll(
+ async () =>
+ (await page.evaluate(() => window.__studyMotionFixture.metadata()))
+ .study.events.length,
+ )
+ .toBe(1);
+ expect(
+ (await page.evaluate(() => window.__studyMotionFixture.metadata())).study
+ .events[0].after.schedule.due,
+ ).toBe(attempted.preview);
+ results.push({ stableAnswerAdmission: attempted, noQueuedGrade: true });
+ await context.close();
+ }
+ for (const reduced of [false, true]) {
+ const context = await browser.newContext({
+ viewport: { width: 1160, height: 850 },
+ reducedMotion: reduced ? "reduce" : "no-preference",
+ });
+ const page = await pageFor(context);
+ await start(page);
+ await reveal(page);
+ const before = await page.evaluate(() =>
+ window.__studyMotionFixture.metadata(),
+ );
+ for (const theme of ["light", "dark", "dim"]) {
+ await page.evaluate(
+ (value) => (document.documentElement.dataset.theme = value),
+ theme,
+ );
+ await page.screenshot({
+ path: `${output}/answer-${theme}-${reduced ? "reduced" : "normal"}.png`,
+ });
+ }
+ await page.setViewportSize({ width: 390, height: 780 });
+ await page.getByRole("button", { name: /^Easy/ }).scrollIntoViewIfNeeded();
+ await page.screenshot({
+ path: `${output}/answer-narrow-${reduced ? "reduced" : "normal"}.png`,
+ });
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ await page.setViewportSize({ width: 1160, height: 850 });
+ const preview = await page
+ .locator(".study-grades time")
+ .evaluateAll((elements) =>
+ elements.map((el) => el.getAttribute("datetime")),
+ );
+ await page.evaluate(() => window.__studyMotionFixture.holdNextCommit());
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.__studyMotionFixture.gates().commitReached),
+ )
+ .toBe(true);
+ expect(
+ await page.locator('[data-study-motion="confirmed-exit"]').count(),
+ ).toBe(0);
+ expect(
+ (await page.evaluate(() => window.__studyMotionFixture.metadata())).study
+ .events,
+ ).toEqual(before.study.events);
+ await page.evaluate(() => window.__studyMotionFixture.releaseCommit());
+ await expect(
+ page.getByRole("article", { name: "Card question" }),
+ ).toBeVisible();
+ await settle(page);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(
+ page.getByRole("article", { name: "Card answer" }),
+ ).toBeVisible();
+ await settle(page);
+ expect(
+ (await page.evaluate(() => window.__studyMotionFixture.metadata())).study
+ .events[0].undoneAt,
+ ).not.toBeNull();
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect(
+ page.getByRole("article", { name: "Card question" }),
+ ).toBeVisible();
+ await settle(page);
+ await expect(page.locator(".study-toast")).toHaveCount(0, {
+ timeout: 7000,
+ });
+ for (let i = 0; i < 3; i++) {
+ await reveal(page);
+ await page.getByRole("button", { name: /^Easy/ }).click();
+ if (i < 2) {
+ await expect(
+ page.getByRole("article", { name: "Card question" }),
+ ).toBeVisible();
+ await settle(page);
+ }
+ }
+ await expect(
+ page.getByRole("heading", { name: "Done for today", exact: true }),
+ ).toBeVisible();
+ await settle(page, ".study-done-label");
+ const events = await page.evaluate(() => window.__motionEvents);
+ timing(events, "study-reveal-out", 0.105);
+ timing(events, "study-answer-in", 0.15);
+ timing(events, "study-card-out", 0.14);
+ timing(events, "study-next-in", 0.2);
+ timing(events, "study-toast-out", 0.14);
+ if (!reduced) timing(events, "study-fade", 0.15);
+ const next = events.find(
+ (event) =>
+ event.name === "study-next-in" && event.kind === "animationstart",
+ );
+ expect(next.easing).toBe("cubic-bezier(0.2, 0.8, 0.2, 1)");
+ expect(
+ next.frames.some(
+ (frame) => frame.transform && frame.transform !== "none",
+ ),
+ ).toBe(!reduced);
+ if (reduced)
+ expect(
+ await page
+ .locator(".study-done-label")
+ .evaluate((el) => getComputedStyle(el).animationName),
+ ).toBe("none");
+ for (const theme of ["light", "dark", "dim"]) {
+ await page.evaluate(
+ (value) => (document.documentElement.dataset.theme = value),
+ theme,
+ );
+ await page.screenshot({
+ path: `${output}/done-${theme}-${reduced ? "reduced" : "normal"}.png`,
+ });
+ }
+ await page.setViewportSize({ width: 390, height: 780 });
+ await page.screenshot({
+ path: `${output}/done-narrow-${reduced ? "reduced" : "normal"}.png`,
+ });
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ results.push({
+ reduced,
+ events,
+ preview,
+ checks: [
+ "real precommit hold has no departure/event",
+ "real confirmation and Undo",
+ "actual105/140/150/200 completions",
+ "toast140exit",
+ "Done actual/reduced",
+ "themes/narrow",
+ ],
+ });
+ await context.close();
+ }
+ // Cold initial read acknowledgement remains genuine; only its delivery is held.
+ for (const reduced of [false, true]) {
+ const context = await browser.newContext({
+ reducedMotion: reduced ? "reduce" : "no-preference",
+ });
+ const page = await pageFor(
+ context,
+ "study-motion-component.html?holdRead=1",
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.__studyMotionFixture.gates().readReached),
+ )
+ .toBe(true);
+ await expect(page.getByLabel("Loading decks")).toBeVisible();
+ const ring = await page.locator(".study-loading-ring").evaluate((el) => ({
+ name: getComputedStyle(el).animationName,
+ duration: getComputedStyle(el).animationDuration,
+ }));
+ expect(ring).toEqual(
+ reduced
+ ? { name: "none", duration: "0s" }
+ : { name: "study-ring", duration: "0.9s" },
+ );
+ await page.evaluate(() => window.__studyMotionFixture.releaseRead());
+ await expect(
+ page.getByRole("button", { name: "Study 4 cards", exact: true }),
+ ).toBeVisible();
+ await expect(page.locator(".study-loader-exit")).toHaveCount(0);
+ const coldEvents = await page.evaluate(() => window.__motionEvents);
+ timing(coldEvents, "study-fade", 0.15);
+ timing(coldEvents, "study-loader-out", 0.15);
+ results.push({
+ coldReduced: reduced,
+ ring,
+ events: coldEvents,
+ });
+ await context.close();
+ }
+ // Synchronous privacy boundaries in fresh isolated owners, including held commit.
+ {
+ const context = await browser.newContext();
+ const page = await pageFor(context);
+ await start(page);
+ const edit = await page.evaluate(async () => {
+ const front = document.querySelector(".study-question").textContent;
+ const state = await window.__studyMotionFixture.metadata();
+ const entry = state.study.entries.find((entry) => entry.front === front);
+ document.querySelector(".study-flash .primary").click();
+ await Promise.resolve();
+ const admitted =
+ document.querySelector(".study-flash").dataset.studyMotion;
+ await window.__studyMotionFixture.editEntry(
+ entry.id,
+ "Genuine compatible edit",
+ );
+ return { admitted };
+ });
+ expect(edit.admitted).toBe("reveal-exit");
+ await expect(page.locator('[data-study-motion="reveal-exit"]')).toHaveCount(
+ 0,
+ );
+ await expect(page.locator(".study-question")).toHaveText(
+ "Genuine compatible edit",
+ );
+ results.push({ sameIdCompatibleEdit: edit });
+ await context.close();
+ }
+ for (const action of [
+ "closeAndInspect",
+ "lockAndInspect",
+ "reauthenticateAndInspect",
+ "eraseAndObserve",
+ ]) {
+ const context = await browser.newContext();
+ const page = await pageFor(context);
+ await start(page);
+ await reveal(page);
+ if (action === "closeAndInspect") {
+ await page.evaluate(() => window.__studyMotionFixture.holdNextCommit());
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => window.__studyMotionFixture.gates().commitReached,
+ ),
+ )
+ .toBe(true);
+ }
+ if (action === "eraseAndObserve")
+ await page.evaluate(() => window.__studyMotionFixture.watchEpoch());
+ const result = await page.evaluate(
+ (action) => window.__studyMotionFixture[action](),
+ action,
+ );
+ expect(result.noCardDom).toBe(true);
+ if (result.epochSamples)
+ expect(result.epochSamples.every(Boolean)).toBe(true);
+ if (action === "closeAndInspect") {
+ expect(
+ await page.evaluate(async () =>
+ (await import("/src/desktop/closeGuard.ts")).checkClose(),
+ ),
+ ).toBe("working");
+ await page.evaluate(() => window.__studyMotionFixture.releaseCommit());
+ await expect(
+ page.getByText("Study is no longer available.", { exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("button", { name: "Try again", exact: true })
+ .click();
+ await expect(
+ page.getByRole("button", { name: "Study 4 cards", exact: true }),
+ ).toBeVisible();
+ }
+ results.push({ privacyAction: action, result });
+ await context.close();
+ }
+ expect(errors).toEqual([]);
+ await writeFile(
+ `${output}/result.json`,
+ JSON.stringify({ results, pageErrors: errors }, null, 2),
+ );
+ console.log("Study motion genuine encrypted browser passed");
+} catch (error) {
+ if (activePage && !activePage.isClosed()) {
+ await activePage.screenshot({ path: `${output}/failure.png` });
+ await writeFile(
+ `${output}/failure-events.json`,
+ JSON.stringify(
+ await activePage.evaluate(() => ({
+ events: window.__motionEvents,
+ body: document.body.innerText,
+ })),
+ null,
+ 2,
+ ),
+ );
+ }
+ await writeFile(
+ `${output}/failure.txt`,
+ String(error) + "\n" + JSON.stringify({ results, errors }, null, 2),
+ );
+ throw error;
+} finally {
+ await browser.close();
+}
diff --git a/web/tests/integration/study-motion-component.html b/web/tests/integration/study-motion-component.html
new file mode 100644
index 0000000..13c00d3
--- /dev/null
+++ b/web/tests/integration/study-motion-component.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+ Synthetic identity · genuine Study owner/WASM/IndexedDB · controlled read
+ acknowledgement and pre-commit gates · no account backend or App-shell
+ qualification
+
+
+
+
+
diff --git a/web/tests/integration/study-motion-component.tsx b/web/tests/integration/study-motion-component.tsx
new file mode 100644
index 0000000..34a037c
--- /dev/null
+++ b/web/tests/integration/study-motion-component.tsx
@@ -0,0 +1,263 @@
+// Test-only direct production component with genuine account/store authority.
+// Gates delay actual read acknowledgements or admission to the real writer;
+// they never fabricate state, authority, commits or successful grades.
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { StudyView } from "../../src/ui/StudyView";
+import { applyInitialPreferences } from "../../src/ui/primitives";
+import { establish, lock } from "../../src/account/session";
+import { openNotesStore } from "../../src/data";
+import { loadEngine, DeviceKeys } from "../../src/data/engine";
+import {
+ newWrappingKey,
+ wrappingSecret,
+ readRecord,
+ mutateRecord,
+ type DeviceRecord,
+} from "../../src/data/database";
+import {
+ openCollectionsStore,
+ onCollectionsEpochChange,
+} from "../../src/collections/store";
+import type { CollectionsStore } from "../../src/collections/contracts";
+import { createDeck, saveEntry } from "../../src/study/model";
+import "../../src/styles/app.css";
+import "@fontsource/geist/400.css";
+import "@fontsource/geist/500.css";
+
+const identity = {
+ accountId: "c1111111-1111-4111-8111-111111111111",
+ deviceId: "c2222222-2222-4222-8222-222222222222",
+ email: "isolated-study-motion@example.test",
+ deviceState: "trusted" as const,
+};
+type Gate = {
+ claimed: boolean;
+ reached: boolean;
+ released: boolean;
+ all: boolean;
+ wait: Promise;
+ release: () => void;
+};
+function gate(all = false): Gate {
+ let resolveWait!: () => void;
+ const wait = new Promise((resolve) => {
+ resolveWait = resolve;
+ });
+ const value: Gate = {
+ claimed: false,
+ reached: false,
+ released: false,
+ all,
+ wait,
+ release: () => {
+ value.released = true;
+ resolveWait();
+ },
+ };
+ return value;
+}
+applyInitialPreferences();
+async function start() {
+ await loadEngine();
+ const fresh = !(await readRecord("devices", identity.email));
+ if (fresh) {
+ const wrapKey = await newWrappingKey(),
+ secret = await wrappingSecret(wrapKey),
+ keys = DeviceKeys.create();
+ try {
+ await mutateRecord("devices", identity.email, () => ({
+ ...identity,
+ wrapKey,
+ sealedKeys: keys.export(secret),
+ generation: 0,
+ verifiedTrust: true,
+ }));
+ } finally {
+ secret.fill(0);
+ keys.free();
+ }
+ }
+ establish(identity);
+ let notes = await openNotesStore(identity),
+ current = await openCollectionsStore(identity);
+ if (fresh) {
+ await notes.create({
+ title: "Motion fixture note",
+ tags: [],
+ document: { type: "doc", content: [{ type: "paragraph" }] },
+ });
+ const initial = await current.read(),
+ at = new Date("2026-01-01T12:00:00Z");
+ await current.update(initial, (state) => {
+ let study = createDeck(state.study, "Motion continuity", at);
+ for (let i = 1; i <= 4; i++)
+ study = saveEntry(
+ study,
+ {
+ id: crypto.randomUUID(),
+ deckId: study.decks[0]!.id,
+ revision: 1,
+ tags: [],
+ source: null,
+ type: "basic",
+ front: `Motion question ${i}`,
+ back: `Motion answer ${i}`,
+ },
+ at,
+ );
+ return { ...state, study };
+ });
+ }
+ let readGate: Gate | null = new URL(location.href).searchParams.has(
+ "holdRead",
+ )
+ ? gate(true)
+ : null,
+ commitGate: Gate | null = null,
+ actualRead: CollectionsStore["read"],
+ covered = false;
+ const epochSamples: boolean[] = [];
+ function wrap(store: CollectionsStore) {
+ const read = store.read.bind(store),
+ update = store.update.bind(store);
+ actualRead = read;
+ store.read = async () => {
+ const held =
+ readGate && !readGate.released && (readGate.all || !readGate.claimed)
+ ? readGate
+ : null;
+ if (held) held.claimed = true;
+ const result = await read();
+ if (held) {
+ held.reached = true;
+ await held.wait;
+ }
+ return result;
+ };
+ store.update = async (expected, change) => {
+ const held =
+ commitGate && !commitGate.released && !commitGate.claimed
+ ? commitGate
+ : null;
+ if (held) {
+ held.claimed = true;
+ held.reached = true;
+ await held.wait;
+ }
+ return update(expected, change);
+ };
+ return store;
+ }
+ current = wrap(current);
+ const root = createRoot(document.getElementById("root")!);
+ const noCardDom = () => document.querySelector(".study-flash") === null;
+ function render() {
+ root.render(
+
+ void reopen().catch(fail)}
+ />
+ ,
+ );
+ }
+ async function reopen() {
+ const next = await openCollectionsStore(identity),
+ nextNotes = await openNotesStore(identity);
+ current.close();
+ notes.close();
+ current = wrap(next);
+ notes = nextNotes;
+ render();
+ }
+ Object.assign(window, {
+ __studyMotionFixture: {
+ metadata: async () => {
+ const state = await actualRead();
+ return {
+ epoch: state.epoch,
+ revision: state.revision,
+ study: state.study,
+ };
+ },
+ holdNextRead: () => {
+ if (readGate && !readGate.released)
+ throw Error("A read gate is already pending.");
+ readGate = gate();
+ },
+ releaseRead: () => readGate?.release(),
+ holdNextCommit: () => {
+ if (commitGate && !commitGate.released)
+ throw Error("A commit gate is already pending.");
+ commitGate = gate();
+ },
+ releaseCommit: () => commitGate?.release(),
+ gates: () => ({
+ readReached: readGate?.reached ?? false,
+ commitReached: commitGate?.reached ?? false,
+ }),
+ closeAndInspect: () => {
+ current.close();
+ return { noCardDom: noCardDom() };
+ },
+ lockAndInspect: () => {
+ lock(false);
+ return { noCardDom: noCardDom() };
+ },
+ reauthenticateAndInspect: () => {
+ establish(identity);
+ return { noCardDom: noCardDom() };
+ },
+ watchEpoch: () =>
+ onCollectionsEpochChange(current, () => epochSamples.push(noCardDom())),
+ eraseAndObserve: async () => {
+ await notes.erase();
+ await actualRead();
+ return { noCardDom: noCardDom(), epochSamples: [...epochSamples] };
+ },
+ setCovered: (value: boolean) => {
+ covered = value;
+ render();
+ },
+ toggleStreak: async () => {
+ const state = await actualRead();
+ await current.update(state, (latest) => ({
+ ...latest,
+ study: {
+ ...latest.study,
+ preferences: {
+ ...latest.study.preferences,
+ showStreak: !latest.study.preferences.showStreak,
+ },
+ },
+ }));
+ },
+ editEntry: async (id: string, front: string) => {
+ const state = await actualRead(),
+ entry = state.study.entries.find((item) => item.id === id);
+ if (!entry || entry.type !== "basic")
+ throw Error("Choose a genuine basic fixture entry.");
+ await current.update(state, (latest) => ({
+ ...latest,
+ study: saveEntry(
+ latest.study,
+ { ...entry, revision: entry.revision + 1, front },
+ new Date(),
+ ),
+ }));
+ },
+ reopen,
+ },
+ });
+ render();
+}
+function fail(error: unknown) {
+ const root = document.getElementById("root")!;
+ root.dataset.fixtureFailed = "true";
+ root.textContent =
+ error instanceof Error ? error.message : "Motion fixture failed";
+}
+void start().catch(fail);
diff --git a/web/tests/integration/study-motion-fixture-smoke.mjs b/web/tests/integration/study-motion-fixture-smoke.mjs
new file mode 100644
index 0000000..b7ee482
--- /dev/null
+++ b/web/tests/integration/study-motion-fixture-smoke.mjs
@@ -0,0 +1,182 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile, readFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+
+const output =
+ process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-fixture-smoke";
+const base = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179";
+await mkdir(output, { recursive: true });
+const sourcePaths = [
+ "src/ui/StudyView.tsx",
+ "src/ui/useStudyMotion.ts",
+ "src/styles/study.css",
+];
+const fingerprint = () =>
+ Promise.all(
+ sourcePaths.map(async (path) => ({
+ path,
+ sha256: createHash("sha256")
+ .update(await readFile(path))
+ .digest("hex"),
+ })),
+ );
+const before = await fingerprint(),
+ results = [];
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+let stage = "boot";
+try {
+ for (const mode of [
+ "cold-and-reopen",
+ "lock",
+ "same-identity",
+ "epoch",
+ "held-commit",
+ "held-read",
+ ]) {
+ const context = await browser.newContext({
+ viewport: { width: 1160, height: 850 },
+ });
+ try {
+ const page = await context.newPage(),
+ errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ stage = mode + ":boot";
+ await page.goto(
+ `${base}/tests/integration/study-motion-component.html${mode === "cold-and-reopen" ? "?holdRead=1" : ""}`,
+ );
+ await page.waitForFunction(
+ () =>
+ !!window.__studyMotionFixture ||
+ document.getElementById("root")?.dataset.fixtureFailed === "true",
+ );
+ if (await page.locator("#root").getAttribute("data-fixture-failed"))
+ throw Error(await page.locator("#root").innerText());
+ if (mode === "cold-and-reopen") {
+ await page.waitForFunction(
+ () => window.__studyMotionFixture.gates().readReached,
+ );
+ await expect(page.locator(".study-skeleton")).toBeVisible();
+ await page.evaluate(() => window.__studyMotionFixture.releaseRead());
+ }
+ await page.getByRole("button", { name: /^Study 4 cards$/ }).click();
+ await page
+ .getByRole("button", { name: "Show answer", exact: true })
+ .click();
+ await expect(
+ page.getByRole("article", { name: "Card answer", exact: true }),
+ ).toBeVisible();
+ stage = mode + ":revoke";
+ let observation;
+ if (mode === "cold-and-reopen") {
+ observation = await page.evaluate(() =>
+ window.__studyMotionFixture.closeAndInspect(),
+ );
+ expect(observation.noCardDom).toBe(true);
+ await expect(
+ page.getByText("Study is no longer available.", { exact: true }),
+ ).toBeVisible();
+ await page
+ .getByRole("button", { name: "Try again", exact: true })
+ .click();
+ await expect(
+ page.getByRole("button", { name: /^Study 4 cards$/ }),
+ ).toBeVisible();
+ } else if (mode === "lock") {
+ observation = await page.evaluate(() =>
+ window.__studyMotionFixture.lockAndInspect(),
+ );
+ expect(observation.noCardDom).toBe(true);
+ } else if (mode === "same-identity") {
+ observation = await page.evaluate(() =>
+ window.__studyMotionFixture.reauthenticateAndInspect(),
+ );
+ expect(observation.noCardDom).toBe(true);
+ } else if (mode === "epoch") {
+ await page.evaluate(() => window.__studyMotionFixture.watchEpoch());
+ observation = await page.evaluate(() =>
+ window.__studyMotionFixture.eraseAndObserve(),
+ );
+ expect(observation.noCardDom).toBe(true);
+ expect(observation.epochSamples.length).toBeGreaterThan(0);
+ expect(observation.epochSamples.every(Boolean)).toBe(true);
+ await expect(
+ page.getByText("No decks yet", { exact: true }),
+ ).toBeVisible();
+ } else if (mode === "held-commit") {
+ await page.evaluate(() => window.__studyMotionFixture.holdNextCommit());
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await page.waitForFunction(
+ () => window.__studyMotionFixture.gates().commitReached,
+ );
+ const pending = await page.evaluate(() =>
+ window.__studyMotionFixture.metadata(),
+ );
+ expect(pending.study.events).toHaveLength(0);
+ await expect(
+ page.getByRole("article", { name: "Card answer", exact: true }),
+ ).toBeVisible();
+ observation = await page.evaluate(() =>
+ window.__studyMotionFixture.closeAndInspect(),
+ );
+ expect(observation.noCardDom).toBe(true);
+ await page.evaluate(() => window.__studyMotionFixture.releaseCommit());
+ } else {
+ await page.evaluate(async () => {
+ window.__studyMotionFixture.holdNextRead();
+ await window.__studyMotionFixture.toggleStreak();
+ });
+ await page.waitForFunction(
+ () => window.__studyMotionFixture.gates().readReached,
+ );
+ observation = await page.evaluate(() =>
+ window.__studyMotionFixture.closeAndInspect(),
+ );
+ expect(observation.noCardDom).toBe(true);
+ await page.evaluate(() => window.__studyMotionFixture.releaseRead());
+ }
+ if (!["cold-and-reopen", "epoch"].includes(mode)) {
+ await expect(
+ page.getByText("Study is no longer available.", { exact: true }),
+ ).toBeVisible();
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ await expect(page.locator(".study-flash")).toHaveCount(0);
+ }
+ expect(errors).toEqual([]);
+ results.push({ mode, observation, errors });
+ } finally {
+ await context.close();
+ }
+ }
+ const after = await fingerprint();
+ await writeFile(
+ `${output}/result.json`,
+ JSON.stringify(
+ {
+ qualified:
+ "Disposable synthetic identity; direct production StudyView with genuine owner/WASM/IndexedDB, explicit test-only read-ack/precommit gates. No App-shell/backend/native proof. WIP source identities recorded; final freeze needs a rerun.",
+ before,
+ after,
+ sourceStable: JSON.stringify(before) === JSON.stringify(after),
+ results,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log(
+ "PASS: genuine cold/read/commit gates, synchronous close/lock/reauth/observed-epoch clearing, real owner reopen and stale-result suppression.",
+ );
+} catch (error) {
+ await writeFile(
+ `${output}/failure.json`,
+ JSON.stringify({ stage, error: String(error), before, results }, null, 2),
+ );
+ throw error;
+} finally {
+ await browser.close();
+}
diff --git a/web/tests/integration/study-motion-policy.mjs b/web/tests/integration/study-motion-policy.mjs
new file mode 100644
index 0000000..9d6afcc
--- /dev/null
+++ b/web/tests/integration/study-motion-policy.mjs
@@ -0,0 +1,142 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+const output =
+ process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-policy";
+await mkdir(output, { recursive: true });
+const browser = await chromium.launch({ channel: "chrome", headless: true }),
+ results = [];
+try {
+ for (const policy of [
+ { os: "reduce", preference: "on", reduced: false },
+ { os: "no-preference", preference: "reduced", reduced: true },
+ ]) {
+ const context = await browser.newContext({
+ reducedMotion: policy.os,
+ viewport: { width: 1160, height: 850 },
+ });
+ try {
+ await context.addInitScript(
+ (preference) => localStorage.setItem("notes.motion", preference),
+ policy.preference,
+ );
+ const page = await context.newPage(),
+ errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(
+ `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-motion-component.html`,
+ );
+ await expect(
+ page.getByRole("button", { name: "Study 4 cards", exact: true }),
+ ).toBeVisible();
+ await page.evaluate(() => {
+ window.__motionPolicyEvents = [];
+ for (const kind of ["animationstart", "animationend"])
+ document.addEventListener(
+ kind,
+ (event) => {
+ if (!event.target.matches(".study-flash")) return;
+ window.__motionPolicyEvents.push({
+ kind,
+ name: event.animationName,
+ elapsed: event.elapsedTime,
+ frames: event.target
+ .getAnimations()
+ .flatMap((a) =>
+ a.effect
+ .getKeyframes()
+ .map(({ transform, opacity }) => ({
+ transform,
+ opacity,
+ })),
+ ),
+ });
+ },
+ true,
+ );
+ });
+ const settle = async () =>
+ page
+ .locator(".study-flash")
+ .evaluate((element) =>
+ Promise.all(
+ element.getAnimations().map((a) => a.finished.catch(() => {})),
+ ),
+ );
+ await page
+ .getByRole("button", { name: "Study 4 cards", exact: true })
+ .click();
+ await expect(
+ page.getByRole("article", { name: "Card question", exact: true }),
+ ).toBeVisible();
+ await settle();
+ await page
+ .getByRole("button", { name: "Show answer", exact: true })
+ .click();
+ await expect(
+ page.getByRole("article", { name: "Card answer", exact: true }),
+ ).toBeVisible();
+ await settle();
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect(
+ page.getByRole("article", { name: "Card question", exact: true }),
+ ).toBeVisible();
+ await settle();
+ const events = await page.evaluate(() => window.__motionPolicyEvents);
+ for (const [name, seconds] of [
+ ["study-reveal-out", 0.105],
+ ["study-answer-in", 0.15],
+ ["study-card-out", 0.14],
+ ["study-next-in", 0.2],
+ ]) {
+ const completed = events.filter(
+ (e) => e.kind === "animationend" && e.name === name,
+ );
+ expect(
+ completed.length,
+ `${policy.preference} actual ${name} completion`,
+ ).toBeGreaterThan(0);
+ for (const event of completed)
+ expect(event.elapsed).toBeCloseTo(seconds, 3);
+ }
+ const frames = events
+ .filter(
+ (e) =>
+ e.kind === "animationstart" &&
+ ["study-answer-in", "study-next-in"].includes(e.name),
+ )
+ .flatMap((e) => e.frames);
+ expect(frames.length).toBeGreaterThan(0);
+ if (policy.reduced)
+ expect(
+ frames.every(
+ (frame) => !frame.transform || frame.transform === "none",
+ ),
+ ).toBe(true);
+ else
+ expect(
+ frames.some((frame) => frame.transform && frame.transform !== "none"),
+ ).toBe(true);
+ expect(errors).toEqual([]);
+ results.push({ policy, events, errors });
+ } finally {
+ await context.close();
+ }
+ }
+ await writeFile(
+ `${output}/result.json`,
+ JSON.stringify(
+ {
+ qualified:
+ "Genuine encrypted direct Study component; saved preference overrides OS policy. Actual CSS event timings; no App-shell/native/FPS claim.",
+ results,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log(
+ "PASS: explicit On overrides OS reduction; explicit Reduced overrides ordinary OS policy, preserving actual phase durations.",
+ );
+} finally {
+ await browser.close();
+}
diff --git a/web/tests/integration/study-settings-browser.html b/web/tests/integration/study-settings-browser.html
new file mode 100644
index 0000000..6558bd2
--- /dev/null
+++ b/web/tests/integration/study-settings-browser.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+ Synthetic account session · real App and encrypted WASM/IndexedDB · no
+ account backend or human profile
+
+
+
+
+
diff --git a/web/tests/integration/study-settings-browser.mjs b/web/tests/integration/study-settings-browser.mjs
new file mode 100644
index 0000000..f4bff8e
--- /dev/null
+++ b/web/tests/integration/study-settings-browser.mjs
@@ -0,0 +1,399 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+const output =
+ process.env.NOTES_UI_OUTPUT ?? "test-results/study-settings-browser";
+await mkdir(output, { recursive: true });
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+const context = await browser.newContext({
+ viewport: { width: 1160, height: 850 },
+});
+const page = await context.newPage(),
+ errors = [];
+page.on("pageerror", (e) => errors.push(e.message));
+async function open(expected = "10") {
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
+ await dialog.getByRole("button", { name: "Study", exact: true }).click();
+ await expect(
+ dialog.getByRole("textbox", { name: "New cards per day", exact: true }),
+ ).toHaveValue(expected);
+ return dialog;
+}
+try {
+ await page.goto(
+ `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-settings-browser.html`,
+ );
+ await expect(
+ page.getByRole("heading", { name: "All notes", exact: true }),
+ ).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(async () => {
+ const { checkClose } = await import("/src/desktop/closeGuard.ts");
+ return checkClose();
+ }),
+ )
+ .toBe("allowed");
+ const before = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ let dialog = await open();
+ await dialog
+ .getByRole("textbox", { name: "New cards per day", exact: true })
+ .fill("2");
+ await dialog
+ .getByRole("textbox", { name: "Reviews per day", exact: true })
+ .fill("20000");
+ await dialog
+ .getByRole("combobox", { name: "Day starts at", exact: true })
+ .selectOption("23");
+ await dialog
+ .getByRole("switch", {
+ name: "Show next review on answer buttons",
+ exact: true,
+ })
+ .uncheck();
+ await dialog
+ .getByRole("switch", { name: "Show streak", exact: true })
+ .uncheck();
+ await dialog
+ .getByRole("button", { name: "Save changes", exact: true })
+ .click();
+ await expect(
+ dialog.getByText("Saved on this device.", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByRole("textbox", { name: "New cards per day", exact: true }),
+ ).toBeEnabled();
+ const saved = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ expect(saved.preferences).toMatchObject({
+ newPerDay: 2,
+ reviewsPerDay: 20000,
+ dayStartHour: 23,
+ showNextReview: false,
+ showStreak: false,
+ });
+ expect(saved.cards).toEqual(before.cards);
+ expect(saved.events).toEqual(before.events);
+ expect(saved.preferences.algorithm).toBe(before.preferences.algorithm);
+ expect(saved.preferences.timezone).toBe(before.preferences.timezone);
+ await dialog.getByRole("button", { name: "Done", exact: true }).click();
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "All notes", exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ dialog = page.getByRole("dialog", { name: "Settings", exact: true });
+ await dialog.getByRole("button", { name: "Study", exact: true }).click();
+ await expect(
+ dialog.getByRole("textbox", { name: "New cards per day", exact: true }),
+ ).toHaveValue("2");
+ const limit = () =>
+ dialog.getByRole("textbox", { name: "New cards per day", exact: true });
+ await limit().fill("1.5");
+ await dialog
+ .getByRole("button", { name: "Save changes", exact: true })
+ .click();
+ await expect(limit()).toHaveValue("1.5");
+ await expect(limit()).toHaveAttribute("aria-invalid", "true");
+ expect(
+ (await page.evaluate(() => window.__studySettingsFixture.metadata()))
+ .preferences.newPerDay,
+ ).toBe(2);
+ await dialog.getByRole("button", { name: "Done", exact: true }).click();
+ let confirm = page.getByRole("dialog", {
+ name: "Unsaved Study changes",
+ exact: true,
+ });
+ await expect(
+ confirm.getByRole("button", { name: "Save and continue" }),
+ ).toBeDisabled();
+ await confirm.getByRole("button", { name: "Keep editing" }).click();
+ await page.keyboard.press("Escape");
+ confirm = page.getByRole("dialog", {
+ name: "Unsaved Study changes",
+ exact: true,
+ });
+ await confirm.getByRole("button", { name: "Keep editing" }).click();
+ await dialog.getByRole("button", { name: "Editor", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Unsaved Study changes", exact: true })
+ .getByRole("button", { name: "Discard and continue" })
+ .click();
+ await expect(
+ dialog.getByRole("heading", { name: "Editor", exact: true }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText(
+ "Planned · Opening backlinks by default isn’t available yet. Use Links and backlinks in a note.",
+ { exact: true },
+ ),
+ ).toBeVisible();
+ await dialog.getByRole("button", { name: "Study", exact: true }).click();
+ await expect(limit()).toHaveValue("2");
+ await limit().fill("3");
+ await page.evaluate(() =>
+ window.__studySettingsFixture.setPreferences({
+ reviewsPerDay: 77,
+ showStreak: true,
+ }),
+ );
+ await expect(
+ dialog.getByRole("button", { name: "Reload latest (keep edits)" }),
+ ).toBeVisible();
+ await dialog
+ .getByRole("button", { name: "Reload latest (keep edits)" })
+ .click();
+ await expect(limit()).toHaveValue("3");
+ await expect(
+ dialog.getByRole("textbox", { name: "Reviews per day", exact: true }),
+ ).toHaveValue("77");
+ await expect(
+ dialog.getByRole("switch", { name: "Show streak", exact: true }),
+ ).toBeChecked();
+ await dialog
+ .getByRole("button", { name: "Save changes", exact: true })
+ .click();
+ await expect(
+ dialog.getByText("Saved on this device.", { exact: true }),
+ ).toBeVisible();
+ const conflictSaved = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ expect(conflictSaved.preferences).toMatchObject({
+ newPerDay: 3,
+ reviewsPerDay: 77,
+ showStreak: true,
+ });
+ expect(conflictSaved.cards).toEqual(before.cards);
+ expect(conflictSaved.events).toEqual(before.events);
+ await dialog
+ .getByRole("combobox", { name: "Day starts at", exact: true })
+ .selectOption("2");
+ await dialog
+ .getByRole("switch", { name: "Show streak", exact: true })
+ .uncheck();
+ await dialog
+ .getByRole("switch", {
+ name: "Show next review on answer buttons",
+ exact: true,
+ })
+ .check();
+ await page.evaluate(() =>
+ window.__studySettingsFixture.setPreferences({ dayStartHour: 3 }),
+ );
+ const actualConflict = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ await dialog
+ .getByRole("button", { name: "Reload latest (keep edits)" })
+ .click();
+ await expect(
+ dialog.getByText("Latest settings loaded. Your edits are still here.", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByRole("combobox", { name: "Day starts at", exact: true }),
+ ).toHaveValue("2");
+ await expect(
+ dialog.getByText("Saved value: 03:00", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText("Saved value: On", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText("Saved value: Off", { exact: true }),
+ ).toBeVisible();
+ expect(
+ (await page.evaluate(() => window.__studySettingsFixture.metadata()))
+ .revision,
+ ).toBe(actualConflict.revision);
+ await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
+ await page
+ .getByRole("dialog", { name: "Discard Study changes?", exact: true })
+ .getByRole("button", { name: "Discard changes", exact: true })
+ .click();
+ await expect(
+ dialog.getByRole("combobox", { name: "Day starts at", exact: true }),
+ ).toHaveValue("3");
+ await dialog.getByRole("button", { name: "Done", exact: true }).click();
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await page
+ .getByRole("button", { name: "Study 3 cards", exact: true })
+ .click();
+ await page.getByRole("button", { name: "Show answer", exact: true }).click();
+ await expect(
+ page.getByRole("article", { name: "Card answer" }),
+ ).toBeVisible();
+ const answer = await page
+ .getByRole("article", { name: "Card answer" })
+ .innerText();
+ expect(await page.locator(".study-grades time").count()).toBe(0);
+ dialog = await open("3");
+ await limit().fill("0");
+ await dialog
+ .getByRole("combobox", { name: "Day starts at", exact: true })
+ .selectOption("0");
+ await dialog
+ .getByRole("switch", {
+ name: "Show next review on answer buttons",
+ exact: true,
+ })
+ .check();
+ expect(await page.locator(".study-grades time").count()).toBe(0);
+ await dialog
+ .getByRole("button", { name: "Save changes", exact: true })
+ .click();
+ await expect(
+ dialog.getByText("Saved on this device.", { exact: true }),
+ ).toBeVisible();
+ await expect(page.locator(".study-grades time")).toHaveCount(4);
+ expect(
+ await page
+ .getByRole("article", { name: "Card answer", includeHidden: true })
+ .innerText(),
+ ).toBe(answer);
+ expect(
+ await page
+ .getByRole("progressbar", {
+ name: "Review progress",
+ includeHidden: true,
+ })
+ .getAttribute("aria-valuemax"),
+ ).toBe("3");
+ expect(
+ (await page.evaluate(() => window.__studySettingsFixture.metadata()))
+ .events,
+ ).toEqual(before.events);
+ for (const theme of ["light", "dark", "dim"]) {
+ await page.evaluate(
+ (value) => (document.documentElement.dataset.theme = value),
+ theme,
+ );
+ await dialog.evaluate(async (element) => {
+ await Promise.all(
+ element
+ .getAnimations({ subtree: true })
+ .map((animation) => animation.finished.catch(() => {})),
+ );
+ });
+ const computed = await limit().evaluate((element) => {
+ const css = getComputedStyle(element);
+ return { width: css.width, height: css.height, radius: css.borderRadius };
+ });
+ expect(computed).toEqual({ width: "100px", height: "32px", radius: "8px" });
+ expect(
+ await dialog
+ .locator(".settings-heading h3")
+ .evaluate((element) => getComputedStyle(element).fontSize),
+ ).toBe("22px");
+ expect(
+ await dialog
+ .getByRole("switch", { name: "Show streak", exact: true })
+ .evaluate((element) => ({
+ width: getComputedStyle(element).width,
+ height: getComputedStyle(element).height,
+ })),
+ ).toEqual({ width: "30px", height: "18px" });
+ await page.screenshot({ path: output + `/study-settings-${theme}.png` });
+ }
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await dialog.evaluate(async (element) => {
+ await Promise.all(
+ element
+ .getAnimations({ subtree: true })
+ .map((animation) => animation.finished.catch(() => {})),
+ );
+ });
+ await expect(limit()).toBeVisible();
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ await dialog
+ .getByRole("button", { name: "Save changes", exact: true })
+ .scrollIntoViewIfNeeded();
+ await page.screenshot({
+ path: output + "/study-settings-narrow-dim-reduced.png",
+ });
+ await dialog.getByRole("button", { name: "Done", exact: true }).click();
+ await expect(
+ page.getByRole("article", { name: "Card answer" }),
+ ).toBeVisible();
+ expect(
+ await page.getByRole("article", { name: "Card answer" }).innerText(),
+ ).toBe(answer);
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect
+ .poll(
+ async () =>
+ (await page.evaluate(() => window.__studySettingsFixture.metadata()))
+ .events.length,
+ )
+ .toBe(1);
+ await expect(
+ page.getByRole("button", { name: "Undo", exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect(
+ page.getByRole("article", { name: "Card answer" }),
+ ).toBeVisible();
+ await page.setViewportSize({ width: 1160, height: 850 });
+ dialog = await open("0");
+ await limit().fill("10000");
+ await dialog
+ .getByRole("button", { name: "Save changes", exact: true })
+ .click();
+ await expect(
+ dialog.getByText("Saved on this device.", { exact: true }),
+ ).toBeVisible();
+ expect(
+ (await page.evaluate(() => window.__studySettingsFixture.metadata()))
+ .preferences.newPerDay,
+ ).toBe(10000);
+ await limit().fill("123");
+ await page.evaluate(() => window.__studySettingsFixture.erase());
+ await expect(
+ page.getByRole("textbox", { name: "New cards per day", exact: true }),
+ ).toHaveCount(0);
+ expect(errors).toEqual([]);
+ await writeFile(
+ output + "/result.json",
+ JSON.stringify(
+ {
+ checks: [
+ "actual owner before Study visit",
+ "five prefs commit/reopen encrypted",
+ "cards/events/timezone/algorithm unchanged",
+ "raw invalid input and Done/Escape/section guards",
+ "real independent CAS conflict keeps dirty keys",
+ "conflict hour and boolean saved-value disclosure without auto write",
+ "held revealed review survives caps/day/visibility save and real grade Undo",
+ "all three themes, narrow/reduced layout, full producer bounds, epoch clears draft",
+ ],
+ pageErrors: errors,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log("Study settings encrypted browser journey passed");
+} catch (e) {
+ await page.screenshot({ path: output + "/failure.png", fullPage: true });
+ await writeFile(
+ output + "/failure.txt",
+ String(e) +
+ "\nURL " +
+ page.url() +
+ "\n" +
+ (await page.locator("body").innerText()),
+ );
+ throw e;
+} finally {
+ await browser.close();
+}
diff --git a/web/tests/integration/study-settings-browser.tsx b/web/tests/integration/study-settings-browser.tsx
new file mode 100644
index 0000000..4faa8ef
--- /dev/null
+++ b/web/tests/integration/study-settings-browser.tsx
@@ -0,0 +1,152 @@
+// Disposable integration fixture; never imported by the production entry.
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "../../src/App";
+import { applyInitialPreferences } from "../../src/ui/primitives";
+import { establish, lock } from "../../src/account/session";
+import { openNotesStore } from "../../src/data";
+import { loadEngine, DeviceKeys } from "../../src/data/engine";
+import {
+ newWrappingKey,
+ wrappingSecret,
+ readRecord,
+ mutateRecord,
+ type DeviceRecord,
+} from "../../src/data/database";
+import { openCollectionsStore } from "../../src/collections/store";
+import type { StudyPreferences } from "../../src/collections/contracts";
+import { createDeck, saveEntry } from "../../src/study/model";
+import "../../src/styles/app.css";
+import "@fontsource/geist/400.css";
+import "@fontsource/geist/500.css";
+
+const identity = {
+ accountId: "b1111111-1111-4111-8111-111111111111",
+ deviceId: "b2222222-2222-4222-8222-222222222222",
+ email: "isolated-study-settings@example.test",
+ deviceState: "trusted" as const,
+};
+type Editable = Pick<
+ StudyPreferences,
+ | "newPerDay"
+ | "reviewsPerDay"
+ | "dayStartHour"
+ | "showNextReview"
+ | "showStreak"
+>;
+applyInitialPreferences();
+async function start() {
+ await loadEngine();
+ const fresh = !(await readRecord("devices", identity.email));
+ if (fresh) {
+ const wrapKey = await newWrappingKey(),
+ secret = await wrappingSecret(wrapKey),
+ keys = DeviceKeys.create();
+ try {
+ await mutateRecord("devices", identity.email, () => ({
+ ...identity,
+ wrapKey,
+ sealedKeys: keys.export(secret),
+ generation: 0,
+ verifiedTrust: true,
+ }));
+ } finally {
+ secret.fill(0);
+ keys.free();
+ }
+ }
+ establish(identity);
+ const notes = await openNotesStore(identity),
+ collections = await openCollectionsStore(identity);
+ if (fresh) {
+ await notes.create({
+ title: "Study settings fixture note",
+ tags: [],
+ document: {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "A real encrypted note keeps optional setup from taking over.",
+ },
+ ],
+ },
+ ],
+ },
+ });
+ const current = await collections.read(),
+ at = new Date("2026-01-01T12:00:00.000Z");
+ await collections.update(current, (state) => {
+ let study = createDeck(state.study, "Settings continuity", at);
+ for (let i = 1; i <= 4; i++) {
+ study = saveEntry(
+ study,
+ {
+ id: crypto.randomUUID(),
+ deckId: study.decks[0]!.id,
+ revision: 1,
+ tags: [],
+ source: null,
+ type: "basic",
+ front: `Settings question ${i}`,
+ back: `Settings answer ${i}`,
+ },
+ at,
+ );
+ }
+ return { ...state, study };
+ });
+ }
+ Object.assign(window, {
+ __studySettingsFixture: {
+ metadata: async () => {
+ const state = await collections.read();
+ return {
+ epoch: state.epoch,
+ revision: state.revision,
+ preferences: state.study.preferences,
+ decks: state.study.decks,
+ cards: state.study.cards,
+ events: state.study.events,
+ };
+ },
+ setPreferences: async (patch: Partial) => {
+ const allowed = new Set([
+ "newPerDay",
+ "reviewsPerDay",
+ "dayStartHour",
+ "showNextReview",
+ "showStreak",
+ ]);
+ if (Object.keys(patch).some((key) => !allowed.has(key)))
+ throw new Error(
+ "Only the five editable preference keys are admitted by this fixture.",
+ );
+ const state = await collections.read();
+ await collections.update(state, (current) => ({
+ ...current,
+ study: {
+ ...current.study,
+ preferences: { ...current.study.preferences, ...patch },
+ },
+ }));
+ },
+ erase: () => notes.erase(),
+ lock: () => lock(false),
+ },
+ });
+ createRoot(document.getElementById("root")!).render(
+
+
+ ,
+ );
+}
+void start().catch((error) => {
+ const root = document.getElementById("root")!;
+ root.dataset.fixtureFailed = "true";
+ root.textContent =
+ error instanceof Error ? error.message : "Study settings fixture failed";
+});
diff --git a/web/tests/integration/study-source-browser.html b/web/tests/integration/study-source-browser.html
new file mode 100644
index 0000000..5f079cb
--- /dev/null
+++ b/web/tests/integration/study-source-browser.html
@@ -0,0 +1 @@
+Encrypted Study source test harnessTest harness — synthetic account session; real local WASM encryption and IndexedDB. No account-server qualification.
\ No newline at end of file
diff --git a/web/tests/integration/study-source-browser.mjs b/web/tests/integration/study-source-browser.mjs
new file mode 100644
index 0000000..444911e
--- /dev/null
+++ b/web/tests/integration/study-source-browser.mjs
@@ -0,0 +1,26 @@
+import {chromium,expect} from '@playwright/test';
+import {mkdir,writeFile} from 'node:fs/promises';
+const output=process.env.NOTES_UI_OUTPUT??'test-results/study-source-browser';await mkdir(output,{recursive:true});
+const browser=await chromium.launch({channel:'chrome',headless:true});const page=await browser.newPage({viewport:{width:1160,height:850}});const errors=[];page.on('pageerror',error=>errors.push(error.message));
+async function stable(locator){await locator.evaluate(async element=>{await Promise.all(element.getAnimations({subtree:true}).map(animation=>animation.finished.catch(()=>{})));});await expect(locator).toHaveCSS('opacity','1');}
+try{
+await page.goto(`${process.env.NOTES_PREVIEW_URL??'http://127.0.0.1:5179'}/tests/integration/study-source-browser.html`);const firstRun=page.getByRole('dialog',{name:'Set up Orbyn Notes'});await expect(firstRun).toBeVisible();await firstRun.getByRole('button',{name:'Skip',exact:true}).click();await firstRun.getByRole('button',{name:'Skip',exact:true}).click();await expect(firstRun).toHaveCount(0);await page.getByRole('button',{name:'Study',exact:true}).click();await page.getByRole('button',{name:'New deck',exact:true}).first().click();let dialog=page.getByRole('dialog',{name:'New deck',exact:true});await dialog.getByLabel('Name').fill('Actual source deck');await dialog.getByRole('button',{name:'Create deck',exact:true}).click();await expect(dialog).toHaveCount(0);
+await page.getByRole('button',{name:/^All notes/}).click();await page.getByRole('button',{name:'New note',exact:true}).first().click();await page.getByLabel('Note title').fill('Source passage note');const editor=page.getByLabel('Note content',{exact:true});await editor.click();await page.keyboard.type('Before NADH and FADH2 after. NADH and FADH2 again.');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();
+await editor.press('Home');await editor.press('ArrowRight',{delay:1});for(let i=1;i<7;i++)await editor.press('ArrowRight');for(let i=0;i<14;i++)await editor.press('Shift+ArrowRight');
+await page.getByRole('toolbar',{name:'Selection tools',exact:true}).getByRole('button',{name:'Make a card',exact:true}).click();dialog=page.getByRole('dialog',{name:'Make a card',exact:true});await expect(dialog.getByRole('textbox',{name:'Back',exact:true})).toHaveValue('NADH and FADH2');await expect(dialog.getByRole('textbox',{name:'Front',exact:true})).toHaveValue('');await dialog.getByRole('textbox',{name:'Front',exact:true}).fill('Which molecules carry electrons?');await expect(dialog.getByRole('button',{name:'Add card',exact:true})).toBeEnabled();await stable(dialog);await page.screenshot({path:output+'/basic-popover-light.png'});await dialog.getByRole('button',{name:'Add card',exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.locator('.study-source-annotation')).toHaveCount(1);const meta=await page.evaluate(()=>window.__studySourceFixture.metadata());if(meta.entries.length!==1||meta.cards.length!==1||meta.entries[0].source?.version!==2)throw Error('Real card/anchor not committed');
+await expect(page.locator('.source-card-notice')).toContainText('Card added to Actual source deck');await page.getByRole('button',{name:'Open card',exact:true}).click();dialog=page.getByRole('dialog',{name:'Edit card',exact:true});await expect(dialog.getByRole('textbox',{name:'Front',exact:true})).toHaveValue('Which molecules carry electrons?');await dialog.getByRole('button',{name:'Cancel',exact:true}).click();await page.getByRole('button',{name:/^Study 1 card/}).click();await page.getByRole('button',{name:'Show answer',exact:true}).click();await page.getByRole('button',{name:'Open in note',exact:true}).click();await expect(page.locator('.study-source-highlight')).toHaveText('NADH and FADH2');await stable(page.locator('.study-source-highlight'));await page.screenshot({path:output+'/jump-source-light.png'});await page.getByRole('button',{name:'Back to card',exact:true}).click();await expect(page.getByRole('article',{name:'Card answer'})).toBeVisible();
+await page.getByRole('button',{name:'End session',exact:true}).click();await page.getByRole('button',{name:/^All notes/}).click();await page.getByRole('button',{name:/Source passage note/}).first().click();
+async function selectPassage(prefix=7){const body=page.getByLabel('Note content',{exact:true});await body.click();await body.press('Home');for(let i=0;iwindow.__studySourceFixture.metadata());if(current.entries.length!==2||current.cards.length!==3)throw Error('Reversed did not create actual2cards');await page.getByRole('button',{name:'Undo card creation',exact:true}).click();await expect.poll(async()=>(await page.evaluate(()=>window.__studySourceFixture.metadata())).cards.length).toBe(1);
+dialog=await selectPassage();await dialog.getByRole('button',{name:'Fill in the blank',exact:true}).click();await expect(dialog.getByRole('region',{name:'Card preview'})).toContainText('[...]');await expect(dialog.getByRole('button',{name:'Add card',exact:true})).toBeEnabled();await stable(dialog);await page.screenshot({path:output+'/cloze-popover-light.png'});await dialog.getByRole('button',{name:'Add card',exact:true}).click();await expect(dialog).toHaveCount(0);current=await page.evaluate(()=>window.__studySourceFixture.metadata());if(current.entries.length!==2||current.cards.length!==2||!current.entries.some(entry=>entry.type==='cloze'))throw Error('Cloze actual entry/card missing');await page.getByRole('button',{name:'Open card',exact:true}).click();dialog=page.getByRole('dialog',{name:'Edit card',exact:true});await dialog.getByRole('button',{name:'Cancel',exact:true}).click();await page.getByRole('row').filter({hasText:'Fill in the blank'}).getByRole('button',{name:'Open in note',exact:true}).click();await expect(page.locator('.study-source-highlight')).toHaveText('NADH and FADH2');
+const sourceNoteId=current.notes.find(note=>note.title==='Source passage note').id;const initialAnchors=await page.evaluate(id=>window.__studySourceFixture.annotations(id),sourceNoteId);if(initialAnchors.length!==2)throw Error('Missing attached source annotations');const from=initialAnchors[0].from;
+await page.getByLabel('Note content',{exact:true}).press('Home');await page.keyboard.type('Nearby ');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();const mapped=await page.evaluate(id=>window.__studySourceFixture.annotations(id),sourceNoteId);if(mapped.length!==2||mapped.some(anchor=>anchor.from!==from+7))throw Error('Surrounding edit did not preserve mapped range');
+await page.getByLabel('Note content',{exact:true}).press('Home');for(let i=0;i<14;i++)await page.getByLabel('Note content',{exact:true}).press('ArrowRight');for(let i=0;i<14;i++)await page.getByLabel('Note content',{exact:true}).press('Shift+ArrowRight');await page.keyboard.type('NADH and FADH2');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();await expect.poll(async()=>(await page.evaluate(id=>window.__studySourceFixture.annotations(id),sourceNoteId)).length).toBe(0);await expect(page.locator('.study-source-annotation')).toHaveCount(0);await page.getByRole('button',{name:'Back to card',exact:true}).click();await page.getByRole('row').filter({hasText:'Fill in the blank'}).getByRole('button',{name:'Open in note',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'The source passage changed.'})).toBeVisible();await expect(page.locator('.study-source-highlight')).toHaveCount(0);await page.getByRole('button',{name:'Open note without passage',exact:true}).click();await expect(page.getByLabel('Note content',{exact:true})).toBeVisible();
+await page.getByLabel('Note content',{exact:true}).press('Control+End');await page.keyboard.type(' {{literal}}');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();dialog=await selectPassage(14);await expect(dialog.getByRole('button',{name:'Fill in the blank',exact:true})).toBeDisabled();await expect(dialog).toContainText('Blank markers in this context prevent a new blank.');await dialog.getByRole('textbox',{name:'Front',exact:true}).fill('Draft kept during reselection');
+for(const theme of ['light','dark','dim']){await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;},theme);await stable(dialog);await page.screenshot({path:output+`/card-form-${theme}.png`});}
+await page.setViewportSize({width:390,height:844});await page.emulateMedia({reducedMotion:'reduce'});await stable(dialog);await page.screenshot({path:output+'/card-sheet-narrow-dim.png'});if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Narrow source page overflow');const reduced=await dialog.evaluate(element=>({animation:getComputedStyle(element).animationDuration,transform:getComputedStyle(element).transform}));if(reduced.transform!=='none')throw Error('Reduced source sheet transform active');
+await dialog.getByRole('textbox',{name:'Front',exact:true}).press('End');await dialog.getByRole('textbox',{name:'Front',exact:true}).press('Enter');await dialog.getByRole('textbox',{name:'Front',exact:true}).type('Second line');await expect(dialog.getByRole('textbox',{name:'Front',exact:true})).toHaveValue('Draft kept during reselection\nSecond line');await dialog.getByRole('button',{name:'Basic',exact:true}).focus();await dialog.getByRole('button',{name:'Basic',exact:true}).press('ArrowRight');await expect(dialog.getByRole('button',{name:'Reversed',exact:true})).toBeFocused();await dialog.getByRole('button',{name:'Reversed',exact:true}).press('ArrowRight');await expect(dialog.getByRole('button',{name:'Basic',exact:true})).toBeFocused();
+await dialog.getByRole('button',{name:'Add card',exact:true}).scrollIntoViewIfNeeded();await expect(dialog.getByRole('button',{name:'Add card',exact:true})).toBeInViewport();await expect(dialog.getByRole('button',{name:'Cancel',exact:true})).toBeInViewport();await stable(dialog);await page.screenshot({path:output+'/card-sheet-actions-narrow-dim.png'});
+await page.evaluate(()=>window.__studySourceFixture.erase());await expect(page.getByRole('dialog',{name:'Make a card',exact:true})).toHaveCount(0);await expect(page.getByText('No decks yet',{exact:true})).toBeVisible();current=await page.evaluate(()=>window.__studySourceFixture.metadata());if(current.entries.length||current.cards.length)throw Error('Erase left source cards');
+if(errors.length)throw Error(errors.join('\n'));await writeFile(output+'/result.json',JSON.stringify({qualified:'Synthetic account session; real production editor/source service/WASM/IndexedDB, not account login/native input',checks:['Basic from actual keyboard selection','empty user Front/verbatim selected Back','atomic actual entry/card/anchor','Open actual card','validated source jump with duplicate quote elsewhere','return to revealed review card','Reversed actual2cards plus receiptUndo','Cloze actualcontext/answer/card','verified surrounding edit maps attached ranges','identical-text replacement makes anchors stale/no wrong jump','literal moustache fallback keeps Basic/Reversed','Light/Dark/Dim and390px reduced sheet','wholeerase clears carddraft/source/cards','scoped enabledtype arrow navigation and textarea newline','constrained sheet scroll keeps Cancel/Add reachable'],pageErrors:errors},null,2));console.log('Study source browser smoke passed');
+}catch(error){await page.screenshot({path:output+'/failure.png'});await writeFile(output+'/failure.txt',String(error)+'\n'+(await page.locator('body').innerText()));throw error;}finally{await browser.close();}
diff --git a/web/tests/integration/study-source-browser.tsx b/web/tests/integration/study-source-browser.tsx
new file mode 100644
index 0000000..fd239fe
--- /dev/null
+++ b/web/tests/integration/study-source-browser.tsx
@@ -0,0 +1,66 @@
+// Test-only entry. Never imported by the production application.
+import { createRoot } from "react-dom/client";
+import { Workspace } from "../../src/ui/Workspace";
+import { applyInitialPreferences } from "../../src/ui/primitives";
+import { openNotesStore } from "../../src/data";
+import { establish } from "../../src/account/session";
+import { loadEngine, DeviceKeys } from "../../src/data/engine";
+import {
+ newWrappingKey,
+ wrappingSecret,
+ readRecord,
+ mutateRecord,
+} from "../../src/data/database";
+import type { DeviceRecord } from "../../src/data/database";
+import "../../src/styles/app.css";
+import "@fontsource/geist/400.css";
+import "@fontsource/geist/500.css";
+
+const identity = {
+ accountId: "99999999-9999-4999-8999-999999999999",
+ deviceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ email: "isolated-source-fixture@example.test",
+ deviceState: "trusted" as const,
+};
+applyInitialPreferences();
+async function start() {
+ await loadEngine();
+ if (!(await readRecord("devices", identity.email))) {
+ const wrapKey = await newWrappingKey(),
+ secret = await wrappingSecret(wrapKey),
+ keys = DeviceKeys.create();
+ try {
+ await mutateRecord("devices", identity.email, () => ({
+ ...identity,
+ wrapKey,
+ sealedKeys: keys.export(secret),
+ generation: 0,
+ }));
+ } finally {
+ secret.fill(0);
+ keys.free();
+ }
+ }
+ establish(identity);
+ const notes = await openNotesStore(identity);
+ const {openCollectionsStore}=await import("../../src/collections/store");
+ const inspector=await openCollectionsStore(identity);
+ const {createStudySourceService}=await import("../../src/study/sourceStore");
+ const sources=createStudySourceService(notes,inspector);
+ Object.assign(window,{__studySourceFixture:{metadata:async()=>{const state=await inspector.read();const list=await notes.list();return{entries:state.study.entries.map(entry=>({id:entry.id,type:entry.type,source:entry.source})),cards:state.study.cards.map(card=>({id:card.id,entryId:card.entryId})),notes:list.map(note=>({id:note.id,title:note.title,revision:note.revision}))};},annotations:(id:string)=>sources.annotations(id),erase:()=>notes.erase()}});
+ createRoot(document.getElementById("root")!).render(
+ {
+ notes.close();
+ }}
+ />,
+ );
+}
+void start().catch((error) => {
+ document.getElementById("root")!.textContent =
+ error instanceof Error ? error.message : "Fixture failed";
+});
diff --git a/web/tests/integration/study-source-reopen-browser.mjs b/web/tests/integration/study-source-reopen-browser.mjs
new file mode 100644
index 0000000..0ca4695
--- /dev/null
+++ b/web/tests/integration/study-source-reopen-browser.mjs
@@ -0,0 +1,193 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import assert from "node:assert/strict";
+
+const output =
+ process.env.NOTES_UI_OUTPUT ?? "test-results/study-source-reopen";
+await mkdir(output, { recursive: true });
+const profile = await mkdtemp(join(tmpdir(), "orbyn-source-reopen-"));
+const url = `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-source-browser.html`;
+const errors = [];
+const quote = "NADH and FADH2";
+const body = `Before ${quote} after. ${quote} again.`;
+const selectedOffset = body.lastIndexOf(quote);
+let context;
+async function launch() {
+ context = await chromium.launchPersistentContext(profile, {
+ channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome",
+ headless: true,
+ viewport: { width: 1160, height: 850 },
+ });
+ // The Vite assets remain available. This deliberately proves local encrypted
+ // state with the account API unreachable, not offline frontend installation.
+ await context.route("http://127.0.0.1:8089/**", (route) => route.abort());
+ const page = context.pages()[0] ?? (await context.newPage());
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(url);
+ await page.waitForFunction(() => Boolean(window.__studySourceFixture));
+ return page;
+}
+const metadata = (page) =>
+ page.evaluate(() => window.__studySourceFixture.metadata());
+async function exactDecoration(page, selector) {
+ const actual = await page
+ .getByLabel("Note content", { exact: true })
+ .evaluate((element, selector) => {
+ const decoration = element.querySelector(selector);
+ if (!decoration) return null;
+ const prefix = element.ownerDocument.createRange();
+ prefix.selectNodeContents(element);
+ prefix.setEndBefore(decoration);
+ return { prefix: prefix.toString(), text: decoration.textContent };
+ }, selector);
+ assert.deepEqual(actual, {
+ prefix: body.slice(0, selectedOffset),
+ text: quote,
+ });
+}
+async function openNote(page) {
+ await page.getByRole("button", { name: /^All notes/ }).click();
+ await page
+ .getByRole("button")
+ .filter({ hasText: "Reload source note" })
+ .click();
+ await expect(page.getByLabel("Note title")).toHaveValue("Reload source note");
+ await expect(page.locator(".study-source-annotation")).toHaveText(
+ "NADH and FADH2",
+ );
+ await exactDecoration(page, ".study-source-annotation");
+}
+
+try {
+ let page = await launch();
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await page
+ .getByRole("button", { name: "New deck", exact: true })
+ .first()
+ .click();
+ let dialog = page.getByRole("dialog", { name: "New deck", exact: true });
+ await dialog.getByLabel("Name").fill("Reload source deck");
+ await dialog
+ .getByRole("button", { name: "Create deck", exact: true })
+ .click();
+ await expect(dialog).toHaveCount(0);
+ await page.getByRole("button", { name: /^All notes/ }).click();
+ await page.getByRole("button", { name: "New note", exact: true }).click();
+ await page.getByLabel("Note title").fill("Reload source note");
+ const editor = page.getByLabel("Note content", { exact: true });
+ await editor.click();
+ await page.keyboard.type(body);
+ await expect(
+ page.getByText("Saved in this browser", { exact: true }),
+ ).toBeVisible();
+ await editor.press("Control+Home");
+ for (let i = 0; i < selectedOffset; i++) await editor.press("ArrowRight");
+ for (let i = 0; i < 14; i++) await editor.press("Shift+ArrowRight");
+ await page
+ .getByRole("toolbar", { name: "Selection tools", exact: true })
+ .getByRole("button", { name: "Make a card", exact: true })
+ .click();
+ dialog = page.getByRole("dialog", { name: "Make a card", exact: true });
+ await expect(
+ dialog.getByRole("textbox", { name: "Back", exact: true }),
+ ).toHaveValue("NADH and FADH2");
+ await dialog
+ .getByRole("textbox", { name: "Front", exact: true })
+ .fill("Which carriers persist?");
+ await dialog.getByRole("button", { name: "Add card", exact: true }).click();
+ await expect(dialog).toHaveCount(0);
+ await expect(page.locator(".study-source-annotation")).toHaveText(
+ "NADH and FADH2",
+ );
+ const before = await metadata(page);
+ assert.equal(before.entries.length, 1);
+ assert.equal(before.cards.length, 1);
+ assert.equal(before.entries[0].source.version, 2);
+ const originalAnchors = await page.evaluate(
+ (id) => window.__studySourceFixture.annotations(id),
+ before.notes[0].id,
+ );
+ assert.equal(originalAnchors.length, 1);
+ assert.equal(originalAnchors[0].from, selectedOffset + 1);
+ assert.equal(originalAnchors[0].to, selectedOffset + quote.length + 1);
+ await exactDecoration(page, ".study-source-annotation");
+
+ await page.reload();
+ await page.waitForFunction(() => Boolean(window.__studySourceFixture));
+ assert.deepEqual(await metadata(page), before);
+ await openNote(page);
+ await page.screenshot({
+ path: join(output, "underline-after-page-reload.png"),
+ });
+ await context.close();
+ context = undefined;
+
+ // Relaunch a new Chromium process against the same task-only profile. No
+ // in-memory source service, key or editor can carry the old projection over.
+ page = await launch();
+ assert.deepEqual(await metadata(page), before);
+ await openNote(page);
+ const noteId = before.notes[0].id;
+ const anchors = await page.evaluate(
+ (id) => window.__studySourceFixture.annotations(id),
+ noteId,
+ );
+ assert.equal(anchors.length, 1);
+ assert.equal(anchors[0].entryId, before.entries[0].id);
+ assert.deepEqual(anchors, originalAnchors);
+ await page.getByRole("button", { name: /Back to notes$/ }).click();
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await page
+ .getByRole("button", { name: "Reload source deck", exact: true })
+ .click();
+ await page.getByRole("button", { name: /^Study 1 card$/ }).click();
+ await page.getByRole("button", { name: "Show answer", exact: true }).click();
+ await page.getByRole("button", { name: "Open in note", exact: true }).click();
+ await expect(page.locator(".study-source-highlight")).toHaveText(
+ "NADH and FADH2",
+ );
+ await exactDecoration(page, ".study-source-highlight");
+ await page.screenshot({
+ path: join(output, "verified-jump-after-browser-restart.png"),
+ });
+ await page.getByRole("button", { name: "Back to card", exact: true }).click();
+ await expect(
+ page.getByRole("article", { name: "Card answer" }),
+ ).toBeVisible();
+ assert.deepEqual(errors, []);
+ await writeFile(
+ join(output, "result.json"),
+ JSON.stringify(
+ {
+ qualified:
+ "Disposable synthetic account session; actual production editor/source/WASM/IndexedDB and retained CryptoKey across Chromium restart. Vite assets stay online, account API blocked. Not account-server login, installed-offline frontend, or native input qualification.",
+ checks: [
+ "actual second-occurrence selection and atomic card/anchor save",
+ "exact saved PM range matches second occurrence",
+ "page reload restores exact entry/card/note identities and underline DOM offset",
+ "new Chromium process restores encrypted state/retained key",
+ "verified source jump after restart matches saved second-occurrence PM range and exact DOM prefix, not first equal quote",
+ "revealed review retained on return after restart",
+ ],
+ pageErrors: errors,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log("Study source encrypted page reload and browser restart passed");
+} catch (error) {
+ const page = context?.pages()[0];
+ if (page) {
+ await page.screenshot({ path: join(output, "failure.png") });
+ await writeFile(
+ join(output, "failure.txt"),
+ `${String(error)}\n${await page.locator("body").innerText()}`,
+ );
+ }
+ throw error;
+} finally {
+ await context?.close();
+}
diff --git a/web/tests/integration/study-toast-pause-browser.mjs b/web/tests/integration/study-toast-pause-browser.mjs
new file mode 100644
index 0000000..0aab545
--- /dev/null
+++ b/web/tests/integration/study-toast-pause-browser.mjs
@@ -0,0 +1,127 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+
+const output = process.env.NOTES_UI_OUTPUT ?? "test-results/study-toast-pause";
+await mkdir(output, { recursive: true });
+const browser = await chromium.launch({ channel: "chrome", headless: true });
+const context = await browser.newContext({
+ viewport: { width: 1160, height: 850 },
+});
+const page = await context.newPage(),
+ errors = [];
+let stage = "boot";
+page.on("pageerror", (error) => errors.push(error.message));
+try {
+ const now = new Date();
+ await page.clock.install({ time: now });
+ await page.goto(
+ `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-settings-browser.html`,
+ );
+ await expect(
+ page.getByRole("heading", { name: "All notes", exact: true }),
+ ).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(async () =>
+ (await import("/src/desktop/closeGuard.ts")).checkClose(),
+ ),
+ )
+ .toBe("allowed");
+ await page.getByRole("button", { name: "Study", exact: true }).click();
+ await page.getByRole("button", { name: /^Study 4 cards$/ }).click();
+ await page.getByRole("button", { name: "Show answer", exact: true }).click();
+ // Bootstrap needs its actual queued work. Freeze after the real answer exists,
+ // before creating the toast whose presentation lifetime is under test.
+ const beforeGrade = await page.evaluate(() => Date.now());
+ await page.clock.pauseAt(new Date(beforeGrade + 1000));
+ await page.getByRole("button", { name: /^Good/ }).click();
+ const toast = page.locator(".study-toast"),
+ undo = toast.getByRole("button", { name: "Undo", exact: true });
+ await expect(toast).toBeVisible();
+ await page.clock.fastForward(1000);
+ await toast.hover();
+ await undo.focus();
+ await page.mouse.move(1140, 5);
+ await expect(undo).toBeFocused();
+ stage = "focus-remains-after-pointer-leave";
+ await page.clock.fastForward(6000);
+ await expect(toast).toBeVisible();
+ await expect(undo).toBeFocused();
+ const held = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ expect(held.events).toHaveLength(1);
+ stage = "resume-remaining-time";
+ await page.getByRole("button", { name: "End session", exact: true }).focus();
+ await page.clock.fastForward(3999);
+ await expect(toast).toBeVisible();
+ await page.clock.fastForward(501);
+ await expect(toast).toHaveCount(0);
+ const after = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ expect(after.events).toEqual(held.events);
+ expect(after.cards).toEqual(held.cards);
+ stage = "settings-cover-pause";
+ await page.getByRole("button", { name: "Show answer", exact: true }).click();
+ await page.getByRole("button", { name: /^Good/ }).click();
+ await expect(toast).toBeVisible();
+ await page.clock.fastForward(1000);
+ await expect
+ .poll(() =>
+ page.evaluate(async () =>
+ (await import("/src/desktop/closeGuard.ts")).checkClose(),
+ ),
+ )
+ .toBe("allowed");
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ const settings = page.getByRole("dialog", { name: "Settings", exact: true });
+ await expect(settings).toBeVisible();
+ await page.clock.fastForward(6000);
+ await expect(toast).toHaveCount(1);
+ const covered = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ expect(covered.events).toHaveLength(2);
+ await settings.getByRole("button", { name: "Done", exact: true }).click();
+ await expect(settings).toHaveCount(0);
+ await page.clock.fastForward(3999);
+ await expect(toast).toBeVisible();
+ await page.clock.fastForward(501);
+ await expect(toast).toHaveCount(0);
+ const uncovered = await page.evaluate(() =>
+ window.__studySettingsFixture.metadata(),
+ );
+ expect(uncovered.events).toEqual(covered.events);
+ expect(uncovered.cards).toEqual(covered.cards);
+ expect(errors).toEqual([]);
+ stage = "passed";
+ await writeFile(
+ `${output}/result.json`,
+ JSON.stringify(
+ {
+ qualified:
+ "Disposable synthetic account; actual App/WASM/IndexedDB with browser-controlled presentation clock. No native input, account backend or real-time frame-rate claim.",
+ stage,
+ eventCount: uncovered.events.length,
+ cardsAndEventsUnchangedByToastExpiry: true,
+ settingsCoverPause: true,
+ errors,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log(
+ "PASS: actual focused toast survives pointer leave and resumes only its remaining dwell; expiry does not change grades.",
+ );
+} catch (error) {
+ await writeFile(
+ `${output}/result.json`,
+ JSON.stringify({ stage, error: String(error), errors }, null, 2),
+ );
+ throw error;
+} finally {
+ await context.close();
+ await browser.close();
+}
diff --git a/web/tests/ui/study-preview.html b/web/tests/ui/study-preview.html
new file mode 100644
index 0000000..99345f3
--- /dev/null
+++ b/web/tests/ui/study-preview.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Study UI test fixture
+
+
+
+ Study UI test fixture · memory only · not encrypted storage
+
+
+
+
+
diff --git a/web/tests/ui/study-preview.tsx b/web/tests/ui/study-preview.tsx
new file mode 100644
index 0000000..486dc93
--- /dev/null
+++ b/web/tests/ui/study-preview.tsx
@@ -0,0 +1,47 @@
+import { createRoot } from "react-dom/client";
+import { StudyView } from "../../src/ui/StudyView";
+import type {
+ CollectionsState,
+ CollectionsStore,
+} from "../../src/collections/contracts";
+import { CollectionsRevisionConflictError } from "../../src/collections/contracts";
+import {
+ emptyStudy,
+ validateCollections,
+} from "../../src/collections/validation";
+import "../../src/styles/app.css";
+
+// Test-only memory store. Production CollectionsHost always opens the real sealed store.
+let state: CollectionsState = {
+ version: 3,
+ epoch: crypto.randomUUID(),
+ revision: 0,
+ study: emptyStudy(Intl.DateTimeFormat().resolvedOptions().timeZone),
+ journal: null,
+ clips: { items: [], receipts: [] },
+};
+const listeners = new Set<() => void>();
+const store: CollectionsStore = {
+ read: async () => structuredClone(state),
+ update: async (expected, change) => {
+ if (expected.epoch !== state.epoch || expected.revision !== state.revision)
+ throw new CollectionsRevisionConflictError(structuredClone(state));
+ state = validateCollections({
+ ...change(structuredClone(state)),
+ revision: state.revision + 1,
+ });
+ listeners.forEach((listener) => listener());
+ return structuredClone(state);
+ },
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ persistence: () => ({ status: "ready" }),
+ close: () => listeners.clear(),
+};
+createRoot(document.getElementById("root")!).render(
+ ,
+);
diff --git a/web/tests/ui/study-source-routing.test.tsx b/web/tests/ui/study-source-routing.test.tsx
new file mode 100644
index 0000000..e646444
--- /dev/null
+++ b/web/tests/ui/study-source-routing.test.tsx
@@ -0,0 +1,270 @@
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { Workspace } from "../../src/ui/Workspace";
+import type { Note, NotesStore } from "../../src/domain/contracts";
+import type { NotesLibrary } from "../../src/library/contracts";
+import type {
+ CollectionsStore,
+ CollectionsState,
+} from "../../src/collections/contracts";
+import type {
+ StudySourceService,
+ SourceResolution,
+} from "../../src/study/sourceContracts";
+import { emptyStudy } from "../../src/collections/validation";
+const model = vi.hoisted(() => ({
+ owners: null as {
+ collections: CollectionsStore;
+ service: StudySourceService;
+ error: string;
+ retry: () => void;
+ } | null,
+}));
+vi.mock("../../src/ui/studySourceOwners", () => ({
+ useStudySourceOwners: () => model.owners,
+}));
+vi.mock("../../src/ui/CollectionsHost", () => ({
+ CollectionsHost: ({
+ onOpenSource,
+ }: {
+ onOpenSource?: (request: { entryId: string }) => void;
+ }) => (
+
+
+
+ ),
+}));
+vi.mock("../../src/ui/NoteEditor", () => ({
+ NoteEditor: (
+ props: Parameters[0],
+ ) => (
+
+ Source reader
+
+
+
+ ),
+}));
+const epoch = "11111111-1111-4111-8111-111111111111",
+ entryId = "33333333-3333-4333-8333-333333333333";
+const note: Note = {
+ id: "source-note",
+ revision: 1,
+ title: "Source",
+ tags: [],
+ document: {
+ type: "doc",
+ content: [{ type: "paragraph", content: [{ type: "text", text: "NADH" }] }],
+ },
+ createdAt: "2026-11-11T00:00:00.000Z",
+ updatedAt: "2026-11-11T00:00:00.000Z",
+ trashedAt: null,
+};
+const attached: SourceResolution = {
+ status: "attached",
+ entryId,
+ anchorId: "44444444-4444-4444-8444-444444444444",
+ anchorRevision: 1,
+ noteId: note.id,
+ noteRevision: 1,
+ documentDigest: "a".repeat(64),
+ from: 1,
+ to: 5,
+ quote: "NADH",
+};
+// Explicit routing/projection boundary doubles; encrypted passage authority is covered by the real browser fixture.
+function fixture() {
+ const collection: CollectionsState = {
+ version: 3,
+ epoch,
+ revision: 1,
+ study: {
+ ...emptyStudy(),
+ entries: [
+ {
+ id: entryId,
+ revision: 1,
+ deckId: "22222222-2222-4222-8222-222222222222",
+ type: "basic",
+ front: "Question",
+ back: "NADH",
+ tags: [],
+ source: {
+ version: 2,
+ noteId: note.id,
+ noteRevision: 1,
+ quote: "NADH",
+ libraryEpoch: epoch,
+ anchorId: attached.status === "attached" ? attached.anchorId : "",
+ },
+ },
+ ],
+ },
+ journal: null,
+ clips: { items: [], receipts: [] },
+ };
+ const collections: CollectionsStore = {
+ read: vi.fn(async () => collection),
+ update: vi.fn(),
+ subscribe: () => () => {},
+ persistence: () => ({ status: "ready" }),
+ close: vi.fn(),
+ };
+ const service: StudySourceService = {
+ prepareCreate: vi.fn(),
+ prepareUndo: vi.fn(),
+ commit: vi.fn(),
+ resolve: vi.fn(async () => attached),
+ annotations: vi.fn(async () => []),
+ subscribe: () => () => {},
+ persistence: () => ({ status: "ready" }),
+ onInvalidate: () => () => {},
+ close: vi.fn(),
+ };
+ const library: NotesLibrary = {
+ read: vi.fn(async () => ({
+ mode: "ready" as const,
+ snapshot: {
+ token: { epoch, revision: 1 },
+ notes: [note],
+ tags: [],
+ metadata: [],
+ preferences: { sort: "edited" as const },
+ },
+ })),
+ updateWithSourceSteps: vi.fn(),
+ replaceDocument: vi.fn(),
+ setPinned: vi.fn(),
+ setSort: vi.fn(),
+ createTag: vi.fn(),
+ renameTag: vi.fn(),
+ recolorTag: vi.fn(),
+ mergeTag: vi.fn(),
+ deleteTag: vi.fn(),
+ history: vi.fn(),
+ version: vi.fn(),
+ restoreVersion: vi.fn(),
+ changeTrash: vi.fn(),
+ undoTrash: vi.fn(),
+ emptyTrash: vi.fn(),
+ eraseLegacy: vi.fn(),
+ };
+ const store: NotesStore = {
+ list: vi.fn(async () => [note]),
+ get: vi.fn(async () => note),
+ create: vi.fn(),
+ update: vi.fn(),
+ trash: vi.fn(),
+ restore: vi.fn(),
+ erase: vi.fn(),
+ subscribe: () => () => {},
+ persistence: () => ({ status: "ready" }),
+ close: vi.fn(),
+ };
+ model.owners = { collections, service, error: "", retry: vi.fn() };
+ const view = render(
+ ,
+ );
+ return { view, service };
+}
+beforeEach(() => {
+ HTMLDialogElement.prototype.showModal = function () {
+ this.setAttribute("open", "");
+ };
+ HTMLDialogElement.prototype.close = function () {
+ this.removeAttribute("open");
+ };
+ vi.stubGlobal("matchMedia", () => ({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ URL.createObjectURL = vi.fn(() => "blob:unrelated-export");
+ URL.revokeObjectURL = vi.fn();
+});
+afterEach(() => vi.unstubAllGlobals());
+async function readerWithExport() {
+ fireEvent.click(await screen.findByRole("button", { name: "Study" }));
+ fireEvent.click(await screen.findByText("Open saved source"));
+ await screen.findByRole("heading", { name: "Source reader" });
+ fireEvent.click(screen.getByText("Export this note"));
+ fireEvent.click(
+ within(screen.getByRole("dialog", { name: "Export Markdown" })).getByRole(
+ "button",
+ { name: "Export" },
+ ),
+ );
+}
+it("clears stale projection feedback on a fresh source jump without deleting an unrelated export notice", async () => {
+ const f = fixture();
+ await readerWithExport();
+ fireEvent.click(screen.getByText("Report stale projection"));
+ await screen.findByText(/This saved passage target changed/);
+ expect(f.view.container.querySelector(".notice")).toHaveTextContent(
+ "Export prepared",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Back to card" }));
+ fireEvent.click(screen.getByText("Open saved source"));
+ await screen.findByRole("heading", { name: "Source reader" });
+ expect(
+ screen.queryByText(/This saved passage target changed/),
+ ).not.toBeInTheDocument();
+ expect(f.view.container.querySelector(".notice")).toHaveTextContent(
+ "Export prepared",
+ );
+ expect(f.service.resolve).toHaveBeenCalledTimes(2);
+});
+it("shows one stale fallback warning and preserves the unrelated notice when ordinary Open note succeeds", async () => {
+ const f = fixture();
+ await readerWithExport();
+ fireEvent.click(screen.getByText("Report stale projection"));
+ await screen.findByText(/This saved passage target changed/);
+ fireEvent.click(screen.getByRole("button", { name: "Back to card" }));
+ vi.mocked(f.service.resolve).mockResolvedValue({
+ status: "unavailable",
+ reason: "stale",
+ });
+ fireEvent.click(screen.getByText("Open saved source"));
+ await screen.findByText("The source passage changed.");
+ fireEvent.click(
+ screen.getByRole("button", { name: "Open note without passage" }),
+ );
+ await screen.findByRole("heading", { name: "Source reader" });
+ expect(screen.getAllByText("The source passage changed.")).toHaveLength(1);
+ expect(
+ screen.queryByText(/This saved passage target changed/),
+ ).not.toBeInTheDocument();
+ expect(f.view.container.querySelector(".notice")).toHaveTextContent(
+ "Export prepared",
+ );
+ await waitFor(() => expect(f.service.resolve).toHaveBeenCalledTimes(3));
+});
diff --git a/web/tests/ui/study.test.tsx b/web/tests/ui/study.test.tsx
new file mode 100644
index 0000000..1e6999b
--- /dev/null
+++ b/web/tests/ui/study.test.tsx
@@ -0,0 +1,710 @@
+import { onLock } from "../../src/account/session";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { StudyView } from "../../src/ui/StudyView";
+import type {
+ CollectionsState,
+ CollectionsStore,
+ StudyEntry,
+} from "../../src/collections/contracts";
+import { CollectionsRevisionConflictError } from "../../src/collections/contracts";
+import {
+ emptyStudy,
+ validateCollections,
+} from "../../src/collections/validation";
+import { createDeck, saveEntry } from "../../src/study/model";
+import { checkClose } from "../../src/desktop/closeGuard";
+import { Workspace } from "../../src/ui/Workspace";
+import { openCollectionsStore } from "../../src/collections/store";
+import type {
+ AccountSession,
+ Note,
+ NotesStore,
+} from "../../src/domain/contracts";
+
+// Labelled component-boundary actors only; production still requires its genuine owner registry.
+const boundaryActors = vi.hoisted(
+ () =>
+ new WeakMap<
+ object,
+ {
+ closed: boolean;
+ epochs: Set<(epoch: string) => void>;
+ closes: Set<() => void>;
+ }
+ >(),
+);
+vi.mock("../../src/collections/store", () => {
+ const access = (store: object) => {
+ const actor = boundaryActors.get(store);
+ if (!actor || actor.closed)
+ throw new Error("locked: Test Collections boundary is closed.");
+ return actor;
+ };
+ return {
+ openCollectionsStore: vi.fn(),
+ assertCollectionsAccess: (store: object) => {
+ access(store);
+ },
+ onCollectionsClose: (store: object, listener: () => void) => {
+ const actor = access(store);
+ actor.closes.add(listener);
+ return () => actor.closes.delete(listener);
+ },
+ onCollectionsEpochChange: (
+ store: object,
+ listener: (epoch: string) => void,
+ ) => {
+ const actor = access(store);
+ actor.epochs.add(listener);
+ return () => actor.epochs.delete(listener);
+ },
+ };
+});
+const fixtureStores = new Set();
+vi.mock("../../src/ui/RichEditor", () => ({
+ RichEditor: () => Editor test boundary
,
+}));
+
+beforeEach(() => {
+ HTMLDialogElement.prototype.showModal = function () {
+ this.setAttribute("open", "");
+ };
+ HTMLDialogElement.prototype.close = function () {
+ this.removeAttribute("open");
+ };
+});
+afterEach(() => {
+ for (const store of fixtureStores) store.close();
+ fixtureStores.clear();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+function memoryStore(populated = false, sourceNoteId: string | null = null) {
+ const at = new Date(Date.now() - 60_000);
+ let study = emptyStudy("UTC");
+ if (populated) {
+ study = createDeck(study, "Biology", at);
+ const entry: StudyEntry = {
+ id: crypto.randomUUID(),
+ deckId: study.decks[0]!.id,
+ type: "basic",
+ front: "Where does glycolysis take place?",
+ back: "In the cytoplasm",
+ tags: [],
+ source: sourceNoteId
+ ? {
+ noteId: sourceNoteId,
+ noteRevision: 1,
+ quote: "Glycolysis takes place in the cytoplasm.",
+ }
+ : null,
+ revision: 1,
+ };
+ study = saveEntry(study, entry, at);
+ }
+ let state: CollectionsState = {
+ version: 3,
+ epoch: crypto.randomUUID(),
+ revision: 0,
+ study,
+ journal: null,
+ clips: { items: [], receipts: [] },
+ };
+ const listeners = new Set<() => void>();
+ const actor = {
+ closed: false,
+ epochs: new Set<(epoch: string) => void>(),
+ closes: new Set<() => void>(),
+ };
+ const assertOpen = () => {
+ if (actor.closed)
+ throw new Error("locked: Test Collections boundary is closed.");
+ };
+ let stopLock = () => {};
+ let unknownResult = false;
+ const update = vi.fn(async (expected, change) => {
+ assertOpen();
+ if (expected.epoch !== state.epoch || expected.revision !== state.revision)
+ throw new CollectionsRevisionConflictError(structuredClone(state));
+ state = validateCollections({
+ ...change(structuredClone(state)),
+ revision: state.revision + 1,
+ });
+ if (unknownResult) {
+ unknownResult = false;
+ throw new Error("Commit response lost");
+ }
+ listeners.forEach((listener) => listener());
+ return structuredClone(state);
+ });
+ const store: CollectionsStore = {
+ read: vi.fn(async () => {
+ assertOpen();
+ return structuredClone(state);
+ }),
+ update,
+ subscribe: (listener) => {
+ assertOpen();
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ persistence: () => ({ status: "ready" }),
+ close: vi.fn(() => {
+ if (actor.closed) return;
+ actor.closed = true;
+ for (const callback of actor.closes) callback();
+ actor.closes.clear();
+ actor.epochs.clear();
+ listeners.clear();
+ stopLock();
+ }),
+ };
+ boundaryActors.set(store, actor);
+ fixtureStores.add(store);
+ stopLock = onLock(() => store.close());
+ return {
+ store,
+ update,
+ state: () => structuredClone(state),
+ loseNextCommitResponse: () => {
+ unknownResult = true;
+ },
+ erase: () => {
+ state = {
+ ...state,
+ epoch: crypto.randomUUID(),
+ revision: state.revision + 1,
+ study: emptyStudy("UTC"),
+ journal: null,
+ };
+ assertOpen();
+ actor.epochs.forEach((listener) => listener(state.epoch));
+ listeners.forEach((listener) => listener());
+ },
+ };
+}
+async function finishMotion(phase: string, animationName: string) {
+ const article = document.querySelector(
+ `.study-flash[data-study-motion="${phase}"]`,
+ );
+ expect(article).not.toBeNull();
+ const event = new Event("animationend", { bubbles: true });
+ Object.defineProperty(event, "animationName", { value: animationName });
+ await act(async () => {
+ fireEvent(article!, event);
+ // This consumer import graph can make React choose WebKit's jsdom event
+ // alias. Both dispatch through the real guarded handler on the same node;
+ // neither calls the controller or advances domain state directly.
+ const prefixed = new Event("webkitAnimationEnd", { bubbles: true });
+ Object.defineProperty(prefixed, "animationName", { value: animationName });
+ fireEvent(article!, prefixed);
+ });
+}
+async function finishReveal() {
+ // Exiting content deliberately has no accessible name while aria-hidden.
+ const outgoing = document.querySelector(
+ 'article.study-flash[aria-label="Card question"][data-study-motion="reveal-exit"]',
+ );
+ expect(outgoing).not.toBeNull();
+ expect(outgoing).toHaveAttribute("data-study-motion", "reveal-exit");
+ expect(outgoing).toHaveAttribute("inert");
+ expect(outgoing).toHaveAttribute("aria-hidden", "true");
+ await finishMotion("reveal-exit", "study-reveal-out");
+ expect(screen.getByRole("article", { name: "Card answer" })).toHaveAttribute(
+ "data-study-motion",
+ "answer-enter",
+ );
+ await finishMotion("answer-enter", "study-answer-in");
+}
+async function revealAnswer() {
+ fireEvent.click(screen.getByRole("button", { name: "Show answer" }));
+ await finishReveal();
+}
+async function openDeck() {
+ fireEvent.click(await screen.findByRole("button", { name: "Biology" }));
+}
+function closeState() {
+ let state: ReturnType = "allowed";
+ act(() => {
+ state = checkClose();
+ });
+ return state;
+}
+
+describe("local Study", () => {
+ it("clears the held source editor when a remote erase replaces the collections epoch", async () => {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: query.includes("min-width"),
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ const id = crypto.randomUUID();
+ const { store, erase } = memoryStore(true, id);
+ vi.mocked(openCollectionsStore).mockResolvedValue(store);
+ const note: Note = {
+ id,
+ title: "Erased source",
+ document: { type: "doc", content: [] },
+ tags: [],
+ createdAt: "2026-11-11T00:00:00Z",
+ updatedAt: "2026-11-11T00:00:00Z",
+ trashedAt: null,
+ revision: 1,
+ };
+ const notes: NotesStore = {
+ list: vi.fn(async () => [note]),
+ get: vi.fn(async () => note),
+ create: vi.fn(),
+ update: vi.fn(),
+ trash: vi.fn(),
+ restore: vi.fn(),
+ erase: vi.fn(),
+ subscribe: () => () => {},
+ persistence: () => ({ status: "ready" }),
+ close: vi.fn(),
+ };
+ const session: AccountSession = {
+ accountId: crypto.randomUUID(),
+ deviceId: crypto.randomUUID(),
+ deviceState: "trusted",
+ email: "study-fixture@example.test",
+ };
+ render();
+ fireEvent.click(await screen.findByRole("button", { name: "Study" }));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ await revealAnswer();
+ fireEvent.click(screen.getByRole("button", { name: "Open in note" }));
+ await screen.findByDisplayValue("Erased source");
+ await act(async () => erase());
+ expect(screen.queryByLabelText("Note title")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Back to card" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("No decks yet")).toBeVisible();
+ });
+ it("does not claim done for the day when Again is due soon, and refreshes the home queue", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-11-11T08:00:00Z"));
+ const { store } = memoryStore(true);
+ await act(async () => {
+ render();
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Study 1 card" }));
+ await revealAnswer();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /^Again/ }));
+ });
+ await finishMotion("confirmed-exit", "study-card-out");
+ expect(
+ screen.getByRole("heading", { name: "Session complete" }),
+ ).toBeVisible();
+ expect(
+ screen.queryByText("That is everything due. Nothing is waiting for you."),
+ ).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: /^Done$/ }));
+ expect(
+ screen.queryByRole("button", { name: "Study 1 card" }),
+ ).not.toBeInTheDocument();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60_010);
+ });
+ expect(screen.getByRole("button", { name: "Study 1 card" })).toBeVisible();
+ });
+
+ it("fences a late old-epoch grade response after erase", async () => {
+ const { store, update, state, erase } = memoryStore(true);
+ let finish!: (state: CollectionsState) => void;
+ let oldResult!: CollectionsState;
+ update.mockImplementationOnce((_expected, change) => {
+ oldResult = validateCollections({ ...change(state()), revision: 1 });
+ return new Promise((resolve) => {
+ finish = resolve;
+ });
+ });
+ render();
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ await revealAnswer();
+ fireEvent.click(screen.getByRole("button", { name: /^Good/ }));
+ await act(async () => erase());
+ await screen.findByText("No decks yet");
+ await act(async () => finish(oldResult));
+ expect(screen.queryByLabelText("Card answer")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Undo" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("No decks yet")).toBeVisible();
+ expect(closeState()).toBe("allowed");
+ });
+ it("preserves the revealed review and store through the guarded source-note round trip", async () => {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: query.includes("min-width"),
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ const id = crypto.randomUUID();
+ const { store } = memoryStore(true, id);
+ vi.mocked(openCollectionsStore).mockResolvedValue(store);
+ const note: Note = {
+ id,
+ title: "Biology source",
+ document: { type: "doc", content: [{ type: "paragraph" }] },
+ tags: [],
+ createdAt: "2026-11-11T00:00:00Z",
+ updatedAt: "2026-11-11T00:00:00Z",
+ trashedAt: null,
+ revision: 1,
+ };
+ const notes: NotesStore = {
+ list: vi.fn(async () => [note]),
+ get: vi.fn(async () => note),
+ create: vi.fn(),
+ update: vi.fn(),
+ trash: vi.fn(),
+ restore: vi.fn(),
+ erase: vi.fn(),
+ subscribe: () => () => {},
+ persistence: () => ({ status: "ready" }),
+ close: vi.fn(),
+ };
+ const session: AccountSession = {
+ accountId: crypto.randomUUID(),
+ deviceId: crypto.randomUUID(),
+ deviceState: "trusted",
+ email: "study-fixture@example.test",
+ };
+ render();
+ await screen.findByRole("button", { name: "Study" });
+ expect(openCollectionsStore).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Study" }));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ await revealAnswer();
+ fireEvent.click(screen.getByRole("button", { name: "Open in note" }));
+ await screen.findByDisplayValue("Biology source");
+ expect(screen.getByLabelText("Card answer")).not.toBeVisible();
+ expect(store.close).not.toHaveBeenCalled();
+ fireEvent.change(screen.getByLabelText("Note title"), {
+ target: { value: "Unsaved source" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Back to card" }));
+ expect(screen.getByDisplayValue("Unsaved source")).toBeVisible();
+ // Returning to the review must not discard the source note's dirty draft.
+ expect(
+ screen.getByText(
+ "Your latest changes are only in this window. Save or copy your text before leaving.",
+ ),
+ ).toBeVisible();
+ });
+
+ it("restores the same answer after opening and returning from a clean source note", async () => {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: query.includes("min-width"),
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ const id = crypto.randomUUID();
+ const { store } = memoryStore(true, id);
+ vi.mocked(openCollectionsStore).mockResolvedValue(store);
+ const note: Note = {
+ id,
+ title: "Source",
+ document: { type: "doc", content: [] },
+ tags: [],
+ createdAt: "2026-11-11T00:00:00Z",
+ updatedAt: "2026-11-11T00:00:00Z",
+ trashedAt: null,
+ revision: 1,
+ };
+ const notes: NotesStore = {
+ list: vi.fn(async () => [note]),
+ get: vi.fn(async () => note),
+ create: vi.fn(),
+ update: vi.fn(),
+ trash: vi.fn(),
+ restore: vi.fn(),
+ erase: vi.fn(),
+ subscribe: () => () => {},
+ persistence: () => ({ status: "ready" }),
+ close: vi.fn(),
+ };
+ const session: AccountSession = {
+ accountId: crypto.randomUUID(),
+ deviceId: crypto.randomUUID(),
+ deviceState: "trusted",
+ email: "study-fixture@example.test",
+ };
+ render();
+ fireEvent.click(await screen.findByRole("button", { name: "Study" }));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ await revealAnswer();
+ fireEvent.click(screen.getByRole("button", { name: "Open in note" }));
+ await screen.findByDisplayValue("Source");
+ fireEvent.click(screen.getByRole("button", { name: "Back to card" }));
+ expect(screen.getByLabelText("Card answer")).toBeVisible();
+ expect(screen.getByText("1 of 1")).toBeVisible();
+ expect(openCollectionsStore).toHaveBeenCalledTimes(1);
+ expect(store.close).not.toHaveBeenCalled();
+ });
+
+ it("uses the revealed preview instant after time passes and focus refreshes", async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-11-11T08:00:00Z"));
+ const { store, state } = memoryStore(true);
+ render();
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ await revealAnswer();
+ const expectedDue = screen
+ .getByRole("button", { name: /^Good/ })
+ .querySelector("time")!.dateTime;
+ vi.setSystemTime(new Date("2026-11-11T09:00:00Z"));
+ fireEvent.focus(window);
+ fireEvent.click(screen.getByRole("button", { name: /^Good/ }));
+ await screen.findByRole("heading", { name: "Done for today" });
+ expect(state().study.events[0]!.at).toBe("2026-11-11T08:00:00.000Z");
+ expect(state().study.cards[0]!.schedule.due).toBe(expectedDue);
+ });
+
+ it("does not intercept Space on the focused End session button", async () => {
+ const { store } = memoryStore(true);
+ render();
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ const end = screen.getByRole("button", { name: "End session" });
+ end.focus();
+ expect(fireEvent.keyDown(end, { key: " " })).toBe(true);
+ expect(screen.queryByLabelText("Card answer")).not.toBeInTheDocument();
+ fireEvent.click(end);
+ expect(screen.queryByLabelText("Card question")).not.toBeInTheDocument();
+ });
+ it("reconciles a committed grade after a lost response without grading twice", async () => {
+ const { store, update, state, loseNextCommitResponse } = memoryStore(true);
+ render();
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ await revealAnswer();
+ loseNextCommitResponse();
+ fireEvent.click(screen.getByRole("button", { name: /^Good/ }));
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
+ expect(state().study.events).toHaveLength(1);
+ fireEvent.click(await screen.findByRole("button", { name: "Reload" }));
+ await screen.findByRole("heading", { name: "Done for today" });
+ expect(update).toHaveBeenCalledTimes(1);
+ expect(state().study.events).toHaveLength(1);
+ expect(state().study.cards[0]!.schedule.repetitions).toBe(1);
+ });
+
+ it("clears erased drafts and their close guard when a replacement epoch arrives", async () => {
+ const { store, erase } = memoryStore(true);
+ render();
+ await openDeck();
+ fireEvent.click(screen.getByRole("button", { name: "Add card" }));
+ fireEvent.change(screen.getByLabelText("Front"), {
+ target: { value: "Private erased draft" },
+ });
+ expect(closeState()).toBe("draft");
+ act(() => {
+ erase();
+ // Assert inside the observer turn, before any async read can settle.
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(
+ screen.queryByDisplayValue("Private erased draft"),
+ ).not.toBeInTheDocument();
+ expect(checkClose()).toBe("allowed");
+ });
+ await screen.findByText("No decks yet");
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(
+ screen.queryByDisplayValue("Private erased draft"),
+ ).not.toBeInTheDocument();
+ expect(closeState()).toBe("allowed");
+ });
+ it("creates actual decks and reversed cards, retaining a failed draft and close guard", async () => {
+ const { store, update, state } = memoryStore();
+ render();
+ await screen.findByText("No decks yet");
+ fireEvent.click(screen.getAllByRole("button", { name: "New deck" })[0]!);
+ const deckDialog = screen.getByRole("dialog", { name: "New deck" });
+ fireEvent.change(within(deckDialog).getByLabelText("Name"), {
+ target: { value: "My deck" },
+ });
+ expect(closeState()).toBe("draft");
+ fireEvent.click(
+ within(deckDialog).getByRole("button", { name: "Create deck" }),
+ );
+ await screen.findByRole("heading", { name: "My deck" });
+ expect(state().study.decks).toHaveLength(1);
+ expect(closeState()).toBe("allowed");
+ fireEvent.click(screen.getAllByRole("button", { name: "Add card" })[0]!);
+ const dialog = screen.getByRole("dialog", { name: "Add card" });
+ fireEvent.click(within(dialog).getByRole("button", { name: "Reversed" }));
+ fireEvent.change(within(dialog).getByLabelText("Front"), {
+ target: { value: "Glycolysis" },
+ });
+ fireEvent.change(within(dialog).getByLabelText("Back"), {
+ target: { value: "Breaks glucose into pyruvate" },
+ });
+ update.mockRejectedValueOnce(new Error("Storage full"));
+ fireEvent.click(within(dialog).getByRole("button", { name: "Add card" }));
+ await within(dialog).findByText(
+ "Couldn’t save this card. Your draft is still here. Try again.",
+ );
+ expect(within(dialog).getByLabelText("Front")).toHaveValue("Glycolysis");
+ expect(state().study.cards).toHaveLength(0);
+ expect(closeState()).toBe("draft");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Add card" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
+ );
+ expect(state().study.cards).toHaveLength(2);
+ expect(screen.getByText("2 cards · 2 due")).toBeVisible();
+ expect(closeState()).toBe("allowed");
+ });
+
+ it("reveals, grades once, persists its schedule, and undoes the actual review", async () => {
+ const { store, update, state } = memoryStore(true);
+ render();
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ expect(screen.queryByText("In the cytoplasm")).not.toBeInTheDocument();
+ const view = screen.getByRole("region", { name: "Study" });
+ fireEvent.keyDown(view, { key: "1" });
+ expect(update).not.toHaveBeenCalled();
+ fireEvent.keyDown(view, { key: " " });
+ await finishReveal();
+ expect(screen.getByText("In the cytoplasm")).toBeVisible();
+ fireEvent.keyDown(view, { key: "3" });
+ fireEvent.keyDown(view, { key: "3", repeat: true });
+ await screen.findByRole("heading", { name: "Done for today" });
+ expect(update).toHaveBeenCalledTimes(1);
+ expect(state().study.events).toHaveLength(1);
+ expect(state().study.cards[0]!.schedule.state).toBe("review");
+ fireEvent.click(screen.getByRole("button", { name: "Undo" }));
+ await screen.findByLabelText("Card answer");
+ expect(state().study.events[0]!.undoneAt).not.toBeNull();
+ expect(state().study.cards[0]!.schedule.state).toBe("new");
+ expect(screen.getByText("1 of 1")).toBeVisible();
+ });
+
+ it("keeps a revealed answer and queue position when grading fails", async () => {
+ const { store, update, state } = memoryStore(true);
+ render();
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Study 1 card" }),
+ );
+ await revealAnswer();
+ update.mockRejectedValueOnce(new Error("Write failed"));
+ fireEvent.click(screen.getByRole("button", { name: /^Good/ }));
+ await screen.findByText(
+ "Couldn’t confirm this review. Reload to check whether it was saved.",
+ );
+ expect(screen.getByLabelText("Card answer")).toBeVisible();
+ expect(screen.getByText("1 of 1")).toBeVisible();
+ expect(state().study.events).toHaveLength(0);
+ fireEvent.click(screen.getByRole("button", { name: "Reload" }));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: /^Good/ })).toBeEnabled(),
+ );
+ fireEvent.click(screen.getByRole("button", { name: /^Good/ }));
+ await screen.findByRole("heading", { name: "Done for today" });
+ expect(state().study.events).toHaveLength(1);
+ });
+
+ it("requires an explicit reload after CAS conflict without replaying the draft", async () => {
+ const { store, update, state } = memoryStore(true);
+ render();
+ await openDeck();
+ fireEvent.click(screen.getByRole("button", { name: "Add card" }));
+ const dialog = screen.getByRole("dialog", { name: "Add card" });
+ fireEvent.change(within(dialog).getByLabelText("Front"), {
+ target: { value: "My question" },
+ });
+ fireEvent.change(within(dialog).getByLabelText("Back"), {
+ target: { value: "My answer" },
+ });
+ update.mockRejectedValueOnce(new CollectionsRevisionConflictError(state()));
+ fireEvent.click(within(dialog).getByRole("button", { name: "Add card" }));
+ await within(dialog).findByText(
+ "Study changed in another window. Your draft is still here. Reload before trying again.",
+ );
+ expect(within(dialog).getByLabelText("Front")).toHaveValue("My question");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Reload" }));
+ await waitFor(() =>
+ expect(
+ within(dialog).queryByRole("button", { name: "Reload" }),
+ ).not.toBeInTheDocument(),
+ );
+ expect(update).toHaveBeenCalledTimes(1);
+ expect(state().study.entries).toHaveLength(1);
+ fireEvent.click(within(dialog).getByRole("button", { name: "Add card" }));
+ await waitFor(() => expect(state().study.entries).toHaveLength(2));
+ });
+
+ it("validates cloze blanks and derives actual question and answer cards", async () => {
+ const { store, state } = memoryStore(true);
+ render();
+ await openDeck();
+ fireEvent.click(screen.getByRole("button", { name: "Add card" }));
+ const dialog = screen.getByRole("dialog", { name: "Add card" });
+ fireEvent.click(
+ within(dialog).getByRole("button", { name: "Fill in the blank" }),
+ );
+ fireEvent.change(within(dialog).getByLabelText("Text"), {
+ target: { value: "The capital is {{capital}}." },
+ });
+ fireEvent.change(within(dialog).getByLabelText("Answer 1"), {
+ target: { value: "Paris" },
+ });
+ fireEvent.click(within(dialog).getByRole("button", { name: "Add card" }));
+ expect(within(dialog).getByRole("alert")).toHaveTextContent(
+ "Give each blank a unique ID",
+ );
+ fireEvent.change(within(dialog).getByLabelText("Blank ID 1"), {
+ target: { value: "capital" },
+ });
+ fireEvent.click(within(dialog).getByRole("button", { name: "Add card" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
+ );
+ expect(state().study.cards).toHaveLength(2);
+ expect(
+ screen.getByRole("button", { name: "The capital is [...]." }),
+ ).toBeVisible();
+ });
+
+ it("loads progress from actual reviews and persists hiding the streak", async () => {
+ const { store, state } = memoryStore(true);
+ render();
+ fireEvent.click(await screen.findByRole("button", { name: "Progress" }));
+ expect(screen.getByText("Streak 0 days")).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Hide streak" }));
+ await screen.findByRole("button", { name: "Show streak" });
+ expect(state().study.preferences.showStreak).toBe(false);
+ expect(screen.queryByText("Streak 0 days")).not.toBeInTheDocument();
+ });
+});